Replace dasher-pack with unified animation-pack using original Blender bone names

This commit is contained in:
2026-06-15 14:28:26 +08:00
parent 9dd3c59edf
commit 844ec194cb
297 changed files with 28680 additions and 1884 deletions
@@ -0,0 +1,71 @@
@tool
class_name McpNodeValidator
extends RefCounted
## Shared resolve-or-error helper that subsumes the 38+ sites where
## handlers each rolled their own "is the editor ready, does the path
## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard.
##
## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error
## vocabulary.
## Local const names alias the preloaded scripts. The naming choice is
## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER`
## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for
## `error_codes.gd`. The transient #398 parse errors were caused by the
## old runner scanning a mixed old/new plugin snapshot and seeing stale
## Script-object content; the runner now writes one v(N+1) snapshot before
## its scan.
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Resolve a scene-relative path to the live Node, or return a structured
## error dict.
##
## Success shape: `{"node": Node, "scene_root": Node, "path": String}`.
## Error shape: matches `ErrorCodes.make(...)` so callers can
## `return resolved` to propagate.
##
## Errors (in order checked):
## - `MISSING_REQUIRED_PARAM`: `node_path` is empty
## - `EDITOR_NOT_READY`: no scene open
## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open
## scene's path doesn't match
## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root
##
## `param_name` is the agent-facing name reported in the
## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path",
## "player_path", "target_path", etc. so the error reads like the
## hand-written messages it replaces.
static func resolve_or_error(
node_path: String,
param_name: String = "path",
scene_file: String = "",
) -> Dictionary:
if node_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: %s" % param_name,
)
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
var scene_root: Node = scene_check.node
var node := ScenePath.resolve(node_path, scene_root)
if node == null:
return ErrorCodes.make(
ErrorCodes.NODE_NOT_FOUND,
ScenePath.format_node_error(node_path, scene_root),
)
return {"node": node, "scene_root": scene_root, "path": node_path}
## When the caller needs the scene root but no specific node yet — e.g.
## handlers that walk children or filter by group. Returns either
## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict.
static func require_scene_or_error(scene_file: String = "") -> Dictionary:
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
return {"scene_root": scene_check.node}
@@ -0,0 +1 @@
uid://dn75jifad0ghx
@@ -0,0 +1,48 @@
@tool
class_name McpParamValidators
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Type-check a JSON-decoded param Variant before assigning it into a typed
## GDScript local. The dispatcher only catches handler crashes as an opaque
## "malformed result" (issue #210), so a typed assignment like
## var group: String = params.get("group", "")
## will runtime-error and bubble up without telling the caller which param
## was the wrong shape. Handlers should guard untrusted values with one of
## the require_*() helpers below and return its error dict on mismatch.
## Returns null iff `value` is a String or StringName. On any other type
## returns an INVALID_PARAMS error dict whose message names both `name` and
## the actual Variant type (via Godot's built-in `type_string`).
static func require_string(name: String, value: Variant) -> Variant:
var t := typeof(value)
if t == TYPE_STRING or t == TYPE_STRING_NAME:
return null
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Param '%s' must be a String, got %s" % [name, type_string(t)],
)
## Returns null iff `value` is an int. Floats are rejected — JSON decoders
## that emit `1.0` for an integer slot will surface a clear error here
## rather than silently truncating downstream.
static func require_int(name: String, value: Variant) -> Variant:
if typeof(value) == TYPE_INT:
return null
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Param '%s' must be an int, got %s" % [name, type_string(typeof(value))],
)
## Returns null iff `value` is a bool.
static func require_bool(name: String, value: Variant) -> Variant:
if typeof(value) == TYPE_BOOL:
return null
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Param '%s' must be a bool, got %s" % [name, type_string(typeof(value))],
)
@@ -0,0 +1 @@
uid://difa877m8dsla
@@ -0,0 +1,82 @@
@tool
class_name McpPropertyErrors
extends RefCounted
## Shared helper for building "Property not found" error messages that include
## "did you mean" suggestions and a tail of available property names. All
## handlers that validate user-supplied property names against a target Object
## (Node, Resource, …) should route through build_message() so agents get
## consistent, actionable errors on typos.
##
## Ranking combines Godot's built-in String.similarity() with a substring
## bonus so both "radus" → "radius" (edit distance) and "top" → "top_radius"
## (substring) surface naturally.
const _SIMILARITY_THRESHOLD: float = 0.4
const _SUBSTRING_BONUS: float = 0.5
const _MAX_SUGGESTIONS: int = 5
const _MAX_TAIL: int = 10
static func build_message(target: Object, bad_name: String) -> String:
if target == null:
return "Property '%s' not found" % bad_name
var class_label := _class_label(target)
var available := _available_property_names(target)
if available.is_empty():
return "Property '%s' not found on %s" % [bad_name, class_label]
var msg := "Property '%s' not found on %s" % [bad_name, class_label]
var suggestions := _rank_suggestions(bad_name, available)
if not suggestions.is_empty():
msg += ". Did you mean: %s?" % ", ".join(suggestions)
var tail_names := available.slice(0, min(_MAX_TAIL, available.size()))
msg += " (available: %s" % ", ".join(tail_names)
if available.size() > tail_names.size():
msg += ", ..."
msg += ")"
return msg
## Prefer a scripted class_name if the target has one, else the engine class.
static func _class_label(target: Object) -> String:
var scr := target.get_script()
if scr != null and scr.has_method("get_global_name"):
var gcn: String = scr.get_global_name()
if not gcn.is_empty():
return gcn
return target.get_class()
## Editor-visible properties, alphabetised, with internal/category entries dropped.
static func _available_property_names(target: Object) -> Array:
var names: Array = []
for p in target.get_property_list():
var usage: int = int(p.get("usage", 0))
if (usage & PROPERTY_USAGE_EDITOR) == 0:
continue
var name: String = p.get("name", "")
if name.is_empty() or name.begins_with("_"):
continue
names.append(name)
names.sort()
return names
static func _rank_suggestions(bad: String, available: Array) -> Array:
if bad.is_empty():
return []
var bad_lower := bad.to_lower()
var scored: Array = []
for n in available:
var score: float = bad.similarity(n)
if n.to_lower().find(bad_lower) != -1 or bad_lower.find(n.to_lower()) != -1:
score += _SUBSTRING_BONUS
if score >= _SIMILARITY_THRESHOLD:
scored.append([score, n])
scored.sort_custom(func(a, b): return a[0] > b[0])
var result: Array = []
for i in range(min(_MAX_SUGGESTIONS, scored.size())):
result.append(scored[i][1])
return result
@@ -0,0 +1 @@
uid://c74d560g4l86b
@@ -0,0 +1,825 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles AnimationPlayer authoring: creating players, animations, tracks,
## keyframes, autoplay, and dev-ergonomics playback.
##
## Animations live inside an AnimationLibrary attached to an AnimationPlayer
## node in the scene. They save with the .tscn — no separate resource file
## needed. Undo callables hold direct Animation references (not paths).
##
## Split (issue #342, audit finding #13):
## - animation_presets.gd → preset_fade / slide / shake / pulse + helpers
## - animation_values.gd → animation_list / get / validate + shared
## value coercion / serialization
## Both submodules hold a WeakRef back to this handler. The handler's
## preset_* / list / get / validate methods are thin proxies so existing
## dispatcher registrations and test fixtures don't change.
const AnimationPresets := preload("res://addons/godot_ai/handlers/animation_presets.gd")
const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
var _undo_redo: EditorUndoRedoManager
var _presets
var _values
const _LOOP_MODES := {
"none": Animation.LOOP_NONE,
"linear": Animation.LOOP_LINEAR,
"pingpong": Animation.LOOP_PINGPONG,
}
const _INTERP_MODES := {
"nearest": Animation.INTERPOLATION_NEAREST,
"linear": Animation.INTERPOLATION_LINEAR,
"cubic": Animation.INTERPOLATION_CUBIC,
}
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
_presets = AnimationPresets.new(self)
_values = AnimationValues.new(self)
# ============================================================================
# animation_player_create
# ============================================================================
func create_player(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "AnimationPlayer")
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 player := AnimationPlayer.new()
if not node_name.is_empty():
player.name = node_name
# Attach the default library before adding to tree — it persists on redo.
var library := AnimationLibrary.new()
player.add_animation_library("", library)
_undo_redo.create_action("MCP: Create AnimationPlayer %s" % player.name)
_undo_redo.add_do_method(parent, "add_child", player, true)
_undo_redo.add_do_method(player, "set_owner", scene_root)
_undo_redo.add_do_reference(player)
_undo_redo.add_do_reference(library)
_undo_redo.add_undo_method(parent, "remove_child", player)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(player, scene_root),
"parent_path": McpScenePath.from_node(parent, scene_root),
"name": String(player.name),
"undoable": true,
}
}
# ============================================================================
# animation_create
# ============================================================================
func create_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("name", "")
var length: float = float(params.get("length", 1.0))
var loop_mode_str: String = params.get("loop_mode", "none")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if length <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "length must be > 0 (got %s)" % length)
if not _LOOP_MODES.has(loop_mode_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())])
var resolved := _resolve_player(player_path, true)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_player: bool = resolved.get("player_created", false)
var player_parent: Node = resolved.get("player_parent", null)
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var overwrite: bool = params.get("overwrite", false)
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var anim := Animation.new()
anim.length = length
anim.loop_mode = _LOOP_MODES[loop_mode_str]
_commit_animation_add("MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
created_player, player_parent)
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": length,
"loop_mode": loop_mode_str,
"library_created": created_library or created_player,
"animation_player_created": created_player,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_delete
# ============================================================================
func delete_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
# Use _resolve_animation so we can delete from ANY library, not just the
# default. Mirrors the read-side symmetry with animation_get / animation_play
# which already search all libraries via _resolve_animation.
var anim_resolved := _resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var old_anim: Animation = anim_resolved.animation
var library: AnimationLibrary = anim_resolved.library
# Clip key within the owning library — strips the "libname/" prefix if the
# caller passed a qualified name.
var clip_key: String = anim_name
var slash := anim_name.find("/")
if slash >= 0:
clip_key = anim_name.substr(slash + 1)
_undo_redo.create_action("MCP: Delete animation %s" % anim_name)
_undo_redo.add_do_method(library, "remove_animation", clip_key)
_undo_redo.add_undo_method(library, "add_animation", clip_key, old_anim)
_undo_redo.add_do_reference(old_anim) # prevent GC so undo→redo works
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"library_key": anim_resolved.get("library_key", ""),
"undoable": true,
}
}
# ============================================================================
# animation_add_property_track
# ============================================================================
func add_property_track(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
var track_path: String = params.get("track_path", "")
var keyframes = params.get("keyframes", [])
var interp_str: String = params.get("interpolation", "linear")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
if track_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: track_path (format: 'NodeName:property', e.g. 'Panel:modulate')")
if not track_path.contains(":"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"track_path must include ':property' suffix (e.g. 'Panel:modulate', '.:position')")
if not _INTERP_MODES.has(interp_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid interpolation '%s'. Valid: %s" % [interp_str, ", ".join(_INTERP_MODES.keys())])
if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved := _resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
# Validate + pre-coerce keyframes before mutating. Coercion errors
# surface as INVALID_PARAMS rather than silently inserting garbage keys.
# Resolve the target property's type ONCE — dense clips used to re-walk
# get_property_list() per keyframe.
var ctx := AnimationValues.resolve_track_prop_context(track_path, player)
if ctx.has("error"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, ctx.error)
var coerced_keyframes: Array = []
for kf in keyframes:
if typeof(kf) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary")
if not "time" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field")
if not "value" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'value' field")
var coerce_result := AnimationValues.coerce_with_context(kf.get("value"), ctx)
if coerce_result.has("error"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, coerce_result.error)
coerced_keyframes.append({
"time": kf.get("time"),
"value": coerce_result.ok,
"transition": kf.get("transition", "linear"),
})
_create_scene_pinned_action("MCP: Add property track %s to %s" % [track_path, anim_name])
_undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes)
# Undo locates the track by (path, type) at undo time rather than caching
# an index captured at do time. Cached indices go stale if any other track
# mutation lands between do and undo (Godot editor, another MCP call, etc.)
_undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"track_path": track_path,
"interpolation": interp_str,
"keyframe_count": keyframes.size(),
"undoable": true,
}
}
## Insert a pre-coerced track into the animation. Callers must coerce
## values against the target property before calling this (see
## AnimationValues.coerce_value_for_track) — this method runs inside the
## undo do-method path where error propagation isn't possible.
func _do_add_property_track(
anim: Animation,
track_path: String,
interp_str: String,
keyframes: Array,
) -> void:
var idx := anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(idx, NodePath(track_path))
anim.track_set_interpolation_type(idx, _INTERP_MODES.get(interp_str, Animation.INTERPOLATION_LINEAR))
for kf in keyframes:
var t: float = float(kf.get("time", 0.0))
var trans: float = AnimationValues.parse_transition(kf.get("transition", "linear"))
anim.track_insert_key(idx, t, kf.get("value"), trans)
# ============================================================================
# animation_add_method_track
# ============================================================================
func add_method_track(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
var target_path: String = params.get("target_node_path", "")
var keyframes = params.get("keyframes", [])
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_node_path")
if target_path.contains(":"):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"target_node_path is a bare NodePath without ':property' (got '%s'). " % target_path +
"Method name goes in each keyframe's 'method' field, not the path.")
if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array")
for kf in keyframes:
if typeof(kf) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary")
if not "time" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field")
if not "method" in kf:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'method' field")
var method_field = kf.get("method")
if typeof(method_field) != TYPE_STRING or (method_field as String).is_empty():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'method' must be a non-empty string")
if kf.has("args") and typeof(kf.get("args")) != TYPE_ARRAY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"'args' must be an array if provided (got %s)" % type_string(typeof(kf.get("args"))))
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved := _resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
_create_scene_pinned_action("MCP: Add method track %s to %s" % [target_path, anim_name])
_undo_redo.add_do_method(self, "_do_add_method_track", anim, target_path, keyframes)
# Undo locates the track by (path, type) at undo time — see add_property_track.
_undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, target_path, Animation.TYPE_METHOD)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"target_node_path": target_path,
"keyframe_count": keyframes.size(),
"undoable": true,
}
}
## Remove a track identified by (path, type) at undo time. Robust to
## history interleaving: if another track was added since the do, the
## find_track call still resolves to the correct index. Returns silently
## if the track is no longer present (e.g. a prior undo already removed it).
func _undo_remove_track_by_path(anim: Animation, track_path: String, track_type: int) -> void:
var idx := anim.find_track(NodePath(track_path), track_type)
if idx >= 0:
anim.remove_track(idx)
func _do_add_method_track(anim: Animation, target_path: String, keyframes: Array) -> void:
var idx := anim.add_track(Animation.TYPE_METHOD)
anim.track_set_path(idx, NodePath(target_path))
for kf in keyframes:
var t: float = float(kf.get("time", 0.0))
var method_name: String = str(kf.get("method", ""))
var args: Array = kf.get("args", [])
anim.track_insert_key(idx, t, {"method": method_name, "args": args})
# ============================================================================
# animation_set_autoplay
# ============================================================================
func set_autoplay(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
# Allow empty string to clear autoplay; otherwise validate the name exists.
if not anim_name.is_empty() and not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
var old_autoplay: String = player.autoplay
_undo_redo.create_action("MCP: Set autoplay %s on %s" % [anim_name, player_path])
_undo_redo.add_do_property(player, "autoplay", anim_name)
_undo_redo.add_undo_property(player, "autoplay", old_autoplay)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"previous_autoplay": old_autoplay,
"cleared": anim_name.is_empty(),
"undoable": true,
}
}
# ============================================================================
# animation_play (dev ergonomics — not saved with scene)
# ============================================================================
func play(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
if not anim_name.is_empty() and not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
player.play(anim_name)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# animation_stop (dev ergonomics — not saved with scene)
# ============================================================================
func stop(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
player.stop()
return {
"data": {
"player_path": player_path,
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# animation_create_simple (composer)
# ============================================================================
func create_simple(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("name", "")
var tweens = params.get("tweens", [])
var loop_mode_str: String = params.get("loop_mode", "none")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if typeof(tweens) != TYPE_ARRAY or tweens.is_empty():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tweens must be a non-empty array")
if not _LOOP_MODES.has(loop_mode_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())])
# Validate all tween specs before touching the scene.
var seen_paths := {}
for spec in tweens:
if typeof(spec) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each tween spec must be a dictionary")
for field in ["target", "property", "from", "to", "duration"]:
if not field in spec:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"Each tween spec must have '%s'" % field)
if float(spec.get("duration", 0.0)) <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"tween 'duration' must be > 0")
var dup_key: String = str(spec.target) + ":" + str(spec.property)
if seen_paths.has(dup_key):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Duplicate tween target '%s' — merge keyframes into a single track " % dup_key +
"via animation_add_property_track instead of two separate tweens.")
seen_paths[dup_key] = true
# Compute/validate length before resolving the player — a fresh auto-created
# AnimationPlayer is a detached Node that leaks if we return after creation.
var has_length: bool = params.has("length") and params.get("length") != null
var computed_length: float = 0.0
if has_length:
computed_length = float(params.get("length"))
if computed_length <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"'length' must be > 0 when provided (got %s)" % str(params.get("length")))
else:
for spec in tweens:
var end_time: float = float(spec.get("delay", 0.0)) + float(spec.get("duration", 0.0))
if end_time > computed_length:
computed_length = end_time
if computed_length <= 0.0:
computed_length = 1.0
var resolved := _resolve_player(player_path, true)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_player: bool = resolved.get("player_created", false)
var player_parent: Node = resolved.get("player_parent", null)
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var overwrite: bool = params.get("overwrite", false)
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
if created_player:
player.queue_free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
# Pre-coerce all tween values before touching the anim — coercion errors
# surface as INVALID_PARAMS, not silent garbage keyframes.
# When the player was auto-created, it isn't in the tree yet — pass its
# future parent so the coercer can still resolve target property types.
var coerce_root: Node = player_parent if created_player else null
var per_track_keyframes: Array = []
for spec in tweens:
var target: String = str(spec.get("target", ""))
var property: String = str(spec.get("property", ""))
var track_path: String = target + ":" + property
var duration: float = float(spec.get("duration", 1.0))
var delay: float = float(spec.get("delay", 0.0))
var trans_str = spec.get("transition", "linear")
var from_result := AnimationValues.coerce_value_for_track(spec.get("from"), track_path, player, coerce_root)
if from_result.has("error"):
if created_player:
player.queue_free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, from_result.error])
var to_result := AnimationValues.coerce_value_for_track(spec.get("to"), track_path, player, coerce_root)
if to_result.has("error"):
if created_player:
player.queue_free()
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, to_result.error])
per_track_keyframes.append({
"track_path": track_path,
"keyframes": [
{"time": delay, "value": from_result.ok, "transition": trans_str},
{"time": delay + duration, "value": to_result.ok, "transition": trans_str},
],
})
# Build the animation fully in memory before touching the undo stack.
var anim := Animation.new()
anim.length = computed_length
anim.loop_mode = _LOOP_MODES[loop_mode_str]
for entry in per_track_keyframes:
_do_add_property_track(anim, entry.track_path, "linear", entry.keyframes)
# One atomic undo action — bundles player creation (if any), library
# creation (if any), and the animation add. A single Ctrl-Z rolls back all.
_commit_animation_add("MCP: Create animation %s (%d tracks)" % [anim_name, anim.get_track_count()],
player, library, created_library, anim_name, anim, old_anim,
created_player, player_parent)
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": computed_length,
"loop_mode": loop_mode_str,
"track_count": anim.get_track_count(),
"library_created": created_library or created_player,
"animation_player_created": created_player,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# Proxies — preset_* and read methods live in the submodules. Kept here so
# the dispatcher registrations and `_handler.method(...)` test fixtures stay
# unchanged across the split.
# ============================================================================
func preset_fade(params: Dictionary) -> Dictionary:
return _presets.preset_fade(params)
func preset_slide(params: Dictionary) -> Dictionary:
return _presets.preset_slide(params)
func preset_shake(params: Dictionary) -> Dictionary:
return _presets.preset_shake(params)
func preset_pulse(params: Dictionary) -> Dictionary:
return _presets.preset_pulse(params)
func list_animations(params: Dictionary) -> Dictionary:
return _values.list_animations(params)
func get_animation(params: Dictionary) -> Dictionary:
return _values.get_animation(params)
func validate_animation(params: Dictionary) -> Dictionary:
return _values.validate_animation(params)
# ============================================================================
# Helpers — undo
# ============================================================================
## Shared undo setup for create_animation and create_simple. Handles fresh-
## create, overwrite, library auto-create, and player auto-create in a single
## atomic action. When `created_player` is true, the player already has the
## library attached (eagerly, from `_instantiate_player`) and the library
## doesn't need its own undo bookkeeping — it rides along with the add_child.
func _commit_animation_add(
action_label: String,
player: AnimationPlayer,
library: AnimationLibrary,
created_library: bool,
anim_name: String,
anim: Animation,
old_anim: Animation, ## null when not overwriting
created_player: bool = false,
player_parent: Node = null,
) -> void:
_undo_redo.create_action(action_label)
if created_player:
var scene_root := EditorInterface.get_edited_scene_root()
_undo_redo.add_do_method(player_parent, "add_child", player, true)
_undo_redo.add_do_method(player, "set_owner", scene_root)
_undo_redo.add_do_reference(player)
_undo_redo.add_do_reference(library)
_undo_redo.add_undo_method(player_parent, "remove_child", player)
elif created_library:
_undo_redo.add_do_method(player, "add_animation_library", "", library)
_undo_redo.add_undo_method(player, "remove_animation_library", "")
_undo_redo.add_do_reference(library)
if old_anim != null:
_undo_redo.add_do_method(library, "remove_animation", anim_name)
_undo_redo.add_do_method(library, "add_animation", anim_name, anim)
if old_anim != null:
_undo_redo.add_undo_method(library, "remove_animation", anim_name)
_undo_redo.add_undo_method(library, "add_animation", anim_name, old_anim)
_undo_redo.add_do_reference(old_anim)
else:
_undo_redo.add_undo_method(library, "remove_animation", anim_name)
_undo_redo.add_do_reference(anim)
_undo_redo.commit_action()
## Open a `create_action` pinned to the edited scene's history.
##
## Without an explicit context, `add_do_method(self, ...)` against a
## RefCounted handler lands in GLOBAL_HISTORY while sibling actions whose
## first do-target is a Resource (e.g. AnimationLibrary) land in the scene's
## history. Mismatched histories make the test-side `editor_undo` helper
## (walks scene first) undo the wrong action, and break batch_handler's
## rollback. Mirrors `camera_handler.gd`'s identical pinning rationale.
func _create_scene_pinned_action(action_label: String) -> void:
_undo_redo.create_action(
action_label, UndoRedo.MERGE_DISABLE, EditorInterface.get_edited_scene_root(),
)
# ============================================================================
# Helpers — resolution
# ============================================================================
## Resolve an AnimationPlayer and its default library for write operations.
## Returns {player, library, player_created, player_parent} on success, or an
## error dict. library is null if the player exists but has no default library
## yet — callers bundle an `add_animation_library` step into their undo action.
##
## When `create_if_missing` is true and `player_path` resolves to nothing, a
## fresh AnimationPlayer is instantiated (with an empty default library attached
## eagerly) but is NOT added to the scene tree — callers must bundle the
## add_child step into their undo action via `_commit_animation_add`.
## If the resolved node exists but isn't an AnimationPlayer, that's still an
## error — we don't clobber an existing node of a different type.
func _resolve_player(player_path: String, create_if_missing: bool = false) -> 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 node := McpScenePath.resolve(player_path, scene_root)
if node == null:
if not create_if_missing:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(player_path, scene_root))
return _instantiate_player(player_path, scene_root)
if not node is AnimationPlayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()])
var player := node as AnimationPlayer
var lib: AnimationLibrary = null
if player.has_animation_library(""):
lib = player.get_animation_library("")
return {"player": player, "library": lib, "player_created": false, "player_parent": null}
## Build a new AnimationPlayer (with empty default library) for insertion under
## the parent implied by `player_path`. Returns an error dict if the parent
## can't be resolved or the path has no usable leaf name.
func _instantiate_player(player_path: String, scene_root: Node) -> Dictionary:
var slash := player_path.rfind("/")
var parent_path: String
var player_name: String
if slash < 0:
parent_path = ""
player_name = player_path
else:
parent_path = player_path.substr(0, slash)
player_name = player_path.substr(slash + 1)
if player_name.is_empty():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Cannot auto-create AnimationPlayer: player_path '%s' has no leaf name" % player_path)
var parent: Node
if parent_path.is_empty():
parent = scene_root
else:
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Cannot auto-create AnimationPlayer at %s: %s" % [
player_path, McpScenePath.format_parent_error(parent_path, scene_root)])
var new_player := AnimationPlayer.new()
new_player.name = player_name
var lib := AnimationLibrary.new()
new_player.add_animation_library("", lib)
return {
"player": new_player,
"library": lib,
"player_created": true,
"player_parent": parent,
}
## Resolve for read operations (no library requirement).
func _resolve_player_read(player_path: String) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is AnimationPlayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()])
return {"player": node as AnimationPlayer}
## Resolve an animation by name, searching all libraries.
## Accepts bare clip names ("idle") and library-qualified names ("moves/idle")
## as returned by `list_animations` for non-default libraries.
func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionary:
if not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player. Available: %s" % [
anim_name,
", ".join(Array(player.get_animation_list()))
])
# If the caller passed "library/clip", look up in that specific library.
var slash := anim_name.find("/")
if slash >= 0:
var lib_key := anim_name.substr(0, slash)
var clip_key := anim_name.substr(slash + 1)
if player.has_animation_library(lib_key):
var lib: AnimationLibrary = player.get_animation_library(lib_key)
if lib.has_animation(clip_key):
return {"animation": lib.get_animation(clip_key), "library": lib, "library_key": lib_key}
# Otherwise scan libraries for a bare clip name.
for lib_name in player.get_animation_library_list():
var lib2: AnimationLibrary = player.get_animation_library(lib_name)
if lib2.has_animation(anim_name):
return {"animation": lib2.get_animation(anim_name), "library": lib2, "library_key": lib_name}
# Fallback — shouldn't happen if has_animation returned true.
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Animation found by player but not in any library")
@@ -0,0 +1 @@
uid://c0jrius46xsd4
@@ -0,0 +1,528 @@
@tool
extends RefCounted
## Curated motion presets for the AnimationPlayer surface.
##
## Each preset_* method:
## 1. Validates params + resolves the player (auto-creating its default lib).
## 2. Resolves the target node + classifies it as control / 2d / 3d.
## 3. Builds a single-track Animation with shape-appropriate keyframes.
## 4. Commits the add through the handler's shared `_commit_animation_add`
## so a single Ctrl-Z rolls back any auto-created library + the animation.
##
## Holds a WeakRef back to the AnimationHandler instance so the handler can
## continue to own this module strongly via `_presets` without forming a
## RefCounted cycle. Resolution / undo helpers live on the handler — keeping
## the `_undo_redo` member single-source there avoids drift.
const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_preset_fade
# ============================================================================
func preset_fade(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.5))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target: Node = target_resolved.node
var track_target: String = target_resolved.track_path_root
# Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc).
var has_modulate := false
for p in target.get_property_list():
if p.name == "modulate":
has_modulate = true
break
if not has_modulate:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' (class %s) has no 'modulate' property — fade requires a CanvasItem, Control, Node2D, or Sprite3D"
% [target_path, target.get_class()])
if anim_name.is_empty():
anim_name = "fade_%s" % mode
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var start_a: float = 0.0 if mode == "in" else 1.0
var end_a: float = 1.0 if mode == "in" else 0.0
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:modulate:a" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_a, "transition": "linear"},
{"time": duration, "value": end_a, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"mode": mode,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_slide
# ============================================================================
func preset_slide(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var direction: String = params.get("direction", "left")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if not ["left", "right", "up", "down"].has(direction):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid direction '%s'. Valid: 'left', 'right', 'up', 'down'" % direction)
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
# Default distance picks 3D units vs screen pixels based on target kind.
var default_distance: float = 1.0 if kind == "3d" else 100.0
var distance: float = float(params.get("distance", default_distance))
if distance == 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'distance' must be non-zero")
var offset: Variant = _direction_offset(kind, direction, distance)
var current_pos: Variant = target.position
var start_pos: Variant
var end_pos: Variant
if mode == "in":
start_pos = current_pos + offset
end_pos = current_pos
else:
start_pos = current_pos
end_pos = current_pos + offset
if anim_name.is_empty():
anim_name = "slide_%s_%s" % [mode, direction]
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_pos, "transition": "linear"},
{"time": duration, "value": end_pos, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"direction": direction,
"mode": mode,
"distance": distance,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_shake
# ============================================================================
func preset_shake(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var duration: float = float(params.get("duration", 0.3))
var frequency: float = float(params.get("frequency", 30.0))
var rng_seed: int = int(params.get("seed", 0))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if frequency <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'frequency' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
var default_intensity: float = 0.1 if kind == "3d" else 10.0
var intensity: float = float(params.get("intensity", default_intensity))
if intensity <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'intensity' must be > 0")
if anim_name.is_empty():
anim_name = "shake"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var rng := RandomNumberGenerator.new()
if rng_seed != 0:
rng.seed = rng_seed
else:
rng.randomize()
# Samples between t=0 and t=duration (exclusive); bookended by at-rest keys.
var sample_count: int = int(ceil(frequency * duration))
if sample_count < 2:
sample_count = 2
var current_pos: Variant = target.position
var kfs: Array = []
kfs.append({"time": 0.0, "value": current_pos, "transition": "linear"})
for i in range(1, sample_count):
var t: float = (float(i) / float(sample_count)) * duration
var jx: float = rng.randf_range(-intensity, intensity)
var jy: float = rng.randf_range(-intensity, intensity)
var jittered: Variant
if kind == "3d":
var jz: float = rng.randf_range(-intensity, intensity)
jittered = current_pos + Vector3(jx, jy, jz)
else:
jittered = current_pos + Vector2(jx, jy)
kfs.append({"time": t, "value": jittered, "transition": "linear"})
kfs.append({"time": duration, "value": current_pos, "transition": "linear"})
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", kfs)
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"length": duration,
"frequency": frequency,
"intensity": intensity,
"keyframe_count": kfs.size(),
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_pulse
# ============================================================================
func preset_pulse(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var from_scale: float = float(params.get("from_scale", 1.0))
var to_scale: float = float(params.get("to_scale", 1.1))
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if from_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'from_scale' must be > 0")
if to_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'to_scale' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
if anim_name.is_empty():
anim_name = "pulse"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var from_vec: Variant
var to_vec: Variant
if kind == "3d":
from_vec = Vector3(from_scale, from_scale, from_scale)
to_vec = Vector3(to_scale, to_scale, to_scale)
else:
from_vec = Vector2(from_scale, from_scale)
to_vec = Vector2(to_scale, to_scale)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:scale" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": from_vec, "transition": "linear"},
{"time": duration * 0.5, "value": to_vec, "transition": "linear"},
{"time": duration, "value": from_vec, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"from_scale": from_scale,
"to_scale": to_scale,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# Helpers — preset resolution
# ============================================================================
## Resolve a preset target node and classify its transform kind.
##
## Accepts two `target_path` shapes:
## * Scene-absolute (starts with "/") — resolved through `ScenePath.resolve`,
## matching the convention used by every other scene-mutating tool. Targets
## outside the player's `root_node` subtree are converted to `..`-prefixed
## paths via `root_node.get_path_to(target)`, mirroring what the relative
## form accepts and how Godot stores track paths.
## * Relative — used as-is against the player's `root_node`, matching how
## animation tracks themselves are stored.
##
## Returns `{node, kind, track_path_root}` where `track_path_root` is the path
## (relative to `root_node`) that callers should embed in the track path. For
## scene-absolute inputs this is the converted relative path; for relative
## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}.
##
## Mirrors the same root-node fallback that
## `AnimationValues.resolve_track_prop_context` uses so tool inputs match how
## the track path will resolve at playback.
func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary:
var root_node := AnimationValues.player_root_node(player)
if root_node == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path()))
var target: Node = null
var track_path_root: String = target_path
if target_path.begins_with("/"):
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Cannot resolve scene-absolute target_path '%s': no scene open" % target_path)
target = ScenePath.resolve(target_path, scene_root)
if target == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
ScenePath.format_node_error(target_path, scene_root))
# Convert to a root_node-relative path. For targets outside the
# subtree this yields a `..`-prefixed path, matching what the
# relative form already accepts (root_node.get_node_or_null
# resolves `..` segments) and what Godot's animation engine
# stores natively.
track_path_root = str(root_node.get_path_to(target))
else:
target = root_node.get_node_or_null(target_path)
if target == null:
# root_node.get_path() leaks the editor's SubViewport-wrapped
# path; use the clean scene-relative form so the hint is
# actionable.
var scene_root := EditorInterface.get_edited_scene_root()
var root_hint := ScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name)
var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target"
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). "
+ "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").")
% [target_path, root_hint, abs_example])
var kind: String
if target is Control:
kind = "control"
elif target is Node2D:
kind = "2d"
elif target is Node3D:
kind = "3d"
else:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()])
return {"node": target, "kind": kind, "track_path_root": track_path_root}
## Build a directional offset for slide presets.
## Axis conventions:
## Control + Node2D (screen-space, y-down): left/right = ∓x, up = -y, down = +y
## Node3D (world-up): left/right = ∓x, up = +y, down = -y
static func _direction_offset(kind: String, direction: String, distance: float) -> Variant:
if kind == "3d":
match direction:
"left": return Vector3(-distance, 0.0, 0.0)
"right": return Vector3(distance, 0.0, 0.0)
"up": return Vector3(0.0, distance, 0.0)
"down": return Vector3(0.0, -distance, 0.0)
else:
match direction:
"left": return Vector2(-distance, 0.0)
"right": return Vector2(distance, 0.0)
"up": return Vector2(0.0, -distance)
"down": return Vector2(0.0, distance)
return null
@@ -0,0 +1 @@
uid://c4s3h78bwvr6w
@@ -0,0 +1,465 @@
@tool
extends RefCounted
## Read-only animation introspection + shared value-coercion / serialization.
##
## Holds:
## - Static helpers used by both the write handler (track building, simple
## composer) and the preset module (target/property resolution).
## - Instance methods that back the read MCP ops: animation_list,
## animation_get, animation_validate.
##
## The instance methods need the handler to resolve players / animations.
## To keep that without introducing a RefCounted cycle (the handler holds a
## strong ref to this module via `_values`), the back-pointer is a WeakRef.
## When the handler is freed during plugin teardown, _h() returns null and
## the (no-longer-routable) calls short-circuit to a generic editor-not-ready
## error — matches the dispatcher already being torn down at that point.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const PropertyErrors := preload("res://addons/godot_ai/handlers/_property_errors.gd")
const _NAMED_TRANSITIONS := {
"linear": 1.0,
"ease_in": 2.0,
"ease_out": 0.5,
"ease_in_out": -2.0,
}
## Component letters accepted on each aggregate base type, paired with the
## scalar Variant type the component resolves to. A subpath like `position:y`
## on a Vector3 maps to TYPE_FLOAT; on a Vector3i it maps to TYPE_INT.
const _SUBPATH_COMPONENTS := {
TYPE_VECTOR2: ["xy", TYPE_FLOAT],
TYPE_VECTOR3: ["xyz", TYPE_FLOAT],
TYPE_VECTOR4: ["xyzw", TYPE_FLOAT],
TYPE_QUATERNION: ["xyzw", TYPE_FLOAT],
TYPE_COLOR: ["rgba", TYPE_FLOAT],
TYPE_VECTOR2I: ["xy", TYPE_INT],
TYPE_VECTOR3I: ["xyz", TYPE_INT],
TYPE_VECTOR4I: ["xyzw", TYPE_INT],
}
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_list (read)
# ============================================================================
func list_animations(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var animations: Array[Dictionary] = []
for lib_name in player.get_animation_library_list():
var lib: AnimationLibrary = player.get_animation_library(lib_name)
for anim_name in lib.get_animation_list():
var anim: Animation = lib.get_animation(anim_name)
var display_name: String = anim_name if lib_name == "" else "%s/%s" % [lib_name, anim_name]
animations.append({
"name": display_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
})
return {
"data": {
"player_path": player_path,
"animations": animations,
"count": animations.size(),
}
}
# ============================================================================
# animation_get (read)
# ============================================================================
func get_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved: Dictionary = handler._resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
var tracks: Array[Dictionary] = []
for i in anim.get_track_count():
var track_type := anim.track_get_type(i)
var type_name := track_type_to_string(track_type)
var keys: Array[Dictionary] = []
for k in anim.track_get_key_count(i):
var key_val = anim.track_get_key_value(i, k)
keys.append({
"time": anim.track_get_key_time(i, k),
"value": serialize_value(key_val),
"transition": anim.track_get_key_transition(i, k),
})
tracks.append({
"index": i,
"type": type_name,
"path": str(anim.track_get_path(i)),
"interpolation": interp_to_string(anim.track_get_interpolation_type(i)),
"key_count": keys.size(),
"keys": keys,
})
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
"tracks": tracks,
}
}
# ============================================================================
# animation_validate (read-only)
# ============================================================================
func validate_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
if not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
var anim: Animation = player.get_animation(anim_name)
var root_node := player_root_node(player)
var broken_tracks: Array[Dictionary] = []
var valid_count := 0
for i in anim.get_track_count():
var track_path_str := str(anim.track_get_path(i))
# Split on the FIRST colon (node↔property boundary), not the last.
# Godot's get_node_or_null strips the ":property" tail natively, so
# the valid/broken classification is the same either way — but for
# BROKEN tracks the broken_tracks[].node_path field is what callers
# read to diagnose the missing node, and rfind would surface
# "MissingTarget:modulate" instead of "MissingTarget" for subpath
# tracks like the "Target:modulate:a" shape preset_fade emits.
var colon := track_path_str.find(":")
var node_part: String
if colon >= 0:
node_part = track_path_str.substr(0, colon)
else:
node_part = track_path_str
var target_node: Node = null
if root_node != null:
target_node = root_node.get_node_or_null(node_part)
if target_node == null:
broken_tracks.append({
"index": i,
"path": track_path_str,
"type": track_type_to_string(anim.track_get_type(i)),
"issue": "node_not_found",
"node_path": node_part,
})
else:
valid_count += 1
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"track_count": anim.get_track_count(),
"valid_count": valid_count,
"broken_count": broken_tracks.size(),
"broken_tracks": broken_tracks,
"valid": broken_tracks.is_empty(),
}
}
# ============================================================================
# Static helpers — shared with handler + presets
# ============================================================================
## Resolve the effective root node an AnimationPlayer animates against.
## Falls back to the player's parent when the explicit root_node NodePath is
## empty or unresolvable. Returns null when the player isn't in the tree.
##
## Mirrors the resolution Godot does at playback time so the validator,
## preset target resolver, and track-property coercer all see the same root.
static func player_root_node(player: AnimationPlayer) -> Node:
if not player.is_inside_tree():
return null
var rn := player.root_node
if rn != NodePath():
var n := player.get_node_or_null(rn)
if n != null:
return n
return player.get_parent()
## Coerce a JSON value to match the expected Godot type for the given
## track_path. Returns {"ok": value} or {"error": msg}.
## Passes the raw value through when the target node isn't in the scene
## yet (authoring-time path). Errors when the target exists but the
## property doesn't, or when parsing a typed value (Color/Vector2/Vector3)
## clearly fails — better to reject than silently store garbage.
## `override_root_node` lets callers supply the root to resolve target paths
## against when the player isn't in the tree yet (auto-create flow) — the
## player's future parent stands in for the root the AnimationPlayer will
## eventually use.
static func coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var ctx := resolve_track_prop_context(track_path, player, override_root_node)
if ctx.has("error"):
return {"error": ctx.error}
return coerce_with_context(value, ctx)
## Resolve a track_path's target property type once, so callers coercing many
## keyframes avoid walking `get_property_list()` on every one. Returns:
## {pass_through: true} — no resolution / authoring-time
## {pass_through: false, prop_type, prop_name} — coerce against this type
## {error: msg} — property not found on target
##
## Supports Godot's native NodePath subpath form `property:sub` (e.g.
## `position:y`, `modulate:a`) — splits on the FIRST colon (node↔property
## boundary), resolves the base property on the target, and for known
## scalar subpaths (x/y/z/w on vectors, r/g/b/a on Color) narrows the
## coerce target to TYPE_FLOAT so JSON numbers land as floats, not dicts.
static func resolve_track_prop_context(track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var colon := track_path.find(":")
if colon < 0:
return {"pass_through": true}
var node_part := track_path.substr(0, colon)
var prop_full := track_path.substr(colon + 1)
# Property may include a subpath: "position:y", "modulate:a", etc.
var sub_colon := prop_full.find(":")
var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon)
var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1)
var root_node: Node = override_root_node
if root_node == null:
root_node = player_root_node(player)
if root_node == null:
return {"pass_through": true}
var target: Node = root_node.get_node_or_null(node_part)
if target == null:
# Target node isn't in the scene yet — authoring-time path. Pass through.
return {"pass_through": true}
for p in target.get_property_list():
if p.name == prop_base:
var base_type: int = p.get("type", TYPE_NIL)
var coerce_type := base_type
if not prop_sub.is_empty():
var sub_type := subpath_component_type(base_type, prop_sub)
if sub_type == TYPE_NIL:
# Unknown subpath component — pass through so Godot's own
# NodePath resolution raises at playback if it's truly bogus,
# rather than fabricating a coerce error for a valid-but-
# uncommon form (e.g. Transform3D subpaths).
return {"pass_through": true}
coerce_type = sub_type
return {
"pass_through": false,
"prop_type": coerce_type,
"prop_name": prop_full,
}
# Target exists but the property doesn't. Reject loudly — silently storing
# the raw value here produces garbage keyframes at playback time.
return {"error":
"%s (target path: '%s')" %
[PropertyErrors.build_message(target, prop_base), node_part]}
## Map a `property:sub` subpath to its scalar component type. Returns
## TYPE_NIL when the base type / subkey pair isn't one we recognise —
## callers pass-through in that case rather than mis-coerce.
static func subpath_component_type(base_type: int, sub: String) -> int:
var entry = _SUBPATH_COMPONENTS.get(base_type)
if entry == null or sub.length() != 1:
return TYPE_NIL
return entry[1] if (entry[0] as String).contains(sub) else TYPE_NIL
static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
if ctx.get("pass_through", false):
return {"ok": value}
return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
## Coerce a single value to the given Godot variant type. Returns
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
match prop_type:
TYPE_COLOR:
if value is Color:
return {"ok": value}
if value is String:
var s := value as String
var a := Color.from_string(s, Color(0, 0, 0, 0))
var b := Color.from_string(s, Color(1, 1, 1, 1))
if a == b:
return {"ok": a}
return {"error": "Cannot parse '%s' as Color for property '%s'" % [s, prop_name]}
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
return {"ok": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0)))}
return {"error": "Cannot coerce value to Color for property '%s' (expected string, {r,g,b}, or Color)" % prop_name}
TYPE_VECTOR2:
if value is Vector2:
return {"ok": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": Vector2(float(value.x), float(value.y))}
if value is Array and value.size() >= 2:
return {"ok": Vector2(float(value[0]), float(value[1]))}
return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
TYPE_VECTOR3:
if value is Vector3:
return {"ok": value}
if value is Dictionary and value.has("x") and value.has("y") and value.has("z"):
return {"ok": Vector3(float(value.x), float(value.y), float(value.z))}
return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z} or Vector3)" % prop_name}
TYPE_FLOAT:
if value is int or value is float:
return {"ok": float(value)}
TYPE_INT:
if value is float or value is int:
return {"ok": int(value)}
TYPE_BOOL:
if value is int or value is float or value is bool:
return {"ok": bool(value)}
return {"ok": value}
# ============================================================================
# Static helpers — parsing + serializing
# ============================================================================
## Parse a transition value: named string or raw float.
## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source.
static func parse_transition(v: Variant) -> float:
if v is float or v is int:
return float(v)
if v is String:
var key: String = (v as String).to_lower()
if _NAMED_TRANSITIONS.has(key):
return float(_NAMED_TRANSITIONS[key])
return 1.0
## Map an Animation.TrackType enum to a stable string. Unknown types report
## as "unknown" rather than being silently coerced to "method" — callers that
## only produce value/method tracks can ignore the others; clients that want
## to round-trip bezier/audio/etc. get an honest label to key off.
static func track_type_to_string(track_type: int) -> String:
match track_type:
Animation.TYPE_VALUE: return "value"
Animation.TYPE_METHOD: return "method"
Animation.TYPE_POSITION_3D: return "position_3d"
Animation.TYPE_ROTATION_3D: return "rotation_3d"
Animation.TYPE_SCALE_3D: return "scale_3d"
Animation.TYPE_BLEND_SHAPE: return "blend_shape"
Animation.TYPE_BEZIER: return "bezier"
Animation.TYPE_AUDIO: return "audio"
Animation.TYPE_ANIMATION: return "animation"
_: return "unknown"
static func loop_mode_to_string(mode: int) -> String:
match mode:
Animation.LOOP_LINEAR: return "linear"
Animation.LOOP_PINGPONG: return "pingpong"
_: return "none"
static func interp_to_string(mode: int) -> String:
match mode:
Animation.INTERPOLATION_NEAREST: return "nearest"
Animation.INTERPOLATION_CUBIC: return "cubic"
_: return "linear"
## Convert a Godot Variant to a JSON-safe value.
static func serialize_value(value: Variant) -> Variant:
if value == null:
return null
match typeof(value):
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
return value
TYPE_STRING_NAME:
return str(value)
TYPE_VECTOR2:
return {"x": value.x, "y": value.y}
TYPE_VECTOR3:
return {"x": value.x, "y": value.y, "z": value.z}
TYPE_COLOR:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
TYPE_NODE_PATH:
return str(value)
TYPE_ARRAY:
var arr: Array = []
for item in value:
arr.append(serialize_value(item))
return arr
TYPE_DICTIONARY:
var out := {}
for k in value:
out[str(k)] = serialize_value(value[k])
return out
return str(value)
@@ -0,0 +1 @@
uid://bguta2eb8blgf
+89
View File
@@ -0,0 +1,89 @@
@tool
extends RefCounted
## Read-only access to version-correct Godot class metadata.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
func get_class_info(params: Dictionary) -> Dictionary:
var requested_class: String = params.get("class_name", "")
if requested_class.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: class_name"
)
if not ClassDB.class_exists(requested_class):
var script_class := _global_script_class(requested_class)
if not script_class.is_empty():
return _script_class_error(requested_class, script_class)
return _unknown_class_error(requested_class)
if params.has("limit") and int(params.get("limit")) < 0:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"limit must be >= 0; use limit=0 only when an unlimited section is needed"
)
var section_check := ClassIntrospection.validate_sections(
params.get("sections", ClassIntrospection.DEFAULT_SECTIONS)
)
if not section_check.invalid.is_empty():
return _invalid_sections_error(section_check.invalid)
return {"data": ClassIntrospection.build(requested_class, params)}
static func _unknown_class_error(requested_class: String) -> Dictionary:
var suggestions := _suggest_classes(requested_class)
var message := "Unknown Godot class: %s" % requested_class
if not suggestions.is_empty():
message += ". Did you mean: %s?" % ", ".join(suggestions)
var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message)
result["error"]["data"] = {"suggestions": suggestions}
return result
static func _suggest_classes(requested_class: String) -> Array[String]:
return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list())
static func _global_script_class(requested_class: String) -> Dictionary:
for raw_info in ProjectSettings.get_global_class_list():
var info: Dictionary = raw_info
if info.get("class", "") == requested_class:
return info
return {}
static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary:
var path := str(script_class.get("path", ""))
var base := str(script_class.get("base", ""))
var message := (
"%s is a project script class, not a ClassDB class. "
+ "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols."
) % [requested_class, path]
var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message)
result["error"]["data"] = {
"script_class": true,
"class_name": requested_class,
"base_class": base,
"path": path,
}
return result
static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary:
var suggestions := {}
for section in invalid_sections:
suggestions[section] = FuzzySuggestions.rank(
section,
ClassIntrospection.KNOWN_SECTIONS,
3,
0.3
)
var message := "Unknown class-info section(s): %s. Valid sections: %s" % [
", ".join(invalid_sections),
", ".join(ClassIntrospection.KNOWN_SECTIONS),
]
var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
result["error"]["data"] = {"suggestions": suggestions}
return result
@@ -0,0 +1 @@
uid://v3rkd7ueunii
+359
View File
@@ -0,0 +1,359 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream
## assignment, playback-property edits, and real editor preview playback.
##
## Stream assignment loads a Godot-imported AudioStream resource from
## res:// (the editor's import step converts .ogg / .wav / .mp3 into a
## streamable AudioStream subclass before we ever see it).
##
## play() / stop() call the live node method directly — no undo, no
## persistence; they match what the inspector's play button does.
const _VALID_TYPES := {
"1d": "AudioStreamPlayer",
"2d": "AudioStreamPlayer2D",
"3d": "AudioStreamPlayer3D",
}
## Whitelist of playback properties settable via audio_player_set_playback.
## Each value is the expected Variant type of the param dict value.
const _PLAYBACK_KEYS := {
"volume_db": TYPE_FLOAT,
"pitch_scale": TYPE_FLOAT,
"autoplay": TYPE_BOOL,
"bus": TYPE_STRING,
}
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# audio_player_create
# ============================================================================
func create_player(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "AudioStreamPlayer")
var type_str: String = params.get("type", "1d")
if not _VALID_TYPES.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid audio player 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_player(type_str)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player")
if not node_name.is_empty():
node.name = node_name
_undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
_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)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
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],
"undoable": true,
}
}
# ============================================================================
# audio_player_set_stream
# ============================================================================
func set_stream(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var stream_path: String = params.get("stream_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if stream_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path")
var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path")
if stream_path_err != null:
return stream_path_err
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
if not ResourceLoader.exists(stream_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path)
var loaded := ResourceLoader.load(stream_path)
if loaded == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path)
if not (loaded is AudioStream):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()]
)
var old_stream: AudioStream = player.stream
_undo_redo.create_action("MCP: Set audio stream on %s" % player.name)
_undo_redo.add_do_property(player, "stream", loaded)
_undo_redo.add_undo_property(player, "stream", old_stream)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"stream_path": stream_path,
"stream_class": loaded.get_class(),
"duration_seconds": float(loaded.get_length()),
"undoable": true,
}
}
# ============================================================================
# audio_player_set_playback
# ============================================================================
func set_playback(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
var updates: Dictionary = {}
for key in _PLAYBACK_KEYS:
if params.has(key):
var expected_type: int = _PLAYBACK_KEYS[key]
var value = params.get(key)
var coerced = _coerce_playback_value(value, expected_type)
if coerced == null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Invalid value for %s: expected %s, got %s" % [
key, type_string(expected_type), type_string(typeof(value))
]
)
updates[key] = coerced
if updates.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys())
)
var old_values: Dictionary = {}
for key in updates:
old_values[key] = player.get(key)
_undo_redo.create_action("MCP: Update playback on %s" % player.name)
for key in updates:
_undo_redo.add_do_property(player, key, updates[key])
_undo_redo.add_undo_property(player, key, old_values[key])
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"applied": updates.keys(),
"values": updates,
"undoable": true,
}
}
# ============================================================================
# audio_play (runtime preview — not saved with scene)
# ============================================================================
func play(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var from_position: float = float(params.get("from_position", 0.0))
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
if player.stream == null:
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Player has no stream assigned — call audio_player_set_stream first"
)
player.play(from_position)
return {
"data": {
"player_path": player_path,
"from_position": from_position,
"playing": bool(player.playing),
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# audio_stop (runtime preview — not saved with scene)
# ============================================================================
func stop(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
player.stop()
return {
"data": {
"player_path": player_path,
"playing": bool(player.playing),
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# audio_list (read — scan project for AudioStream resources)
# ============================================================================
func list_streams(params: Dictionary) -> Dictionary:
var root: String = params.get("root", "res://")
var include_duration: bool = bool(params.get("include_duration", true))
var root_err = McpPathValidator.path_error(root, "root")
if root_err != null:
return root_err
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var results: Array[Dictionary] = []
var start_dir := efs.get_filesystem_path(root)
if start_dir == null:
start_dir = efs.get_filesystem()
_scan_audio(start_dir, root, include_duration, results)
return {
"data": {
"root": root,
"streams": results,
"count": results.size(),
}
}
func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void:
if dir == null:
return
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
if not file_path.begins_with(root):
continue
var file_type := dir.get_file_type(i)
var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream")
if not is_audio:
continue
var entry: Dictionary = {
"path": file_path,
"class": file_type,
}
if include_duration:
var res := ResourceLoader.load(file_path)
if res is AudioStream:
entry["duration_seconds"] = float((res as AudioStream).get_length())
else:
entry["duration_seconds"] = 0.0
out.append(entry)
for i in dir.get_subdir_count():
_scan_audio(dir.get_subdir(i), root, include_duration, out)
# ============================================================================
# Helpers
# ============================================================================
static func _instantiate_player(type_str: String) -> Node:
match type_str:
"1d":
return AudioStreamPlayer.new()
"2d":
return AudioStreamPlayer2D.new()
"3d":
return AudioStreamPlayer3D.new()
return null
func _resolve_player(player_path: String) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var is_player := node is AudioStreamPlayer \
or node is AudioStreamPlayer2D \
or node is AudioStreamPlayer3D
if not is_player:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()]
)
return {"player": node}
## Coerce a playback param value to the expected type. int→float is allowed
## so JSON integers pass through; everything else requires the exact type.
## Returns the coerced value, or null on type mismatch.
static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant:
match expected_type:
TYPE_FLOAT:
if value is float or value is int:
return float(value)
TYPE_BOOL:
if value is bool:
return value
TYPE_STRING:
if value is String:
return value
return null
@@ -0,0 +1 @@
uid://cjtvod52xxocs
@@ -0,0 +1,91 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles autoload listing, adding, and removing via ProjectSettings.
func list_autoloads(_params: Dictionary) -> Dictionary:
var autoloads: Array[Dictionary] = []
for prop in ProjectSettings.get_property_list():
var key: String = prop.get("name", "")
if not key.begins_with("autoload/"):
continue
var name := key.substr("autoload/".length())
var raw_value: String = ProjectSettings.get_setting(key, "")
var is_singleton := raw_value.begins_with("*")
var path := raw_value.substr(1) if is_singleton else raw_value
autoloads.append({
"name": name,
"path": path,
"singleton": is_singleton,
})
return {"data": {"autoloads": autoloads, "count": autoloads.size()}}
func add_autoload(params: Dictionary) -> Dictionary:
var name: String = params.get("name", "")
var path: String = params.get("path", "")
var singleton: bool = params.get("singleton", true)
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var key := "autoload/%s" % name
if ProjectSettings.has_setting(key):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Autoload '%s' already exists" % name)
var value := ("*" if singleton else "") + path
ProjectSettings.set_setting(key, value)
ProjectSettings.set_initial_value(key, "")
ProjectSettings.set_as_basic(key, true)
var err := ProjectSettings.save()
if err != OK:
ProjectSettings.clear(key)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while adding autoload '%s': %s (error %d)" % [name, error_string(err), err])
return {
"data": {
"name": name,
"path": path,
"singleton": singleton,
"undoable": false,
"reason": "Autoload changes are saved to project.godot",
}
}
func remove_autoload(params: Dictionary) -> Dictionary:
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var key := "autoload/%s" % name
if not ProjectSettings.has_setting(key):
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Autoload '%s' not found" % name)
var old_value: String = ProjectSettings.get_setting(key, "")
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
ProjectSettings.set_setting(key, old_value)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while removing autoload '%s': %s (error %d)" % [name, error_string(err), err])
return {
"data": {
"name": name,
"removed": true,
"undoable": false,
"reason": "Autoload changes are saved to project.godot",
}
}
@@ -0,0 +1 @@
uid://bb0inov044jn6
+131
View File
@@ -0,0 +1,131 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Executes a list of sub-commands through the dispatcher with stop-on-first-error
## semantics. When undo=true (default), any successful sub-commands are rolled
## back via the scene's UndoRedo history if a later sub-command fails.
const FORBIDDEN_SUBCOMMANDS := ["batch_execute"]
var _dispatcher: McpDispatcher
var _undo_redo: EditorUndoRedoManager
func _init(dispatcher: McpDispatcher, undo_redo: EditorUndoRedoManager) -> void:
_dispatcher = dispatcher
_undo_redo = undo_redo
func batch_execute(params: Dictionary) -> Dictionary:
var commands = params.get("commands", null)
if typeof(commands) != TYPE_ARRAY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands must be a list")
if commands.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands must not be empty")
var undo: bool = params.get("undo", true)
for idx in range(commands.size()):
var item = commands[idx]
if typeof(item) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d] must be a dict" % idx)
var cmd_name: String = item.get("command", "")
if cmd_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands[%d] missing 'command' field" % idx)
if cmd_name in FORBIDDEN_SUBCOMMANDS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "commands[%d]: '%s' is not allowed as a sub-command" % [idx, cmd_name])
if not _dispatcher.has_command(cmd_name):
return _unknown_command_error(idx, cmd_name)
var results: Array = []
var succeeded := 0
var stopped_at = null
var all_undoable := true
# Captured after the first successful commit — get_history_undo_redo()
# errors if called before any action exists in the history_map.
var histories: Array = []
for idx in range(commands.size()):
var item: Dictionary = commands[idx]
var cmd_name: String = item["command"]
var sub_params: Dictionary = item.get("params", {})
var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params)
var status: String = raw_result.get("status", "ok")
var result_entry: Dictionary = {"command": cmd_name, "status": status}
if status == "error":
result_entry["error"] = raw_result.get("error", {})
results.append(result_entry)
stopped_at = idx
break
else:
var data: Dictionary = raw_result.get("data", raw_result)
result_entry["data"] = data
if typeof(data) == TYPE_DICTIONARY and data.get("undoable", false) != true:
all_undoable = false
results.append(result_entry)
succeeded += 1
_capture_histories(histories)
var rolled_back := false
if stopped_at != null and undo and succeeded > 0:
rolled_back = _rollback(succeeded, histories)
var response_data: Dictionary = {
"succeeded": succeeded,
"stopped_at": stopped_at,
"results": results,
"undo": undo,
"rolled_back": rolled_back,
"undoable": stopped_at == null and all_undoable and not rolled_back,
}
if stopped_at != null:
response_data["error"] = results[-1]["error"]
return {"data": response_data}
## Capture the scene's UndoRedo reference for batch rollback. Safe to call
## multiple times; appends only the new reference. MCP write handlers all pin
## their actions to the scene history, so the scene UndoRedo is the only one
## rollback needs. Must be called only after at least one action has been
## committed to the scene history.
func _capture_histories(histories: Array) -> void:
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return
var scene_id := _undo_redo.get_object_history_id(scene_root)
var scene_ur := _undo_redo.get_history_undo_redo(scene_id)
if scene_ur != null and not scene_ur in histories:
histories.append(scene_ur)
## Build the unknown-command error for a sub-command. Clarifies that
## batch_execute expects plugin command names (not MCP tool names) and
## surfaces fuzzy suggestions in both the message and structured data.
func _unknown_command_error(idx: int, cmd_name: String) -> Dictionary:
var suggestions := _dispatcher.suggest_similar(cmd_name)
var msg := "commands[%d]: unknown plugin command '%s'. batch_execute expects plugin command names (e.g. 'create_node'), not MCP tool names (e.g. 'node_create')." % [idx, cmd_name]
if not suggestions.is_empty():
msg += " Did you mean: %s?" % ", ".join(suggestions)
var err := ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, msg)
err["error"]["data"] = {"suggestions": suggestions}
return err
## Undo `count` actions by calling undo() on captured histories in LIFO order.
## Returns true iff all undo calls succeeded.
func _rollback(count: int, histories: Array) -> bool:
if histories.is_empty():
return false
for _i in range(count):
var undone := false
for ur in histories:
if ur.undo():
undone = true
break
if not undone:
return false
return true
@@ -0,0 +1 @@
uid://dt7um75oofdrh
+1151
View File
@@ -0,0 +1,1151 @@
@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
@@ -0,0 +1 @@
uid://c0lcviccrlrl8
@@ -0,0 +1,81 @@
@tool
extends RefCounted
## Opinionated Camera2D / Camera3D presets.
##
## build(preset_name, overrides) -> {default_type, properties} | null
## properties are merged with caller overrides (overrides win).
const _PRESETS := {
# Top-down roguelite / arena — damped follow feel, drag deadzone.
"topdown_2d": {
"default_type": "2d",
"properties": {
"zoom": {"x": 2.0, "y": 2.0},
"anchor_mode": "drag_center",
"position_smoothing_enabled": true,
"position_smoothing_speed": 5.0,
"rotation_smoothing_enabled": false,
"drag_horizontal_enabled": true,
"drag_vertical_enabled": true,
"drag_left_margin": 0.2,
"drag_right_margin": 0.2,
"drag_top_margin": 0.2,
"drag_bottom_margin": 0.2,
},
},
# Platformer — tight horizontal follow, vertical snap with smoothing on.
"platformer_2d": {
"default_type": "2d",
"properties": {
"zoom": {"x": 1.5, "y": 1.5},
"anchor_mode": "drag_center",
"position_smoothing_enabled": true,
"position_smoothing_speed": 8.0,
"drag_horizontal_enabled": true,
"drag_vertical_enabled": false,
"drag_left_margin": 0.15,
"drag_right_margin": 0.15,
},
},
# Cinematic 3D — narrow FOV, long range. Good for dramatic wide shots.
"cinematic_3d": {
"default_type": "3d",
"properties": {
"fov": 40.0,
"near": 0.1,
"far": 500.0,
"projection": "perspective",
},
},
# Action 3D — wider FOV for first/third-person action gameplay.
"action_3d": {
"default_type": "3d",
"properties": {
"fov": 70.0,
"near": 0.1,
"far": 200.0,
"projection": "perspective",
},
},
}
static func list_presets() -> Array:
return _PRESETS.keys()
## Build a preset blueprint. Returns null if preset_name is unknown.
## overrides is merged on top of preset defaults (caller values win).
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var preset: Dictionary = _PRESETS[preset_name]
var properties: Dictionary = (preset.get("properties", {}) as Dictionary).duplicate(true)
for key in overrides:
properties[key] = overrides[key]
return {
"default_type": preset.get("default_type", "2d"),
"properties": properties,
}
@@ -0,0 +1 @@
uid://bl3rfy72o3wy5
+143
View File
@@ -0,0 +1,143 @@
@tool
extends RefCounted
## Value coercion helpers for camera authoring.
##
## Handles:
## - enum-by-name (keep_aspect="keep_height" -> Camera3D.KEEP_HEIGHT)
## - {x, y} dict -> Vector2 (zoom, offset, drag_*_offset)
## - serialization back to JSON-friendly shapes
const _ENUM_TABLES := {
"projection": {
"perspective": Camera3D.PROJECTION_PERSPECTIVE,
"orthogonal": Camera3D.PROJECTION_ORTHOGONAL,
"frustum": Camera3D.PROJECTION_FRUSTUM,
},
"keep_aspect": {
"keep_width": Camera3D.KEEP_WIDTH,
"keep_height": Camera3D.KEEP_HEIGHT,
},
"anchor_mode": {
"fixed_top_left": Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT,
"drag_center": Camera2D.ANCHOR_MODE_DRAG_CENTER,
},
"doppler_tracking": {
"disabled": Camera3D.DOPPLER_TRACKING_DISABLED,
"idle_step": Camera3D.DOPPLER_TRACKING_IDLE_STEP,
"physics_step": Camera3D.DOPPLER_TRACKING_PHYSICS_STEP,
},
"process_callback": {
"physics": Camera2D.CAMERA2D_PROCESS_PHYSICS,
"idle": Camera2D.CAMERA2D_PROCESS_IDLE,
},
}
## Return the enum int for (property, string_name), or null if not a known enum string.
static func resolve_enum(property: String, value: Variant) -> Variant:
if not (value is String):
return null
if not _ENUM_TABLES.has(property):
return null
var table: Dictionary = _ENUM_TABLES[property]
var key: String = String(value).to_lower()
if table.has(key):
return table[key]
return null
## Valid enum names for a property, for error messages.
static func enum_keys(property: String) -> Array:
if not _ENUM_TABLES.has(property):
return []
return (_ENUM_TABLES[property] as Dictionary).keys()
static func parse_vector2(value: Variant) -> Variant:
if value is Vector2:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector2(float(d.get("x", 0)), float(d.get("y", 0)))
if value is Array and value.size() >= 2:
return Vector2(float(value[0]), float(value[1]))
if value is int or value is float:
return Vector2(float(value), float(value))
return null
static func parse_vector3(value: Variant) -> Variant:
if value is Vector3:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector3(float(d.get("x", 0)), float(d.get("y", 0)), float(d.get("z", 0)))
if value is Array and value.size() >= 3:
return Vector3(float(value[0]), float(value[1]), float(value[2]))
return null
## Coerce a JSON-shaped value for a camera property against the declared type.
## Returns {ok: true, value: ...} or {ok: false, error: "..."}.
static func coerce(property: String, value: Variant, target_type: int) -> Dictionary:
# Enum-by-name: must match before generic TYPE_INT coercion.
if _ENUM_TABLES.has(property):
if value is String:
var enum_val = resolve_enum(property, value)
if enum_val == null:
return {
"ok": false,
"error": "Invalid %s value: '%s'. Valid: %s" % [
property, value, ", ".join(enum_keys(property))
],
}
return {"ok": true, "value": int(enum_val)}
if value is int or value is float:
return {"ok": true, "value": int(value)}
match target_type:
TYPE_VECTOR2:
var v2 = parse_vector2(value)
if v2 == null:
return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]}
return {"ok": true, "value": v2}
TYPE_VECTOR3:
var v3 = parse_vector3(value)
if v3 == null:
return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]}
return {"ok": true, "value": v3}
TYPE_BOOL:
if value is bool:
return {"ok": true, "value": value}
if value is int or value is float:
return {"ok": true, "value": bool(value)}
return {"ok": false, "error": "Expected bool for %s" % property}
TYPE_INT:
if value is int:
return {"ok": true, "value": value}
if value is float:
return {"ok": true, "value": int(value)}
return {"ok": false, "error": "Expected int for %s" % property}
TYPE_FLOAT:
if value is float:
return {"ok": true, "value": value}
if value is int:
return {"ok": true, "value": float(value)}
return {"ok": false, "error": "Expected number for %s" % property}
TYPE_STRING:
return {"ok": true, "value": String(value)}
return {"ok": true, "value": value}
## Serialize a Variant into a JSON-friendly shape for responses.
static func serialize(value: Variant) -> Variant:
if value == null:
return null
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}
return value
@@ -0,0 +1 @@
uid://bgjnubgnv6ses
@@ -0,0 +1,43 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles MCP client configuration commands.
func configure_client(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
if not McpClientConfigurator.has_client(client_id):
var valid := ", ".join(McpClientConfigurator.client_ids())
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid])
var result := McpClientConfigurator.configure(client_id)
if result.get("status") == "error":
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
result.get("message", "Configuration failed for '%s'" % client_id))
return {"data": result}
func remove_client(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
if not McpClientConfigurator.has_client(client_id):
var valid := ", ".join(McpClientConfigurator.client_ids())
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid])
var result := McpClientConfigurator.remove(client_id)
if result.get("status") == "error":
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
result.get("message", "Removal failed for '%s'" % client_id))
return {"data": result}
func check_client_status(_params: Dictionary) -> Dictionary:
var clients := []
for client_id in McpClientConfigurator.client_ids():
var status := McpClientConfigurator.check_status(client_id)
clients.append({
"id": client_id,
"display_name": McpClientConfigurator.client_display_name(client_id),
"status": McpClient.status_label(status),
"installed": McpClientConfigurator.is_installed(client_id),
})
return {"data": {"clients": clients}}
@@ -0,0 +1 @@
uid://bmo4foc5fq75c
@@ -0,0 +1,325 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles the control_draw_recipe MCP command. Attaches a shared DrawRecipe
## script to a Control and stores the caller's ordered draw ops in node
## metadata under "_ops". The DrawRecipe script dispatches each op to a
## CanvasItem draw_* call in _draw(). One Ctrl+Z reverts script + meta as a
## single undo step.
const DRAW_RECIPE_SCRIPT := preload("res://addons/godot_ai/runtime/draw_recipe.gd")
const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd")
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
func control_draw_recipe(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var ops_raw: Variant = params.get("ops", null)
var clear_existing: bool = bool(params.get("clear_existing", true))
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if typeof(ops_raw) != TYPE_ARRAY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "ops must be an Array")
var _resolved := McpNodeValidator.resolve_or_error(path, "path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
if not node is Control:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"control_draw_recipe requires a Control node, got %s" % node.get_class()
)
var coerced := _coerce_ops(ops_raw)
if coerced.has("error"):
return coerced
var coerced_ops: Array = coerced.ops
var old_script: Variant = node.get_script()
if old_script != null and old_script != DRAW_RECIPE_SCRIPT:
if not clear_existing:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
(
"Node %s already has a script. Pass clear_existing=true to replace."
% path
)
)
var had_meta := node.has_meta("_ops")
var old_ops: Variant = node.get_meta("_ops") if had_meta else null
_undo_redo.create_action("MCP: Draw recipe on %s" % node.name)
_undo_redo.add_do_method(node, "set_script", DRAW_RECIPE_SCRIPT)
_undo_redo.add_do_method(node, "set_meta", "_ops", coerced_ops)
_undo_redo.add_do_method(node, "queue_redraw")
_undo_redo.add_undo_method(node, "set_script", old_script)
if had_meta:
_undo_redo.add_undo_method(node, "set_meta", "_ops", old_ops)
else:
_undo_redo.add_undo_method(node, "remove_meta", "_ops")
_undo_redo.add_undo_method(node, "queue_redraw")
_undo_redo.commit_action()
return {
"data":
{
"path": McpScenePath.from_node(node, scene_root),
"ops_count": coerced_ops.size(),
"script_attached": old_script == null,
"script_replaced": old_script != null and old_script != DRAW_RECIPE_SCRIPT,
"undoable": true,
}
}
## Populate a freshly-instantiated Control with the draw recipe in memory
## (no undo action). Used by PR2's pattern_corner_brackets, which wraps the
## node-add + set_script/set_meta in its own create_action.
static func attach_recipe_to(node: Control, coerced_ops: Array) -> void:
node.set_script(DRAW_RECIPE_SCRIPT)
node.set_meta("_ops", coerced_ops)
## Validate and coerce every op dict. Returns {"ops": Array} or an error dict.
func _coerce_ops(ops: Array) -> Dictionary:
var result: Array = []
for i in ops.size():
var op: Variant = ops[i]
if typeof(op) != TYPE_DICTIONARY:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE, "ops[%d] must be a dictionary" % i
)
var coerced := _coerce_single_op(op, i)
if coerced.has("error"):
return coerced
result.append(coerced.op)
return {"ops": result}
func _coerce_single_op(op: Dictionary, idx: int) -> Dictionary:
var draw_type: String = op.get("draw", "")
if draw_type.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d]: missing 'draw' field" % idx
)
match draw_type:
"line":
return _coerce_line(op, idx)
"rect":
return _coerce_rect(op, idx)
"arc":
return _coerce_arc(op, idx)
"circle":
return _coerce_circle(op, idx)
"polyline":
return _coerce_polyline_or_polygon(op, idx, "polyline")
"polygon":
return _coerce_polyline_or_polygon(op, idx, "polygon")
"string":
return _coerce_string(op, idx)
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"ops[%d]: unknown draw type '%s'" % [idx, draw_type]
)
func _require_fields(op: Dictionary, idx: int, kind: String, fields: Array) -> Dictionary:
for f in fields:
if not op.has(f):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): missing '%s'" % [idx, kind, f]
)
return {}
func _coerce_typed(value: Variant, prop_type: int, idx: int, kind: String, field: String) -> Dictionary:
var r := UiHandler._coerce_for_type(value, prop_type)
if r.ok:
return {"ok": true, "value": r.value}
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid '%s'" % [idx, kind, field]
)
func _coerce_line(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "line", ["from", "to", "color"])
if missing.has("error"):
return missing
var frm := _coerce_typed(op.from, TYPE_VECTOR2, idx, "line", "from")
if frm.has("error"):
return frm
var to_ := _coerce_typed(op.to, TYPE_VECTOR2, idx, "line", "to")
if to_.has("error"):
return to_
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "line", "color")
if c.has("error"):
return c
var out := {"draw": "line", "from": frm.value, "to": to_.value, "color": c.value}
if op.has("width"):
out["width"] = float(op.width)
if op.has("antialiased"):
out["antialiased"] = bool(op.antialiased)
return {"op": out}
func _coerce_rect(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "rect", ["rect", "color"])
if missing.has("error"):
return missing
var r := _coerce_typed(op.rect, TYPE_RECT2, idx, "rect", "rect")
if r.has("error"):
return r
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "rect", "color")
if c.has("error"):
return c
var out := {"draw": "rect", "rect": r.value, "color": c.value}
if op.has("filled"):
out["filled"] = bool(op.filled)
if op.has("width"):
out["width"] = float(op.width)
return {"op": out}
func _coerce_arc(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(
op, idx, "arc", ["center", "radius", "start_angle", "end_angle", "color"]
)
if missing.has("error"):
return missing
var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "arc", "center")
if center.has("error"):
return center
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "arc", "color")
if c.has("error"):
return c
var out := {
"draw": "arc",
"center": center.value,
"radius": float(op.radius),
"start_angle": float(op.start_angle),
"end_angle": float(op.end_angle),
"color": c.value,
}
if op.has("point_count"):
out["point_count"] = int(op.point_count)
if op.has("width"):
out["width"] = float(op.width)
if op.has("antialiased"):
out["antialiased"] = bool(op.antialiased)
return {"op": out}
func _coerce_circle(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "circle", ["center", "radius", "color"])
if missing.has("error"):
return missing
var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "circle", "center")
if center.has("error"):
return center
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "circle", "color")
if c.has("error"):
return c
return {
"op":
{
"draw": "circle",
"center": center.value,
"radius": float(op.radius),
"color": c.value,
}
}
func _coerce_polyline_or_polygon(op: Dictionary, idx: int, kind: String) -> Dictionary:
if not op.has("points"):
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d] (%s): missing 'points'" % [idx, kind]
)
if typeof(op.points) != TYPE_ARRAY:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"ops[%d] (%s): 'points' must be an Array" % [idx, kind]
)
var points := PackedVector2Array()
for j in op.points.size():
var p := UiHandler._coerce_for_type(op.points[j], TYPE_VECTOR2)
if not p.ok:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): points[%d] invalid" % [idx, kind, j]
)
points.append(p.value)
var out := {"draw": kind, "points": points}
if op.has("colors"):
if typeof(op.colors) != TYPE_ARRAY:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): 'colors' must be an Array" % [idx, kind]
)
var colors := PackedColorArray()
for k in op.colors.size():
var ck := UiHandler._coerce_for_type(op.colors[k], TYPE_COLOR)
if not ck.ok:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ops[%d] (%s): colors[%d] invalid" % [idx, kind, k]
)
colors.append(ck.value)
out["colors"] = colors
elif op.has("color"):
var c := UiHandler._coerce_for_type(op.color, TYPE_COLOR)
if not c.ok:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid 'color'" % [idx, kind]
)
out["color"] = c.value
else:
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"ops[%d] (%s): missing 'color' or 'colors'" % [idx, kind]
)
if op.has("width"):
out["width"] = float(op.width)
if op.has("antialiased"):
out["antialiased"] = bool(op.antialiased)
return {"op": out}
func _coerce_string(op: Dictionary, idx: int) -> Dictionary:
var missing := _require_fields(op, idx, "string", ["position", "text", "color"])
if missing.has("error"):
return missing
var pos := _coerce_typed(op.position, TYPE_VECTOR2, idx, "string", "position")
if pos.has("error"):
return pos
var c := _coerce_typed(op.color, TYPE_COLOR, idx, "string", "color")
if c.has("error"):
return c
var out := {
"draw": "string",
"position": pos.value,
"text": str(op.text),
"color": c.value,
}
if op.has("font_size"):
out["font_size"] = int(op.font_size)
if op.has("align"):
out["align"] = int(op.align)
if op.has("max_width"):
out["max_width"] = float(op.max_width)
return {"op": out}
@@ -0,0 +1 @@
uid://buat1mt0fjlqb
+243
View File
@@ -0,0 +1,243 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Replaces all points on a Curve / Curve2D / Curve3D resource. The point
## list shape depends on resource type (see `set_points` for the schemas).
##
## Dedicated tool rather than a property set because Curve2D/Curve3D.add_point
## is a method call, not a property — resource_create's `properties` dict can't
## reach it.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
func set_points(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
var new_points: Array = params.get("points", [])
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var has_file_target := not resource_path.is_empty()
if not (new_points is Array):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "points must be an array")
var curve: Resource
var node: Node = null
var curve_created := false
if has_file_target:
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
if rpath_err != null:
return rpath_err
if not ResourceLoader.exists(resource_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
# ResourceLoader.load() returns Godot's cached Resource. Duplicate
# before mutating so: (a) open scenes holding a reference to this
# .tres don't silently see the new points outside any undo action,
# and (b) if ResourceSaver.save() fails we haven't corrupted the
# in-memory cache (cache/disk divergence). Also guard against
# ResourceLoader.exists() succeeding but load() returning null
# (corrupt .tres, unregistered class) — otherwise curve.get_class()
# on the response line below would crash the plugin.
var loaded_curve: Resource = ResourceLoader.load(resource_path)
if loaded_curve == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to load curve from %s (file exists but load returned null — may be corrupt)" % resource_path
)
curve = loaded_curve.duplicate()
else:
var _scene_check := McpNodeValidator.require_scene_or_error()
if _scene_check.has("error"):
return _scene_check
var scene_root: Node = _scene_check.scene_root
node = McpScenePath.resolve(node_path, scene_root)
if node == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(node_path, scene_root))
if not (property in node):
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s" % [property, node.get_class()]
)
curve = node.get(property)
# Auto-create a fresh Curve subclass if the slot is empty. Infer the
# concrete class from the property's hint_string (e.g. Path3D.curve's
# hint is "Curve3D"). Creation is bundled into the same undo action
# as the point-set below, so Ctrl-Z rolls back both.
if curve == null:
var inferred := _infer_curve_class(node, property)
if inferred.is_empty():
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve slot on %s.%s is null and the Curve class can't be inferred from the property hint — create one first with resource_create (type=Curve3D/Curve2D/Curve)" % [node.get_class(), property]
)
curve = ClassDB.instantiate(inferred)
if curve == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % inferred)
curve_created = true
if not (curve is Curve or curve is Curve2D or curve is Curve3D):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource is %s — must be Curve, Curve2D, or Curve3D" % curve.get_class()
)
var coerced := _coerce_points(curve, new_points)
if coerced.has("error"):
return coerced.error
var new_snapshot: Array = coerced.snapshot
if has_file_target:
_apply_snapshot_to_curve(curve, new_snapshot)
# curve_set_points EDITS an existing .tres, so override the default
# "delete to revert" message via extra_fields.
return McpResourceIO.save_to_disk(curve, resource_path, true, "Curve", {
"curve_class": curve.get_class(),
"point_count": new_snapshot.size(),
"reason": "File save is persistent; edit the .tres file manually to revert",
}, _connection)
# Inline (node-attached) path: swap the curve property so the action lands
# cleanly in scene history, mirroring the resource-swap pattern used by
# material_handler::assign_material. When curve_created is true the
# "old" value is null — undo clears the slot back to empty.
var new_curve: Resource = curve if curve_created else curve.duplicate()
_apply_snapshot_to_curve(new_curve, new_snapshot)
var old_curve: Resource = null if curve_created else curve
_undo_redo.create_action("MCP: Set %d points on %s.%s" % [new_snapshot.size(), node.name, property])
_undo_redo.add_do_property(node, property, new_curve)
_undo_redo.add_undo_property(node, property, old_curve)
_undo_redo.add_do_reference(new_curve)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"property": property,
"curve_class": new_curve.get_class(),
"point_count": new_snapshot.size(),
"curve_created": curve_created,
"undoable": true,
}
}
## Infer the concrete Curve class to instantiate for a null property slot.
## Reads the property's hint_string (set by Godot on resource-typed exports)
## to get the exact accepted class name (e.g. "Curve3D" for Path3D.curve).
## Returns empty string if no viable curve class can be determined.
static func _infer_curve_class(node: Node, property: String) -> String:
for prop in node.get_property_list():
if prop.name != property:
continue
var hint_string: String = prop.get("hint_string", "")
if hint_string.is_empty():
return ""
if not ClassDB.class_exists(hint_string):
return ""
if hint_string == "Curve" or hint_string == "Curve2D" or hint_string == "Curve3D":
return hint_string
# Some custom properties may list a parent class; require an exact
# match against our three supported types to avoid surprises.
return ""
return ""
## Convert input `points` into a normalized snapshot of typed values for
## the given curve type. Returns {snapshot: Array} on success or
## {error: ...} on failure.
static func _coerce_points(curve: Resource, points: Array) -> Dictionary:
var snapshot: Array = []
if curve is Curve:
for i in range(points.size()):
var p = points[i]
if not (p is Dictionary) or not p.has("offset") or not p.has("value"):
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve points[%d] must be {offset, value, [left_tangent, right_tangent]}" % i
)}
snapshot.append({
"offset": float(p["offset"]),
"value": float(p["value"]),
"left_tangent": float(p.get("left_tangent", 0.0)),
"right_tangent": float(p.get("right_tangent", 0.0)),
})
elif curve is Curve2D:
var zero2 := {"x": 0, "y": 0}
for i in range(points.size()):
var p2 = points[i]
if not (p2 is Dictionary) or not p2.has("position"):
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve2D points[%d] must have 'position' (and optional 'in', 'out')" % i
)}
var axes2 := {
"position": p2["position"],
"in": p2.get("in", zero2),
"out": p2.get("out", zero2),
}
var coerced2 := {}
for field in ["position", "in", "out"]:
var v = NodeHandler._coerce_value(axes2[field], TYPE_VECTOR2)
var err := NodeHandler._check_coerced(v, TYPE_VECTOR2, "Curve2D points[%d].%s" % [i, field])
if err != null:
return {"error": err}
coerced2[field] = v
snapshot.append(coerced2)
else: # Curve3D
var zero3 := {"x": 0, "y": 0, "z": 0}
for i in range(points.size()):
var p3 = points[i]
if not (p3 is Dictionary) or not p3.has("position"):
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Curve3D points[%d] must have 'position' (and optional 'in', 'out', 'tilt')" % i
)}
var axes3 := {
"position": p3["position"],
"in": p3.get("in", zero3),
"out": p3.get("out", zero3),
}
var coerced3 := {}
for field in ["position", "in", "out"]:
var v = NodeHandler._coerce_value(axes3[field], TYPE_VECTOR3)
var err := NodeHandler._check_coerced(v, TYPE_VECTOR3, "Curve3D points[%d].%s" % [i, field])
if err != null:
return {"error": err}
coerced3[field] = v
coerced3["tilt"] = float(p3.get("tilt", 0.0))
snapshot.append(coerced3)
return {"snapshot": snapshot}
func _apply_snapshot_to_curve(curve: Resource, snapshot: Array) -> void:
curve.clear_points()
if curve is Curve:
for p: Dictionary in snapshot:
curve.add_point(
Vector2(p.offset, p.value),
p.left_tangent,
p.right_tangent
)
elif curve is Curve2D:
for p: Dictionary in snapshot:
curve.add_point(p.position, p["in"], p.out)
elif curve is Curve3D:
for i in range(snapshot.size()):
var p: Dictionary = snapshot[i]
curve.add_point(p.position, p["in"], p.out)
curve.set_point_tilt(i, p.tilt)
@@ -0,0 +1 @@
uid://dboqr06a1fvqx
+1098
View File
@@ -0,0 +1,1098 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const Telemetry := preload("res://addons/godot_ai/telemetry.gd")
## Handles editor state, selection, log, screenshot, and performance commands.
const UpdateMixedState := preload("res://addons/godot_ai/utils/update_mixed_state.gd")
var _log_buffer: McpLogBuffer
var _connection: McpConnection
var _debugger_plugin: McpDebuggerPlugin
var _game_log_buffer: McpGameLogBuffer
var _editor_log_buffer: McpEditorLogBuffer
var _debugger_errors_root: Node
var _debugger_search_root_cache: Node
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null) -> void:
_log_buffer = log_buffer
_connection = connection
_debugger_plugin = debugger_plugin
_game_log_buffer = game_log_buffer
_editor_log_buffer = editor_log_buffer
_debugger_errors_root = debugger_errors_root
func get_editor_state(_params: Dictionary) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
var data := {
"godot_version": Engine.get_version_info().get("string", "unknown"),
"project_name": ProjectSettings.get_setting("application/config/name", ""),
"current_scene": scene_root.scene_file_path if scene_root else "",
"is_playing": EditorInterface.is_playing_scene(),
"readiness": McpConnection.get_readiness(),
## True once the game subprocess autoload has beaconed mcp:hello;
## false between Play→Stop cycles. Lets capture-source=game callers
## poll for a real ready signal instead of guessing with sleep().
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
}
## Half-installed addon tree from a failed self-update rollback. When
## non-empty, the agent / dock paint the operator-facing recovery copy
## from `update_mixed_state.gd::diagnose`. Field omitted when the
## addons tree is clean so editor_state's normal payload stays small.
## See issue #354 / audit-v2 #10.
var mixed_state := UpdateMixedState.diagnose()
if not mixed_state.is_empty():
data["mixed_state"] = mixed_state
return {"data": data}
func get_selection(_params: Dictionary) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
var selected := EditorInterface.get_selection().get_selected_nodes()
var paths: Array[String] = []
for node in selected:
paths.append(McpScenePath.from_node(node, scene_root))
return {"data": {"selected_paths": paths, "count": paths.size()}}
const VALID_LOG_SOURCES := ["plugin", "game", "editor", "all"]
func get_logs(params: Dictionary) -> Dictionary:
## Coerce defensively — MCP clients can send JSON numbers as floats or
## stray `null` values that would otherwise fail the typed locals
## before we ever reach the INVALID_PARAMS return below.
var count: int = maxi(0, int(params.get("count", 50)))
var offset: int = maxi(0, int(params.get("offset", 0)))
var source: String = str(params.get("source", "plugin"))
var include_details: bool = bool(params.get("include_details", false))
if not source in VALID_LOG_SOURCES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid source '%s' — use 'plugin', 'game', 'editor', or 'all'" % source,
)
match source:
"plugin":
return _get_plugin_logs(count, offset)
"game":
return _get_game_logs(count, offset, include_details)
"editor":
return _get_editor_logs(count, offset, include_details)
"all":
return _get_all_logs(count, offset, include_details)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
var page: Array[Dictionary] = []
var stop := mini(all_lines.size(), offset + count)
for i in range(mini(offset, all_lines.size()), stop):
page.append({"source": "plugin", "level": "info", "text": all_lines[i]})
return {
"data": {
"source": "plugin",
"lines": page,
"total_count": all_lines.size(),
"returned_count": page.size(),
"offset": offset,
}
}
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
if _game_log_buffer == null:
return {
"data": {
"source": "game",
"lines": [],
"total_count": 0,
"returned_count": 0,
"offset": offset,
"run_id": "",
"is_running": false,
"dropped_count": 0,
}
}
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
return {
"data": {
"source": "game",
"lines": page,
"total_count": _game_log_buffer.total_count(),
"returned_count": page.size(),
"offset": offset,
"run_id": _game_log_buffer.run_id(),
"is_running": EditorInterface.is_playing_scene(),
"dropped_count": _game_log_buffer.dropped_count(),
}
}
func _get_editor_logs(count: int, offset: int, include_details: bool) -> Dictionary:
## Editor-process script errors (parse errors, @tool runtime errors,
## EditorPlugin errors, push_error/push_warning). Captured by
## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older
## engines the buffer can be null. Godot also sends GDScript reload
## warnings/errors straight to the Debugger dock's Errors tab; those do
## not flow through OS.add_logger, so merge the visible tree rows here.
var all_entries := _collect_editor_log_entries()
var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details)
return {
"data": {
"source": "editor",
"lines": page,
"total_count": all_entries.size(),
"returned_count": page.size(),
"offset": offset,
"dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0,
}
}
func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary:
## Plugin lines have no timestamp, so we can't merge chronologically.
## Concatenate plugin → editor → game and apply the offset/count window
## over the combined list. The per-line `source` field tells callers
## where each entry came from. Editor goes between plugin and game so
## script errors stay grouped near the plugin recv/send traffic that
## triggered them, with game runtime logs at the end.
var combined: Array[Dictionary] = []
for line in _log_buffer.get_recent(_log_buffer.total_count()):
combined.append({"source": "plugin", "level": "info", "text": line})
for entry in _collect_editor_log_entries():
combined.append(entry)
if _game_log_buffer != null:
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
combined.append(entry)
var stop := mini(combined.size(), offset + count)
var page: Array[Dictionary] = []
for i in range(mini(offset, combined.size()), stop):
page.append(combined[i])
page = _entries_for_response(page, include_details)
var run_id := ""
var dropped := 0
if _game_log_buffer != null:
run_id = _game_log_buffer.run_id()
dropped = _game_log_buffer.dropped_count()
if _editor_log_buffer != null:
dropped += _editor_log_buffer.dropped_count()
return {
"data": {
"source": "all",
"lines": page,
"total_count": combined.size(),
"returned_count": page.size(),
"offset": offset,
"run_id": run_id,
"is_running": EditorInterface.is_playing_scene(),
"dropped_count": dropped,
}
}
func _entries_for_response(entries: Array[Dictionary], include_details: bool) -> Array[Dictionary]:
## Compact responses only drop the top-level "details" key, so a shallow
## copy is enough; the deep copy is reserved for the opt-in details path
## where nested dicts leave the buffer.
var out: Array[Dictionary] = []
for entry in entries:
if include_details:
out.append(entry.duplicate(true))
else:
var copy: Dictionary = entry.duplicate(false)
copy.erase("details")
out.append(copy)
return out
func _collect_editor_log_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
entries.append(entry)
for entry in _read_debugger_error_entries():
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in _locate_debugger_error_trees():
for entry in _entries_from_debugger_error_tree(tree):
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
if _debugger_plugin == null and _debugger_errors_root == null:
return trees
var root: Node = _debugger_errors_root
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func _debugger_search_root() -> Node:
## logs_read is a polling tool, so per-call discovery must not recurse the
## entire editor UI. EditorDebuggerNode is the bottom-panel container that
## owns every ScriptEditorDebugger session tab and lives for the editor's
## lifetime — find it once from the base control, then scan only its
## subtree on later calls. The error Trees themselves can't be cached:
## they are identified by their content, and an emptied tree is
## indistinguishable from any other Tree.
if is_instance_valid(_debugger_search_root_cache):
return _debugger_search_root_cache
_debugger_search_root_cache = null
var base := EditorInterface.get_base_control()
if base == null:
return null
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
if _debugger_search_root_cache == null:
return base
return _debugger_search_root_cache
static func _find_first_of_class(node: Node, klass: String) -> Node:
if node.get_class() == klass:
return node
for child in node.get_children():
var found := _find_first_of_class(child, klass)
if found != null:
return found
return null
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
if node is Tree and _tree_has_debugger_errors(node as Tree):
out.append(node as Tree)
for child in node.get_children():
if child is Node:
_collect_debugger_error_trees(child as Node, out)
static func _tree_has_debugger_errors(tree: Tree) -> bool:
var root := tree.get_root()
if root == null:
return false
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
return true
item = item.get_next()
return false
static func _entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return entries
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
entries.append(_entry_from_debugger_error_item(item))
item = item.get_next()
return entries
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
var title := item.get_text(1)
var loc := _location_from_metadata(item.get_metadata(0))
var function := _function_from_title(title)
return {
"source": "editor",
"level": "warn" if item.has_meta("_is_warning") else "error",
"text": title,
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
"details": _details_from_debugger_error_item(item, loc, function),
}
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
var children: Array[Dictionary] = []
var child := item.get_first_child()
while child != null:
var child_loc := _location_from_metadata(child.get_metadata(0))
children.append({
"label": child.get_text(0),
"text": child.get_text(1),
"path": str(child_loc.get("path", "")),
"line": int(child_loc.get("line", 0)),
})
child = child.get_next()
return {
"debugger_tab": "Errors",
"time": item.get_text(0),
"message": item.get_text(1),
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
"source": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"resolved": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"children": children,
"frames": _frames_from_error_children(children),
}
static func _is_debugger_error_item(item: TreeItem) -> bool:
return item.has_meta("_is_warning") or item.has_meta("_is_error")
## ScriptEditorDebugger lays out an error item's children flat, in order: an
## optional "<X Error>" row, one "<X Source>" row, then one row per stack
## frame. Only frame 0 carries the "<Stack Trace>" label (TTR-translated);
## later frames have an empty label. Every frame row carries [path, line]
## metadata, but so can the Error/Source rows, so metadata alone can't
## identify frames — the frame run has to be found first.
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
var start := -1
for i in children.size():
if str(children[i].label).contains("Stack Trace"):
start = i
break
if start < 0:
## Non-English editor locale: the "<Stack Trace>" label is translated.
## Frames past the first are the only rows with an empty label and a
## real location; back up one row to recover the labeled first frame
## (rows before the frame run always have a non-empty label).
for i in children.size():
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
start = maxi(i - 1, 0)
break
if start < 0:
return []
var frames: Array[Dictionary] = []
for i in range(start, children.size()):
if str(children[i].path).is_empty():
continue
frames.append({
"path": children[i].path,
"line": children[i].line,
"function": _function_from_frame_text(children[i].text),
})
return frames
static func _location_from_metadata(meta: Variant) -> Dictionary:
if meta is Array and meta.size() >= 2:
return {"path": str(meta[0]), "line": int(meta[1])}
return {"path": "", "line": 0}
static func _function_from_title(title: String) -> String:
var colon := title.find(": ")
if colon <= 0:
return ""
return title.substr(0, colon)
static func _function_from_frame_text(text: String) -> String:
var marker := text.find(" @ ")
if marker < 0:
return ""
var fn := text.substr(marker + 3).strip_edges()
if fn.ends_with("()"):
fn = fn.substr(0, fn.length() - 2)
return fn
static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]:
var page: Array[Dictionary] = []
var stop := mini(entries.size(), offset + count)
for i in range(mini(offset, entries.size()), stop):
page.append(entries[i])
return page
static func _has_equivalent_log_entry(entries: Array[Dictionary], candidate: Dictionary) -> bool:
var key := _log_entry_key(candidate)
for entry in entries:
if _log_entry_key(entry) == key:
return true
return false
static func _log_entry_key(entry: Dictionary) -> String:
return "%s|%s|%s|%s" % [
str(entry.get("level", "")),
str(entry.get("text", "")),
str(entry.get("path", "")),
str(entry.get("line", 0)),
]
## Map of human-readable monitor names to Performance.Monitor enum values.
const MONITORS := {
"time/fps": Performance.TIME_FPS,
"time/process": Performance.TIME_PROCESS,
"time/physics_process": Performance.TIME_PHYSICS_PROCESS,
"time/navigation_process": Performance.TIME_NAVIGATION_PROCESS,
"memory/static": Performance.MEMORY_STATIC,
"memory/static_max": Performance.MEMORY_STATIC_MAX,
"memory/message_buffer_max": Performance.MEMORY_MESSAGE_BUFFER_MAX,
"object/count": Performance.OBJECT_COUNT,
"object/resource_count": Performance.OBJECT_RESOURCE_COUNT,
"object/node_count": Performance.OBJECT_NODE_COUNT,
"object/orphan_node_count": Performance.OBJECT_ORPHAN_NODE_COUNT,
"render/total_objects_in_frame": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME,
"render/total_primitives_in_frame": Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME,
"render/total_draw_calls_in_frame": Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME,
"render/video_mem_used": Performance.RENDER_VIDEO_MEM_USED,
"physics_2d/active_objects": Performance.PHYSICS_2D_ACTIVE_OBJECTS,
"physics_2d/collision_pairs": Performance.PHYSICS_2D_COLLISION_PAIRS,
"physics_2d/island_count": Performance.PHYSICS_2D_ISLAND_COUNT,
"physics_3d/active_objects": Performance.PHYSICS_3D_ACTIVE_OBJECTS,
"physics_3d/collision_pairs": Performance.PHYSICS_3D_COLLISION_PAIRS,
"physics_3d/island_count": Performance.PHYSICS_3D_ISLAND_COUNT,
"navigation/active_maps": Performance.NAVIGATION_ACTIVE_MAPS,
"navigation/region_count": Performance.NAVIGATION_REGION_COUNT,
"navigation/agent_count": Performance.NAVIGATION_AGENT_COUNT,
"navigation/link_count": Performance.NAVIGATION_LINK_COUNT,
"navigation/polygon_count": Performance.NAVIGATION_POLYGON_COUNT,
"navigation/edge_count": Performance.NAVIGATION_EDGE_COUNT,
"navigation/edge_merge_count": Performance.NAVIGATION_EDGE_MERGE_COUNT,
"navigation/edge_connection_count": Performance.NAVIGATION_EDGE_CONNECTION_COUNT,
"navigation/edge_free_count": Performance.NAVIGATION_EDGE_FREE_COUNT,
}
## Compute coverage angles from the target's AABB geometry.
## Returns an establishing perspective shot (faces the longest ground axis)
## and an orthographic top-down for spatial layout. The AI iterates from
## there with explicit elevation/azimuth/fov for closeups and detail shots.
func _compute_coverage_angles(aabb: AABB) -> Array[Dictionary]:
var size := aabb.size
var ground_x := maxf(size.x, 0.01)
var ground_z := maxf(size.z, 0.01)
## Face the longest ground axis — establishing shot shows maximum extent
var estab_azimuth: float
if ground_x >= ground_z:
estab_azimuth = 0.0 # face along Z, showing X width
else:
estab_azimuth = 90.0 # face along X, showing Z width
## FOV: wider for spread-out subjects, narrower for compact ones
var ground_ratio := maxf(ground_x, ground_z) / minf(ground_x, ground_z)
var estab_fov := clampf(40.0 + ground_ratio * 5.0, 45.0, 65.0)
return [
{"label": "establishing", "elevation": 25.0, "azimuth": estab_azimuth + 20.0,
"fov": estab_fov, "ortho": false, "padding": 1.8},
{"label": "top", "elevation": 90.0, "azimuth": 0.0,
"fov": 0.0, "ortho": true},
]
func take_screenshot(params: Dictionary) -> Dictionary:
var source: String = params.get("source", "viewport")
var max_resolution: int = params.get("max_resolution", 0)
var view_target: String = params.get("view_target", "")
var coverage: bool = params.get("coverage", false)
var custom_elevation = params.get("elevation", null)
var custom_azimuth = params.get("azimuth", null)
var custom_fov = params.get("fov", null)
var viewport: Viewport
match source:
"viewport":
viewport = EditorInterface.get_editor_viewport_3d()
if viewport == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No 3D viewport available")
## The 3D viewport's texture is empty when the edited scene
## has no Node3D content (2D-only scene, or no scene open),
## and the empty-image guard further down used to surface
## that as INTERNAL_ERROR — leaving callers with no signal
## that the failure was caller-side. Reject up front with a
## structured hint so the LLM can pick a sensible next step
## (open a 3D scene, switch to source="cinematic", etc.).
var precheck := viewport_screenshot_precheck(EditorInterface.get_edited_scene_root())
if precheck.has("error"):
return precheck
"game":
if not EditorInterface.is_playing_scene():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Game is not running — use source='viewport' or start the project first")
## The game is always a separate OS process (embedded mode just
## reparents its window into the editor). Reach the framebuffer
## via the debugger channel: the `_mcp_game_helper` autoload
## inside the game process replies with a PNG, and
## McpDebuggerPlugin pushes the response back through our
## WebSocket with the same request_id via McpConnection.send_deferred_response.
if _debugger_plugin == null or _connection == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Debugger bridge unavailable — plugin may not be fully initialised")
var request_id: String = params.get("_request_id", "")
if request_id.is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Missing request_id — cannot correlate deferred response")
_debugger_plugin.request_game_screenshot(request_id, max_resolution, _connection)
return McpDispatcher.DEFERRED_RESPONSE
"cinematic":
return _take_cinematic_screenshot(max_resolution)
_:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'cinematic', or 'game'" % source)
## Handle view_target: temporarily reposition the editor's own camera to
## frame one or more target nodes, force a render, capture, then restore.
if not view_target.is_empty() and source == "viewport":
var _scene_check := McpNodeValidator.require_scene_or_error()
if _scene_check.has("error"):
return _scene_check
var scene_root: Node = _scene_check.scene_root
## Parse comma-separated paths, deduplicate
var raw_paths := view_target.split(",")
var seen := {}
var unique_paths: Array[String] = []
for rp in raw_paths:
var p := rp.strip_edges()
if not p.is_empty() and not seen.has(p):
seen[p] = true
unique_paths.append(p)
## Resolve each path, collect valid Node3D targets
var targets: Array[Node3D] = []
var not_found: Array[String] = []
for p in unique_paths:
var node := McpScenePath.resolve(p, scene_root)
if node == null:
not_found.append(p)
elif not node is Node3D:
not_found.append(p)
else:
targets.append(node as Node3D)
if targets.is_empty():
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "No valid Node3D targets found: %s" % ", ".join(not_found))
var cam := viewport.get_camera_3d()
if cam == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No camera in 3D viewport")
## Merge AABBs from all targets
var combined_aabb := _get_visual_aabb(targets[0])
for i in range(1, targets.size()):
combined_aabb = combined_aabb.merge(_get_visual_aabb(targets[i]))
var cam_rid := cam.get_camera_rid()
var saved_xform := cam.global_transform
var saved_fov := cam.fov
var saved_near := cam.near
var saved_far := cam.far
## --- Coverage path: multi-angle sweep ---
if coverage:
var images: Array[Dictionary] = []
for preset in _compute_coverage_angles(combined_aabb):
if preset.get("ortho", false):
## Orthographic top-down view
var ortho_size := combined_aabb.size.length() * 1.8
var cam_height := maxf(combined_aabb.size.length() * 3.0, 10.0)
var center := combined_aabb.get_center()
var xform := Transform3D(Basis.IDENTITY, center + Vector3.UP * cam_height)
xform = xform.looking_at(center, Vector3.FORWARD)
RenderingServer.camera_set_orthogonal(cam_rid, ortho_size, saved_near, maxf(saved_far, cam_height * 2.0))
RenderingServer.camera_set_transform(cam_rid, xform)
else:
## Perspective view — padding per preset (wide for establishing, tight for detail)
var pad: float = preset.get("padding", 2.5)
var xform := _frame_transform_for_aabb(combined_aabb, preset.fov, preset.elevation, preset.azimuth, pad)
RenderingServer.camera_set_perspective(cam_rid, preset.fov, saved_near, saved_far)
RenderingServer.camera_set_transform(cam_rid, xform)
RenderingServer.force_draw(false)
var img: Image = viewport.get_texture().get_image()
if img != null and not img.is_empty():
var entry := _finalize_image(img, "viewport", max_resolution)
entry.data["label"] = preset.label
entry.data["elevation"] = preset.elevation
entry.data["azimuth"] = preset.azimuth
entry.data["fov"] = preset.fov
entry.data["ortho"] = preset.get("ortho", false)
images.append(entry.data)
## Restore camera state (back to perspective + original transform)
RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far)
RenderingServer.camera_set_transform(cam_rid, saved_xform)
## Consistent with single-shot path: error if no frames rendered
## (e.g. headless mode where force_draw produces no output).
if images.is_empty():
return _empty_image_error(
"viewport",
"Coverage sweep rendered no images. The 3D viewport produced no output across any of the preset angles — typically because the editor is in headless mode (force_draw has no rendered output) or the 3D viewport has not drawn a frame yet."
)
var aabb_center := combined_aabb.get_center()
var aabb_size := combined_aabb.size
var result_data := {
"source": "viewport",
"view_target": view_target,
"view_target_count": targets.size(),
"coverage": true,
"images": images,
"aabb_center": [aabb_center.x, aabb_center.y, aabb_center.z],
"aabb_size": [aabb_size.x, aabb_size.y, aabb_size.z],
"aabb_longest_ground_axis": "x" if aabb_size.x >= aabb_size.z else "z",
}
if not not_found.is_empty():
result_data["view_target_not_found"] = not_found
return {"data": result_data}
## --- Custom angle / FOV path ---
var use_elev: float = 25.0 if custom_elevation == null else float(custom_elevation)
var use_azim: float = 30.0 if custom_azimuth == null else float(custom_azimuth)
var use_fov: float = saved_fov if custom_fov == null else float(custom_fov)
var cam_xform := _frame_transform_for_aabb(combined_aabb, use_fov, use_elev, use_azim)
if custom_fov != null:
RenderingServer.camera_set_perspective(cam_rid, use_fov, saved_near, saved_far)
RenderingServer.camera_set_transform(cam_rid, cam_xform)
RenderingServer.force_draw(false)
var image: Image = viewport.get_texture().get_image()
## Restore camera state
if custom_fov != null:
RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far)
RenderingServer.camera_set_transform(cam_rid, saved_xform)
if image == null or image.is_empty():
return _empty_image_error(
"viewport",
"Framed viewport rendered an empty image after repositioning the camera onto the view_target. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet."
)
var result := _finalize_image(image, "viewport", max_resolution)
result.data["view_target"] = view_target
result.data["view_target_count"] = targets.size()
var aabb_c := combined_aabb.get_center()
var aabb_s := combined_aabb.size
result.data["aabb_center"] = [aabb_c.x, aabb_c.y, aabb_c.z]
result.data["aabb_size"] = [aabb_s.x, aabb_s.y, aabb_s.z]
result.data["aabb_longest_ground_axis"] = "x" if aabb_s.x >= aabb_s.z else "z"
if custom_elevation != null or custom_azimuth != null:
result.data["elevation"] = use_elev
result.data["azimuth"] = use_azim
if custom_fov != null:
result.data["fov"] = use_fov
if not not_found.is_empty():
result.data["view_target_not_found"] = not_found
return result
var image: Image = viewport.get_texture().get_image()
if image == null or image.is_empty():
return _empty_image_error(
source,
"Captured an empty image from %s. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet." % source
)
return _finalize_image(image, source, max_resolution)
## Render the edited scene through its active Camera3D without running the
## game. Mirrors Godot's "Cinematic Preview" display mode but via a
## throwaway SubViewport, so the output has no editor gizmos, selection
## outlines, or grid lines.
func _take_cinematic_screenshot(max_resolution: int) -> 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 scene_camera := _find_current_camera_3d(scene_root)
if scene_camera == null:
return ErrorCodes.make(
ErrorCodes.NODE_NOT_FOUND,
"No current Camera3D in scene — mark a Camera3D as `current` or add one to the scene",
)
## Default to a 16:9 HD capture; size is overridden by _finalize_image's
## `max_resolution` downscale step when requested.
var render_size := Vector2i(1920, 1080)
var edit_vp := EditorInterface.get_editor_viewport_3d()
if edit_vp != null:
var vs := edit_vp.get_visible_rect().size
if vs.x >= 1.0 and vs.y >= 1.0:
render_size = Vector2i(int(vs.x), int(vs.y))
var sub_vp := SubViewport.new()
sub_vp.size = render_size
sub_vp.own_world_3d = false
sub_vp.transparent_bg = false
sub_vp.render_target_update_mode = SubViewport.UPDATE_ONCE
var cam := Camera3D.new()
cam.fov = scene_camera.fov
cam.near = scene_camera.near
cam.far = scene_camera.far
cam.projection = scene_camera.projection
cam.size = scene_camera.size
cam.keep_aspect = scene_camera.keep_aspect
cam.cull_mask = scene_camera.cull_mask
cam.environment = scene_camera.environment
cam.attributes = scene_camera.attributes
cam.current = true
sub_vp.add_child(cam)
scene_root.add_child(sub_vp)
## global_transform is resolved against the ancestor Node3D chain, so it
## must be set after parenting — otherwise the camera ends up at origin.
cam.global_transform = scene_camera.global_transform
RenderingServer.force_draw(false)
var image: Image = sub_vp.get_texture().get_image()
scene_root.remove_child(sub_vp)
sub_vp.queue_free()
if image == null or image.is_empty():
return _empty_image_error(
"cinematic",
"Cinematic render produced an empty image. The SubViewport returned no texture — typically headless mode (force_draw has no rendered output) or the scene's Camera3D is positioned so nothing visible is in frame."
)
var result := _finalize_image(image, "cinematic", max_resolution)
result.data["camera_path"] = McpScenePath.from_node(scene_camera, scene_root)
return result
## Reject a `source="viewport"` screenshot before we ever pull the
## texture if the edited scene has no Node3D content. The 3D viewport
## returns an empty (or stale) image in that case; surfacing it as
## INTERNAL_ERROR ("Failed to capture image from viewport") gave LLM
## callers no signal that the right move is to switch source or open a
## 3D scene. 152 hits / 63 uuids in 24h across plugin versions 2.5.0 ->
## 2.5.6 traced back to this. Returns `{}` on success.
##
## Caller passes `EditorInterface.get_edited_scene_root()`; the static
## form lets tests exercise the branches with a synthetic scene root
## without driving the editor.
static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
if scene_root == null:
return _make_viewport_not_3d_error(
"",
"The editor 3D viewport is empty because no scene is open. Open a scene with `scene_open` first."
)
## A scene with any Node3D content — root or descendant — has
## something the 3D viewport can render. Walking the tree (rather
## than only checking the root type) avoids a false reject on the
## common `Node` / `Node2D` root + Node3D descendant pattern.
if _scene_has_node3d_content(scene_root):
return {}
var root_type := scene_root.get_class()
var hint: String
if scene_root is Node2D or scene_root is Control:
hint = (
"The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. "
+ "Options: (a) open a 3D scene, "
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
) % root_type
else:
hint = (
"The 3D viewport is empty because the current scene (%s root) has no Node3D content anywhere in the tree. "
+ "Options: (a) open or add a Node3D, "
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
) % root_type
return _make_viewport_not_3d_error(root_type, hint)
## True if scene_root is itself a Node3D or owns any Node3D descendant.
## DFS short-circuits on the first hit so empty 2D scenes stay cheap.
static func _scene_has_node3d_content(scene_root: Node) -> bool:
if scene_root is Node3D:
return true
var stack: Array[Node] = [scene_root]
while not stack.is_empty():
var node: Node = stack.pop_back()
for child in node.get_children():
if child is Node3D:
return true
stack.append(child)
return false
static func _make_viewport_not_3d_error(scene_root_type: String, hint: String) -> Dictionary:
## `hint` becomes `error.message`; not duplicated into `data` because
## `GodotCommandError`'s string form already appends every `data` key
## as a suffix on the agent-visible error.
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, hint)
err["error"]["data"] = {
"editor_state": "viewport_not_3d",
"scene_root_type": scene_root_type,
}
return err
## Reached only when the precheck passed but the texture still came
## back empty — headless rendering, a freshly opened editor whose 3D
## viewport hasn't drawn a frame, or a SubViewport that lost its target.
static func _empty_image_error(source: String, hint: String) -> Dictionary:
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, hint)
err["error"]["data"] = {
"editor_state": "viewport_empty",
"source": source,
}
return err
## Return the Camera3D that would be active if the scene were running.
## Preference: a descendant with `current=true`, else the first Camera3D
## found in a depth-first walk.
func _find_current_camera_3d(root: Node) -> Camera3D:
var first: Camera3D = null
var stack: Array[Node] = [root]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is Camera3D:
if node.current:
return node
if first == null:
first = node
for child in node.get_children():
stack.append(child)
return first
func _finalize_image(image: Image, source: String, max_resolution: int) -> Dictionary:
var original_width := image.get_width()
var original_height := image.get_height()
if max_resolution > 0:
var longest := maxi(original_width, original_height)
if longest > max_resolution:
var scale := float(max_resolution) / float(longest)
## Clamp to 1px min: extreme aspect ratios at very small max_resolution
## could otherwise compute a zero dimension and crash image.resize().
var new_w := maxi(1, int(original_width * scale))
var new_h := maxi(1, int(original_height * scale))
image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
var img_bytes := image.save_png_to_buffer()
var base64_str := Marshalls.raw_to_base64(img_bytes)
return {
"data": {
"source": source,
"width": image.get_width(),
"height": image.get_height(),
"original_width": original_width,
"original_height": original_height,
"format": "png",
"image_base64": base64_str,
}
}
## Recursively compute the visual bounding box of a Node3D and its children.
func _get_visual_aabb(node: Node3D) -> AABB:
var aabb := AABB()
var found := false
if node is VisualInstance3D:
aabb = node.global_transform * node.get_aabb()
found = true
for child in node.get_children():
if child is Node3D:
var child_aabb := _get_visual_aabb(child)
if child_aabb.size != Vector3.ZERO:
if found:
aabb = aabb.merge(child_aabb)
else:
aabb = child_aabb
found = true
if not found:
aabb = AABB(node.global_position - Vector3(0.5, 0.5, 0.5), Vector3(1, 1, 1))
return aabb
## Calculate a camera Transform3D that frames the given AABB nicely.
## elevation_deg: camera elevation (0 = level, 90 = directly above). Default 25.
## azimuth_deg: camera azimuth (0 = front, 90 = right side). Default 30.
## padding: distance multiplier for breathing room (1.2 = tight, 2.5 = context). Default 1.8.
func _frame_transform_for_aabb(aabb: AABB, fov_degrees: float = 75.0, elevation_deg: float = 25.0, azimuth_deg: float = 30.0, padding: float = 1.8) -> Transform3D:
var center := aabb.get_center()
var radius := aabb.size.length() * 0.5
var fov_rad := deg_to_rad(fov_degrees)
var distance := radius / tan(fov_rad * 0.5) * padding
## Floor with an absolute offset so unit-scale AABBs don't place the camera
## inside or against the target. `radius * 2.0` alone scales to zero as the
## AABB shrinks; the +1.0 guarantees a minimum of ~1 world-unit of standoff.
distance = maxf(distance, radius * 2.0 + 1.0)
var elev := deg_to_rad(elevation_deg)
var azim := deg_to_rad(azimuth_deg)
var cam_pos := center + Vector3(
distance * cos(elev) * sin(azim),
distance * sin(elev),
distance * cos(elev) * cos(azim),
)
var xform := Transform3D(Basis.IDENTITY, cam_pos)
## At ~90° elevation the view direction is parallel to Vector3.UP — use
## FORWARD as the up hint so looking_at doesn't degenerate.
var up := Vector3.FORWARD if elevation_deg > 85.0 else Vector3.UP
return xform.looking_at(center, up)
func get_performance_monitors(params: Dictionary) -> Dictionary:
var filter: Array = params.get("monitors", [])
var result := {}
if filter.is_empty():
for key in MONITORS:
result[key] = Performance.get_monitor(MONITORS[key])
else:
for key in filter:
if MONITORS.has(key):
result[key] = Performance.get_monitor(MONITORS[key])
return {
"data": {
"monitors": result,
"monitor_count": result.size(),
}
}
func clear_logs(params: Dictionary) -> Dictionary:
var count := _log_buffer.total_count()
_log_buffer.clear()
var data := {"cleared_count": count}
## The Debugger Errors panel is user-visible editor UI, not an MCP-owned
## buffer — wiping it stays behind an explicit opt-in.
if bool(params.get("clear_debugger_errors", false)):
data["debugger_errors_cleared"] = _clear_debugger_error_trees()
return {"data": data}
func _clear_debugger_error_trees() -> int:
var cleared := 0
for tree in _locate_debugger_error_trees():
cleared += _entries_from_debugger_error_tree(tree).size()
if not _press_debugger_clear_button(tree):
## No Clear button near this tree (synthetic roots in tests).
## A raw clear is acceptable there; the real panel always routes
## through the button below.
tree.clear()
return cleared
## Clear via ScriptEditorDebugger's own Clear button so the engine runs
## _clear_errors_list() — clearing the Tree directly leaves error_count/
## warning_count, the "Errors (N)" tab badge, the errors_cleared signal, and
## the toolbar button states out of sync with the emptied tree. The button is
## identified by its pressed-connection target, not its (translated) label.
static func _press_debugger_clear_button(tree: Tree) -> bool:
var parent := tree.get_parent()
if parent == null:
return false
var stack: Array[Node] = [parent]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is BaseButton:
for conn in node.get_signal_connection_list("pressed"):
if str(conn.get("callable", "")).contains("_clear_errors_list"):
node.emit_signal("pressed")
return true
for child in node.get_children():
stack.push_back(child)
return false
func reload_plugin(_params: Dictionary) -> Dictionary:
_log_buffer.log("reload_plugin requested, reloading next frame")
## Persist a pending plugin_reload telemetry event *before* the
## disable kills the live WebSocket. The re-enabled plugin's
## _enter_tree flushes via `_telemetry.flush_pending_plugin_reload()`.
Telemetry.record_pending_plugin_reload("mcp_tool")
_do_reload_plugin.call_deferred()
return {"data": {"status": "reloading", "message": "Plugin reload initiated"}}
## Force a filesystem rescan before toggling the plugin, so Godot's
## class-name registry picks up any .gd files added since the last scan
## (e.g. via git pull or an agent-driven sync). Without this, re-enable can
## fail with "Could not find type X" when new class_name scripts are on disk
## but not yet registered, leaving the plugin disabled with no recovery path
## short of killing the editor. See issue #83.
func _do_reload_plugin() -> void:
var fs := EditorInterface.get_resource_filesystem()
fs.scan()
var tree := Engine.get_main_loop() as SceneTree
# Cap the wait so a long scan (huge project) doesn't hang reload.
var deadline_ms := Time.get_ticks_msec() + 5000
while fs.is_scanning() and Time.get_ticks_msec() < deadline_ms:
await tree.process_frame
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false)
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true)
func quit_editor(_params: Dictionary) -> Dictionary:
_log_buffer.log("quit_editor requested, quitting next frame")
## Defer the quit so the response is sent back before the editor exits.
EditorInterface.get_base_control().get_tree().call_deferred("quit")
return {"data": {"status": "quitting", "message": "Editor quit initiated"}}
func game_eval(params: Dictionary) -> Dictionary:
var code: String = params.get("code", "")
if code.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "code is required")
if _debugger_plugin == null or _connection == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Debugger bridge unavailable — plugin may not be fully initialised")
if not EditorInterface.is_playing_scene():
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY,
"Game is not running — start the project first")
var request_id: String = params.get("_request_id", "")
if request_id.is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Missing request_id — cannot correlate deferred response")
_debugger_plugin.request_game_eval(code, request_id, _connection)
return McpDispatcher.DEFERRED_RESPONSE
func game_command(params: Dictionary) -> Dictionary:
var op: String = str(params.get("op", ""))
if op.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "op is required")
if _debugger_plugin == null or _connection == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Debugger bridge unavailable — plugin may not be fully initialised")
if not EditorInterface.is_playing_scene():
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY,
"Game is not running — start the project first")
var request_id: String = params.get("_request_id", "")
if request_id.is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Missing request_id — cannot correlate deferred response")
var command_params: Dictionary = params.get("params", {})
_debugger_plugin.request_game_command(op, command_params, request_id, _connection)
return McpDispatcher.DEFERRED_RESPONSE
@@ -0,0 +1 @@
uid://dcro7yc8bor6v
@@ -0,0 +1,181 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Creates an Environment (+ optional Sky + ProceduralSkyMaterial) chain and
## either assigns it to a WorldEnvironment node or saves it to a .tres file.
## Bundles sub-resource creation + assignment in a single undo action.
const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
const _PRESETS := {
"default": {"sky": true, "fog": false},
"clear": {"sky": true, "fog": false},
"sunset": {"sky": true, "fog": false},
"night": {"sky": true, "fog": false},
"fog": {"sky": true, "fog": true},
}
func create_environment(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var resource_path: String = params.get("resource_path", "")
var overwrite: bool = params.get("overwrite", false)
var preset: String = params.get("preset", "default")
var properties: Dictionary = params.get("properties", {})
var sky_param = params.get("sky", null) # nullable — falls back to preset default
# environment_create targets the whole WorldEnvironment node (no separate
# `property` param) — pass require_property=false.
var home_err := McpResourceIO.validate_home(params, false)
if home_err != null:
return home_err
if not _PRESETS.has(preset):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid preset '%s'. Valid: %s" % [preset, ", ".join(_PRESETS.keys())]
)
var preset_config: Dictionary = _PRESETS[preset]
var want_sky: bool = preset_config.sky
var sky_properties: Dictionary = {}
if sky_param != null:
if sky_param is bool:
want_sky = sky_param
elif sky_param is Dictionary:
var sky_config: Dictionary = (sky_param as Dictionary).duplicate()
var material_type: String = String(sky_config.get("sky_material", "procedural")).to_lower()
if material_type != "procedural":
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"sky.sky_material must be 'procedural' when sky is a dictionary"
)
sky_config.erase("sky_material")
sky_properties = sky_config
want_sky = true
else:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"sky must be a bool, null, or dictionary of ProceduralSkyMaterial properties"
)
var env := Environment.new()
var sky: Sky = null
var sky_material: ProceduralSkyMaterial = null
if want_sky:
sky_material = ProceduralSkyMaterial.new()
sky = Sky.new()
sky.sky_material = sky_material
env.background_mode = Environment.BG_SKY
env.sky = sky
else:
env.background_mode = Environment.BG_CLEAR_COLOR
_apply_preset(env, sky_material, preset)
if not sky_properties.is_empty():
var sky_apply_err := ResourceHandler._apply_resource_properties(sky_material, sky_properties)
if sky_apply_err != null:
return sky_apply_err
if preset_config.fog:
env.volumetric_fog_enabled = true
env.volumetric_fog_density = 0.03
if not properties.is_empty():
var apply_err := ResourceHandler._apply_resource_properties(env, properties)
if apply_err != null:
return apply_err
if not resource_path.is_empty():
return _save_environment(env, sky, sky_material, resource_path, overwrite, preset)
return _assign_environment(env, sky, sky_material, node_path, preset)
static func _apply_preset(env: Environment, sky_material: ProceduralSkyMaterial, preset: String) -> void:
match preset:
"default", "clear":
if sky_material != null:
sky_material.sky_top_color = Color(0.38, 0.45, 0.55)
sky_material.sky_horizon_color = Color(0.65, 0.67, 0.7)
sky_material.ground_horizon_color = Color(0.65, 0.67, 0.7)
sky_material.ground_bottom_color = Color(0.2, 0.17, 0.13)
sky_material.sun_angle_max = 30.0
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_energy = 1.0
"sunset":
if sky_material != null:
sky_material.sky_top_color = Color(0.25, 0.3, 0.55)
sky_material.sky_horizon_color = Color(1.0, 0.55, 0.3)
sky_material.ground_horizon_color = Color(0.85, 0.4, 0.25)
sky_material.ground_bottom_color = Color(0.2, 0.12, 0.1)
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_color = Color(1.0, 0.75, 0.55)
env.ambient_light_energy = 0.8
"night":
if sky_material != null:
sky_material.sky_top_color = Color(0.02, 0.02, 0.07)
sky_material.sky_horizon_color = Color(0.05, 0.07, 0.15)
sky_material.ground_horizon_color = Color(0.04, 0.05, 0.1)
sky_material.ground_bottom_color = Color(0.0, 0.0, 0.02)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.2, 0.22, 0.35)
env.ambient_light_energy = 0.4
"fog":
if sky_material != null:
sky_material.sky_top_color = Color(0.65, 0.65, 0.7)
sky_material.sky_horizon_color = Color(0.8, 0.8, 0.82)
sky_material.ground_horizon_color = Color(0.7, 0.7, 0.72)
sky_material.ground_bottom_color = Color(0.3, 0.3, 0.32)
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_energy = 0.7
func _assign_environment(env: Environment, sky: Sky, sky_material: ProceduralSkyMaterial, node_path: String, preset: String) -> Dictionary:
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
if not (node is WorldEnvironment):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is %s — must be WorldEnvironment" % [node_path, node.get_class()]
)
var old_env = (node as WorldEnvironment).environment
_undo_redo.create_action("MCP: Create Environment (%s) for %s" % [preset, node.name])
_undo_redo.add_do_property(node, "environment", env)
_undo_redo.add_undo_property(node, "environment", old_env)
_undo_redo.add_do_reference(env)
if sky != null:
_undo_redo.add_do_reference(sky)
if sky_material != null:
_undo_redo.add_do_reference(sky_material)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"preset": preset,
"sky_created": sky != null,
"sky_material_class": sky_material.get_class() if sky_material != null else "",
"undoable": true,
}
}
func _save_environment(env: Environment, _sky: Sky, _sky_material: ProceduralSkyMaterial, resource_path: String, overwrite: bool, preset: String) -> Dictionary:
return McpResourceIO.save_to_disk(env, resource_path, overwrite, "Environment", {
"preset": preset,
}, _connection)
@@ -0,0 +1 @@
uid://b1k7jldwjp5jt
@@ -0,0 +1,112 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles file read/write operations and reimport within the Godot project.
func read_file(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
var content := file.get_as_text()
file.close()
return {
"data": {
"path": path,
"content": content,
"size": content.length(),
"line_count": content.count("\n") + (1 if not content.is_empty() else 0),
}
}
func write_file(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var content: String = params.get("content", "")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
# Ensure parent directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var existed_before := FileAccess.file_exists(path)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
file.store_string(content)
file.close()
# Single-file register, not a full scan() — a scan() per write stacks
# filesystem WorkerThreadPool tasks under concurrent writes and can SIGABRT
# in the global-class update (see dsarno/godot#6 and create_script in
# script_handler.gd). update_file() is what reimport()/material/theme use.
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
var data := {
"path": path,
"size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
McpResourceIO.attach_cleanup_hint(data, existed_before, [path])
return {"data": data}
func reimport(params: Dictionary) -> Dictionary:
var paths: Array = params.get("paths", [])
if paths.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths (non-empty array)")
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var reimported: Array[String] = []
var not_found: Array[String] = []
for path_variant in paths:
var path: String = str(path_variant)
var path_err := McpPathValidator.validate_resource_path(path)
if not path_err.is_empty():
not_found.append("%s (%s)" % [path, path_err])
continue
if not FileAccess.file_exists(path):
not_found.append("%s (file does not exist)" % path)
continue
efs.update_file(path)
reimported.append(path)
return {
"data": {
"reimported": reimported,
"not_found": not_found,
"reimported_count": reimported.size(),
"not_found_count": not_found.size(),
"undoable": false,
"reason": "Reimport is a file system operation",
}
}
@@ -0,0 +1 @@
uid://c7ovtpdiumtju
+278
View File
@@ -0,0 +1,278 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles input action listing, creation, removal, and event binding.
## Actions are persisted via ProjectSettings so they survive editor restarts.
func list_actions(params: Dictionary) -> Dictionary:
var include_builtin: bool = params.get("include_builtin", false)
## Authoritative source for user-authored actions is the ``[input]``
## section of ``project.godot``. ``ProjectSettings.has_setting`` is not
## reliable here because Godot registers ``ui_*`` defaults via
## ``GLOBAL_DEF_BASIC``, which makes ``has_setting`` return true for
## them. Reading the file via ``ConfigFile`` distinguishes the user's
## entries from engine-registered defaults regardless of namespace.
## See #213.
var user_authored := _read_user_authored_actions()
var actions: Array[Dictionary] = []
for action_name in InputMap.get_actions():
var name_str := str(action_name)
var is_user_action := user_authored.has(name_str)
if not include_builtin and not is_user_action:
continue
var events: Array[Dictionary] = []
for event in InputMap.action_get_events(action_name):
events.append(_serialize_event(event))
actions.append({
"name": name_str,
"events": events,
"event_count": events.size(),
"is_builtin": not is_user_action,
})
return {"data": {"actions": actions, "count": actions.size()}}
func _read_user_authored_actions() -> Dictionary:
var cfg := ConfigFile.new()
if cfg.load("res://project.godot") != OK:
return {}
if not cfg.has_section("input"):
return {}
var result: Dictionary = {}
for key in cfg.get_section_keys("input"):
result[key] = true
return result
func add_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
if InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action)
InputMap.add_action(action, deadzone)
var key := "input/%s" % action
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": [],
})
var err := ProjectSettings.save()
if err != OK:
InputMap.erase_action(action)
ProjectSettings.clear(key)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while adding action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"deadzone": deadzone,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
}
func remove_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if not InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
var key := "input/%s" % action
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
InputMap.erase_action(action)
if old_setting != null:
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
ProjectSettings.set_setting(key, old_setting)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"removed": true,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
}
func bind_event(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var event_type: String = params.get("event_type", "")
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if event_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
if not InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Action '%s' not found. Call input_map_manage(op='add_action', params={action: '%s'}) first." % [action, action])
var event_or_error = _create_event(event_type, params)
if event_or_error is Dictionary:
return event_or_error
var event: InputEvent = event_or_error
InputMap.action_add_event(action, event)
var err := _save_action_events(action)
if err != OK:
InputMap.action_erase_event(action, event)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"event": _serialize_event(event),
"undoable": false,
"reason": "Input bindings are saved to project.godot",
}
}
## Returns an InputEvent on success, or a Dictionary error on failure.
## Caller must check ``result is Dictionary`` before treating it as an event.
func _create_event(event_type: String, params: Dictionary):
match event_type:
"key":
var ev := InputEventKey.new()
var keycode_str: String = params.get("keycode", "")
if keycode_str.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='key' requires keycode (e.g. 'Space', 'A', 'Enter', 'Escape', 'F1').")
ev.keycode = OS.find_keycode_from_string(keycode_str)
if ev.keycode == KEY_NONE:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid keycode '%s'. Use Godot keycode names like 'A', 'Space', 'Enter', 'Escape', 'F1', 'Left', 'Right'." % keycode_str)
ev.ctrl_pressed = params.get("ctrl", false)
ev.alt_pressed = params.get("alt", false)
ev.shift_pressed = params.get("shift", false)
ev.meta_pressed = params.get("meta", false)
return ev
"mouse_button":
if not params.has("button"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='mouse_button' requires button (1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down).")
var button: int = int(params.get("button", 0))
if button <= 0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button)
var ev := InputEventMouseButton.new()
ev.button_index = button
return ev
"joy_button":
if not params.has("button"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='joy_button' requires button (JoyButton index, e.g. 0=A/Cross, 1=B/Circle).")
var ev := InputEventJoypadButton.new()
ev.button_index = int(params.get("button", 0))
return ev
"joy_axis":
var axis_param = params.get("axis", null)
if axis_param == null:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
"event_type='joy_axis' requires axis (JoyAxis index, e.g. 0=left stick X, 1=left stick Y).")
var axis: int
match typeof(axis_param):
TYPE_INT:
axis = axis_param
TYPE_FLOAT:
if axis_param != floor(axis_param):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"joy_axis axis must be an integer JoyAxis index (got %s)." % str(axis_param))
axis = int(axis_param)
TYPE_STRING:
var axis_text := str(axis_param)
if not axis_text.is_valid_int():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"joy_axis axis must be an integer JoyAxis index (got '%s')." % axis_text)
axis = int(axis_text)
_:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"joy_axis axis must be an integer JoyAxis index (got %s)." % type_string(typeof(axis_param)))
var ev := InputEventJoypadMotion.new()
ev.axis = axis
ev.axis_value = float(params.get("axis_value", 1.0))
return ev
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Unsupported event_type: '%s'. Use 'key', 'mouse_button', 'joy_button', or 'joy_axis'." % event_type)
func _serialize_event(event: InputEvent) -> Dictionary:
if event is InputEventKey:
return {
"type": "key",
"keycode": OS.get_keycode_string(event.keycode),
"physical_keycode": OS.get_keycode_string(event.physical_keycode),
"ctrl": event.ctrl_pressed,
"alt": event.alt_pressed,
"shift": event.shift_pressed,
"meta": event.meta_pressed,
}
if event is InputEventMouseButton:
return {
"type": "mouse_button",
"button": event.button_index,
}
if event is InputEventJoypadButton:
return {
"type": "joy_button",
"button": event.button_index,
}
if event is InputEventJoypadMotion:
return {
"type": "joy_axis",
"axis": event.axis,
"axis_value": event.axis_value,
}
return {"type": event.get_class(), "string": str(event)}
func _save_action_events(action: String) -> int:
var events: Array = []
for event in InputMap.action_get_events(action):
events.append(event)
var key := "input/%s" % action
var had_setting := ProjectSettings.has_setting(key)
var old_setting = ProjectSettings.get_setting(key) if had_setting else null
var deadzone: float = 0.5
if old_setting is Dictionary:
deadzone = old_setting.get("deadzone", 0.5)
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": events,
})
var err := ProjectSettings.save()
if err != OK:
if had_setting:
ProjectSettings.set_setting(key, old_setting)
else:
ProjectSettings.clear(key)
return err
@@ -0,0 +1 @@
uid://buk68rbwssqwp
@@ -0,0 +1,788 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles Material authoring: creating .tres files, setting BaseMaterial3D
## properties / shader uniforms, assigning to nodes, high-level presets.
##
## File-resource lifecycle mirrors ThemeHandler (create/load/mutate/save).
## Undo pattern mirrors AnimationHandler (single create_action bundles
## every dependency spawn).
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
const MaterialPresets := preload("res://addons/godot_ai/handlers/material_presets.gd")
const _TYPE_TO_CLASS := {
"standard": "StandardMaterial3D",
"orm": "ORMMaterial3D",
"canvas_item": "CanvasItemMaterial",
"shader": "ShaderMaterial",
}
const _SUPPORTED_SUFFIXES := [".tres", ".material", ".res"]
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# material_create
# ============================================================================
func create_material(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var type_str: String = params.get("type", "standard")
var shader_path: String = params.get("shader_path", "")
var overwrite: bool = params.get("overwrite", false)
var err := _validate_material_path(path, "path", true)
if err != null:
return err
if not _TYPE_TO_CLASS.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
)
var existed_before := FileAccess.file_exists(path)
if existed_before and not overwrite:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Material already exists at %s (pass overwrite=true to replace)" % path
)
var mat := _instantiate_material(type_str)
if mat == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate material")
if type_str == "shader":
if shader_path.is_empty():
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"ShaderMaterial requires shader_path (res:// / uid:// / user:// path to a .gdshader)"
)
var shader_path_err = McpPathValidator.loadable_error(shader_path, "shader_path")
if shader_path_err != null:
return shader_path_err
if not ResourceLoader.exists(shader_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % shader_path)
var shader_res := ResourceLoader.load(shader_path)
if not (shader_res is Shader):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % shader_path)
(mat as ShaderMaterial).shader = shader_res
var dir_path := path.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
)
var save_err := ResourceSaver.save(mat, path)
if save_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to save material to %s (error %d)" % [path, save_err]
)
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {
"data": {
"path": path,
"type": type_str,
"class": mat.get_class(),
"shader_path": shader_path,
"overwritten": existed_before,
"undoable": false,
"reason": "File creation is persistent; delete the file manually to revert",
}
}
# ============================================================================
# material_set_param
# ============================================================================
func set_param(params: Dictionary) -> Dictionary:
var load_result := _load_material_from_path(params.get("path", ""), true)
if load_result.has("error"):
return load_result
var mat: Material = load_result.material
var mat_path: String = load_result.path
var property: String = params.get("param", "")
if property.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
if not ("value" in params):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var raw_value = params.get("value")
# Probe the property. We allow any property present in get_property_list,
# plus `shader` on ShaderMaterial.
var prop_type: int = TYPE_NIL
var property_exists := false
for prop in mat.get_property_list():
if prop.name == property:
property_exists = true
prop_type = prop.get("type", TYPE_NIL)
break
if not property_exists:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(mat, property)
)
var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
if not coerced.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
var new_value = coerced.value
var old_value = mat.get(property)
_undo_redo.create_action("MCP: Set material %s.%s" % [mat_path.get_file(), property])
_undo_redo.add_do_method(self, "_apply_param", mat_path, property, new_value, false)
_undo_redo.add_undo_method(self, "_apply_param", mat_path, property, old_value, false)
_undo_redo.commit_action()
return {
"data": {
"path": mat_path,
"property": property,
"value": MaterialValues.serialize_value(new_value),
"previous_value": MaterialValues.serialize_value(old_value),
"undoable": true,
}
}
# ============================================================================
# material_set_shader_param
# ============================================================================
func set_shader_param(params: Dictionary) -> Dictionary:
var load_result := _load_material_from_path(params.get("path", ""), true)
if load_result.has("error"):
return load_result
var mat: Material = load_result.material
var mat_path: String = load_result.path
if not (mat is ShaderMaterial):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Material at %s is %s, not ShaderMaterial" % [mat_path, mat.get_class()]
)
var shader_mat := mat as ShaderMaterial
if shader_mat.shader == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"ShaderMaterial at %s has no shader assigned" % mat_path
)
var param_name: String = params.get("param", "")
if param_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
if not ("value" in params):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
# Verify the uniform exists in the shader.
var uniform_type := _shader_uniform_type(shader_mat.shader, param_name)
if uniform_type == TYPE_NIL:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Shader uniform '%s' not declared on shader at %s" % [param_name, shader_mat.shader.resource_path]
)
var raw_value = params.get("value")
var coerced := MaterialValues.coerce_material_value(param_name, raw_value, uniform_type)
if not coerced.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
var new_value = coerced.value
var old_value = shader_mat.get_shader_parameter(param_name)
_undo_redo.create_action("MCP: Set shader param %s.%s" % [mat_path.get_file(), param_name])
_undo_redo.add_do_method(self, "_apply_shader_param", mat_path, param_name, new_value)
_undo_redo.add_undo_method(self, "_apply_shader_param", mat_path, param_name, old_value)
_undo_redo.commit_action()
return {
"data": {
"path": mat_path,
"param": param_name,
"value": MaterialValues.serialize_value(new_value),
"previous_value": MaterialValues.serialize_value(old_value),
"undoable": true,
}
}
# ============================================================================
# material_get
# ============================================================================
func get_material(params: Dictionary) -> Dictionary:
var load_result := _load_material_from_path(params.get("path", ""))
if load_result.has("error"):
return load_result
var mat: Material = load_result.material
var mat_path: String = load_result.path
var properties: Array[Dictionary] = []
for prop in mat.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var name: String = prop.name
if name.begins_with("shader_parameter/"):
continue # handled below
var value = mat.get(name)
if value == null and prop.type != TYPE_NIL:
continue
properties.append({
"name": name,
"type": type_string(prop.type),
"value": MaterialValues.serialize_value(value),
})
var shader_params: Array[Dictionary] = []
if mat is ShaderMaterial:
var shader_mat := mat as ShaderMaterial
if shader_mat.shader != null:
for u in shader_mat.shader.get_shader_uniform_list():
var u_name: String = u.get("name", "")
if u_name.is_empty():
continue
shader_params.append({
"name": u_name,
"type": type_string(u.get("type", TYPE_NIL)),
"value": MaterialValues.serialize_value(shader_mat.get_shader_parameter(u_name)),
})
var reverse_type_map := _reverse_type_map()
var shader_path_str := ""
if mat is ShaderMaterial:
var sm := mat as ShaderMaterial
if sm.shader != null:
shader_path_str = sm.shader.resource_path
return {
"data": {
"path": mat_path,
"class": mat.get_class(),
"type": reverse_type_map.get(mat.get_class(), ""),
"properties": properties,
"property_count": properties.size(),
"shader_parameters": shader_params,
"shader_path": shader_path_str,
}
}
# ============================================================================
# material_list
# ============================================================================
func list_materials(params: Dictionary) -> Dictionary:
var root: String = params.get("root", "res://")
var type_filter: String = params.get("type", "")
var root_err = McpPathValidator.path_error(root, "root")
if root_err != null:
return root_err
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var results: Array[Dictionary] = []
var start_dir := efs.get_filesystem_path(root)
if start_dir == null:
start_dir = efs.get_filesystem()
_scan_materials(start_dir, type_filter, root, results)
return {"data": {"materials": results, "count": results.size()}}
func _scan_materials(dir: EditorFileSystemDirectory, type_filter: String, root: String, out: Array[Dictionary]) -> void:
if dir == null:
return
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
if not file_path.begins_with(root):
continue
var file_type := dir.get_file_type(i)
var is_material := file_type == "Material" or ClassDB.is_parent_class(file_type, "Material")
if not is_material:
# Some material variants serialize as specific classes.
if not (file_type in _TYPE_TO_CLASS.values()):
continue
if not type_filter.is_empty():
if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter):
continue
out.append({"path": file_path, "class": file_type})
for i in dir.get_subdir_count():
_scan_materials(dir.get_subdir(i), type_filter, root, out)
# ============================================================================
# material_assign
# ============================================================================
func assign_material(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var slot: String = params.get("slot", "override")
var resource_path: String = params.get("resource_path", "")
var create_if_missing: bool = params.get("create_if_missing", false)
var type_str: String = params.get("type", "standard")
var slot_result := _resolve_slot_property(node, slot)
if slot_result.has("error"):
return slot_result
var property: String = slot_result.property
# Load or create the material.
var mat: Material = null
var material_created := false
if not resource_path.is_empty():
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
if rpath_err != null:
return rpath_err
if not ResourceLoader.exists(resource_path):
if create_if_missing:
# We'd need to create a new file here — refuse; callers should
# use material_create first or omit resource_path to get an
# inline material.
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"Resource not found: %s. Create it first with material_create or omit resource_path for an inline material." % resource_path
)
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
var loaded := ResourceLoader.load(resource_path)
if not (loaded is Material):
var loaded_class := "null"
if loaded != null:
loaded_class = loaded.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at %s is not a Material (got %s)" % [resource_path, loaded_class]
)
mat = loaded
else:
if not create_if_missing:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Missing resource_path (pass create_if_missing=true to create a new inline material)"
)
if not _TYPE_TO_CLASS.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid material type '%s'" % type_str
)
mat = _instantiate_material(type_str)
material_created = true
var old_value = node.get(property)
_undo_redo.create_action("MCP: Assign material to %s.%s" % [node.name, property])
_undo_redo.add_do_property(node, property, mat)
_undo_redo.add_undo_property(node, property, old_value)
if material_created:
_undo_redo.add_do_reference(mat)
_undo_redo.commit_action()
return {
"data": {
"node_path": node_path,
"property": property,
"slot": slot,
"resource_path": resource_path,
"material_class": mat.get_class(),
"material_created": material_created,
"undoable": true,
}
}
# ============================================================================
# material_apply_to_node
# ============================================================================
func apply_to_node(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
var type_str: String = params.get("type", "standard")
if not _TYPE_TO_CLASS.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
)
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var slot: String = params.get("slot", "override")
var slot_result := _resolve_slot_property(node, slot)
if slot_result.has("error"):
return slot_result
var property: String = slot_result.property
var mat := _instantiate_material(type_str)
var props_to_set: Dictionary = params.get("params", {})
var applied: Array[String] = []
for prop_name in props_to_set:
var apply_err := _apply_one_param_on_instance(mat, String(prop_name), props_to_set[prop_name])
if apply_err != null:
return apply_err
applied.append(String(prop_name))
var save_to: String = params.get("save_to", "")
var saved := false
if not save_to.is_empty():
var save_err_validation := _validate_material_path(save_to, "save_to", true)
if save_err_validation != null:
return save_err_validation
var dir_path := save_to.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var save_err := ResourceSaver.save(mat, save_to)
if save_err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material to %s (error %d)" % [save_to, save_err])
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(save_to)
# Prefer the on-disk reference (keeps the scene ref small), but fall
# back to the in-memory material if the reload fails — otherwise a null
# would clear the slot and crash mat.get_class() below.
var reloaded := ResourceLoader.load(save_to)
if reloaded != null:
mat = reloaded
saved = true
var old_value = node.get(property)
_undo_redo.create_action("MCP: Apply %s material to %s" % [type_str, node.name])
_undo_redo.add_do_property(node, property, mat)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.add_do_reference(mat)
_undo_redo.commit_action()
return {
"data": {
"node_path": node_path,
"property": property,
"slot": slot,
"type": type_str,
"class": mat.get_class(),
"applied_params": applied,
"material_created": true,
"saved_to": save_to if saved else "",
"undoable": true,
}
}
# ============================================================================
# material_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 = MaterialPresets.build(preset_name, overrides)
if blueprint == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(MaterialPresets.list())]
)
var type_str: String = blueprint.get("type", "standard")
var preset_params: Dictionary = blueprint.get("params", {})
var path: String = params.get("path", "")
var node_path: String = params.get("node_path", "")
if path.is_empty() and node_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Pass at least one of: path (save to disk), node_path (assign to node)"
)
# If both path and node_path, save to disk, then assign the saved resource.
# If only path, save to disk.
# If only node_path, inline material via apply_to_node.
if not node_path.is_empty() and path.is_empty():
# Inline
var inline_result := apply_to_node({
"node_path": node_path,
"type": type_str,
"params": preset_params,
"slot": params.get("slot", "override"),
})
if inline_result.has("data"):
inline_result.data["preset"] = preset_name
inline_result.data["assigned"] = true
inline_result.data["path"] = ""
inline_result.data["saved_to_disk"] = false
inline_result.data["reason"] = "Inline material assigned to node"
return inline_result
# Save-to-disk path.
var existed_before := FileAccess.file_exists(path)
if existed_before and not params.get("overwrite", false):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Material already exists at %s (pass overwrite=true to replace)" % path
)
var path_err := _validate_material_path(path, "path", true)
if path_err != null:
return path_err
var mat := _instantiate_material(type_str)
for prop_name in preset_params:
var apply_err := _apply_one_param_on_instance(mat, String(prop_name), preset_params[prop_name])
if apply_err != null:
return apply_err
var dir_path := path.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var save_err := ResourceSaver.save(mat, path)
if save_err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material: %s" % path)
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
var assigned := false
if not node_path.is_empty():
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var slot_result := _resolve_slot_property(node, params.get("slot", "override"))
if slot_result.has("error"):
return slot_result
var property: String = slot_result.property
var saved_mat := ResourceLoader.load(path)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Apply preset %s to %s" % [preset_name, node.name])
_undo_redo.add_do_property(node, property, saved_mat)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.commit_action()
assigned = true
return {
"data": {
"preset": preset_name,
"type": type_str,
"path": path,
"node_path": node_path,
"material_created": true,
"assigned": assigned,
"saved_to_disk": true,
"undoable": assigned, # assign is undoable; save is not
"reason": "" if assigned else "File save is not undoable",
}
}
# ============================================================================
# Undo-callable: applies a param on the loaded resource and saves.
# ============================================================================
func _apply_param(mat_path: String, property: String, value: Variant, _is_shader: bool) -> void:
var mat: Material = ResourceLoader.load(mat_path)
if mat == null:
return
mat.set(property, value)
ResourceSaver.save(mat, mat_path)
func _apply_shader_param(mat_path: String, param_name: String, value: Variant) -> void:
var mat: Material = ResourceLoader.load(mat_path)
if mat == null or not (mat is ShaderMaterial):
return
(mat as ShaderMaterial).set_shader_parameter(param_name, value)
ResourceSaver.save(mat, mat_path)
# ============================================================================
# Helpers
# ============================================================================
static func _instantiate_material(type_str: String) -> Material:
match type_str:
"standard":
return StandardMaterial3D.new()
"orm":
return ORMMaterial3D.new()
"canvas_item":
return CanvasItemMaterial.new()
"shader":
return ShaderMaterial.new()
return null
static func _reverse_type_map() -> Dictionary:
var out := {}
for k in _TYPE_TO_CLASS:
out[_TYPE_TO_CLASS[k]] = k
return out
static func _validate_material_path(path: String, param_name: String, for_write: bool = false) -> Variant:
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
var path_err := McpPathValidator.validate_resource_path(path, for_write)
if not path_err.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
var has_suffix := false
for s in _SUPPORTED_SUFFIXES:
if path.ends_with(s):
has_suffix = true
break
if not has_suffix:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"%s must end with one of %s (got %s)" % [param_name, ", ".join(_SUPPORTED_SUFFIXES), path]
)
return null
func _load_material_from_path(path: String, for_write: bool = false) -> Dictionary:
var err := _validate_material_path(path, "path", for_write)
if err != null:
return err
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % path)
var res := ResourceLoader.load(path)
if res == null or not (res is Material):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % path)
return {"material": res, "path": path}
## Map a slot name to a Godot property name on the given node.
## Returns {property: "..."} or an error dict.
func _resolve_slot_property(node: Node, slot: String) -> Dictionary:
if slot == "override":
if node is MeshInstance3D or node is CSGShape3D:
return {"property": "material_override"}
if node is CanvasItem:
return {"property": "material"}
if node is GPUParticles3D or node is GPUParticles2D or node is CPUParticles3D or node is CPUParticles2D:
return {"property": "material_override"} if node is GeometryInstance3D else {"property": "material"}
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Slot 'override' not supported on %s" % node.get_class()
)
if slot == "canvas":
if node is CanvasItem:
return {"property": "material"}
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Slot 'canvas' requires a CanvasItem (got %s)" % node.get_class()
)
if slot == "process":
if node is GPUParticles3D or node is GPUParticles2D:
return {"property": "process_material"}
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Slot 'process' requires a GPUParticles2D/3D (got %s)" % node.get_class()
)
if slot.begins_with("surface_"):
if not (node is MeshInstance3D):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Slot '%s' requires a MeshInstance3D (got %s)" % [slot, node.get_class()]
)
var idx_str := slot.substr(len("surface_"))
if not idx_str.is_valid_int():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid surface slot: %s" % slot)
var idx := int(idx_str)
var mi := node as MeshInstance3D
var surf_count := mi.mesh.get_surface_count() if mi.mesh != null else 0
if idx < 0 or idx >= surf_count:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Surface index %d out of range (mesh has %d surfaces)" % [idx, surf_count]
)
return {"property": "surface_material_override/%d" % idx}
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown slot '%s'. Valid: override, canvas, process, surface_N" % slot
)
## Apply one property to an in-memory material instance; returns null on
## success or an error dict on failure.
func _apply_one_param_on_instance(mat: Material, property: String, raw_value: Variant) -> Variant:
var prop_type: int = TYPE_NIL
var property_exists := false
for prop in mat.get_property_list():
if prop.name == property:
property_exists = true
prop_type = prop.get("type", TYPE_NIL)
break
if not property_exists:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(mat, property)
)
var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
if not coerced.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
mat.set(property, coerced.value)
return null
## Inspect a shader to get the Variant type of a uniform. Returns TYPE_NIL if
## the uniform is not declared.
static func _shader_uniform_type(shader: Shader, name: String) -> int:
if shader == null:
return TYPE_NIL
for u in shader.get_shader_uniform_list():
if u.get("name", "") == name:
return int(u.get("type", TYPE_NIL))
return TYPE_NIL
@@ -0,0 +1 @@
uid://blh4norn3rjga
@@ -0,0 +1,92 @@
@tool
extends RefCounted
## Curated material preset blueprints.
##
## Each preset returns {type, params}. Handler applies them through the
## normal material build path so they get undo + validation for free.
const _PRESETS := {
"metal": {
"type": "orm",
"params": {
"metallic": 1.0,
"roughness": 0.25,
"albedo_color": {"r": 0.85, "g": 0.85, "b": 0.88, "a": 1.0},
},
},
"glass": {
"type": "standard",
"params": {
"transparency": "alpha",
"albedo_color": {"r": 0.9, "g": 0.95, "b": 1.0, "a": 0.3},
"metallic": 0.0,
"metallic_specular": 0.5,
"roughness": 0.05,
"refraction_enabled": true,
"refraction_scale": 0.05,
},
},
"emissive": {
"type": "standard",
"params": {
"emission_enabled": true,
"emission_energy_multiplier": 3.0,
"emission": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
"albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
},
},
"unlit": {
"type": "standard",
"params": {
"shading_mode": "unshaded",
"albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
},
},
"matte": {
"type": "standard",
"params": {
"roughness": 1.0,
"metallic": 0.0,
"albedo_color": {"r": 0.7, "g": 0.7, "b": 0.7, "a": 1.0},
},
},
"ceramic": {
"type": "standard",
"params": {
"roughness": 0.4,
"metallic": 0.0,
"clearcoat_enabled": true,
"clearcoat": 0.7,
"clearcoat_roughness": 0.15,
"albedo_color": {"r": 0.95, "g": 0.95, "b": 0.95, "a": 1.0},
},
},
}
static func list() -> Array:
return _PRESETS.keys()
static func has(preset_name: String) -> bool:
return _PRESETS.has(preset_name)
## Returns a deep-copied {type, params} blueprint for the named preset, or
## null if the preset is unknown. Overrides are merged into params.
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var entry: Dictionary = _PRESETS[preset_name].duplicate(true)
var params: Dictionary = entry.get("params", {})
# Allow overrides to change type, too.
if overrides.has("type"):
entry["type"] = overrides["type"]
for key in overrides:
if key == "type":
continue
params[key] = overrides[key]
entry["params"] = params
return entry
@@ -0,0 +1 @@
uid://bnuwye1r8ow7g
+255
View File
@@ -0,0 +1,255 @@
@tool
extends RefCounted
## Value coercion helpers for material authoring.
##
## Extends node_handler._coerce_value with material-specific cases:
## - enum-by-name (transparency="alpha" → TRANSPARENCY_ALPHA)
## - texture path → Texture2D
## - {r,g,b,a} dict → Color (also handled by node coerce, but we want it inline)
const _ENUM_TABLES := {
"transparency": {
"disabled": BaseMaterial3D.TRANSPARENCY_DISABLED,
"alpha": BaseMaterial3D.TRANSPARENCY_ALPHA,
"alpha_scissor": BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR,
"alpha_hash": BaseMaterial3D.TRANSPARENCY_ALPHA_HASH,
"alpha_depth_pre_pass": BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS,
},
"shading_mode": {
"unshaded": BaseMaterial3D.SHADING_MODE_UNSHADED,
"per_pixel": BaseMaterial3D.SHADING_MODE_PER_PIXEL,
"per_vertex": BaseMaterial3D.SHADING_MODE_PER_VERTEX,
},
"blend_mode": {
"mix": BaseMaterial3D.BLEND_MODE_MIX,
"add": BaseMaterial3D.BLEND_MODE_ADD,
"sub": BaseMaterial3D.BLEND_MODE_SUB,
"mul": BaseMaterial3D.BLEND_MODE_MUL,
},
"cull_mode": {
"back": BaseMaterial3D.CULL_BACK,
"front": BaseMaterial3D.CULL_FRONT,
"disabled": BaseMaterial3D.CULL_DISABLED,
},
"depth_draw_mode": {
"opaque_only": BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY,
"always": BaseMaterial3D.DEPTH_DRAW_ALWAYS,
"disabled": BaseMaterial3D.DEPTH_DRAW_DISABLED,
},
"diffuse_mode": {
"burley": BaseMaterial3D.DIFFUSE_BURLEY,
"lambert": BaseMaterial3D.DIFFUSE_LAMBERT,
"lambert_wrap": BaseMaterial3D.DIFFUSE_LAMBERT_WRAP,
"toon": BaseMaterial3D.DIFFUSE_TOON,
},
"specular_mode": {
"schlick_ggx": BaseMaterial3D.SPECULAR_SCHLICK_GGX,
"toon": BaseMaterial3D.SPECULAR_TOON,
"disabled": BaseMaterial3D.SPECULAR_DISABLED,
},
"billboard_mode": {
"disabled": BaseMaterial3D.BILLBOARD_DISABLED,
"enabled": BaseMaterial3D.BILLBOARD_ENABLED,
"fixed_y": BaseMaterial3D.BILLBOARD_FIXED_Y,
"particles": BaseMaterial3D.BILLBOARD_PARTICLES,
},
"texture_filter": {
"nearest": BaseMaterial3D.TEXTURE_FILTER_NEAREST,
"linear": BaseMaterial3D.TEXTURE_FILTER_LINEAR,
"nearest_mipmap": BaseMaterial3D.TEXTURE_FILTER_NEAREST_WITH_MIPMAPS,
"linear_mipmap": BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS,
},
}
## Return the enum int for (property, string_name), or null if not a known enum string.
static func resolve_enum(property: String, value: Variant) -> Variant:
if not (value is String):
return null
if not _ENUM_TABLES.has(property):
return null
var table: Dictionary = _ENUM_TABLES[property]
var key: String = String(value).to_lower()
if table.has(key):
return table[key]
return null
## Parse a color from Color, "#rrggbb", "#rrggbbaa", named (red/blue/...) or dict.
## Returns null if the input cannot be parsed.
static func parse_color(value: Variant) -> Variant:
if value is Color:
return value
if value is String:
var s: String = value
var sentinel_a := Color(0, 0, 0, 0)
var sentinel_b := Color(1, 1, 1, 1)
var a := Color.from_string(s, sentinel_a)
var b := Color.from_string(s, sentinel_b)
if a != b:
return null
return a
if value is Dictionary:
var d: Dictionary = value
if d.has("r") and d.has("g") and d.has("b"):
return Color(float(d.r), float(d.g), float(d.b), float(d.get("a", 1.0)))
if value is Array and value.size() >= 3:
var arr: Array = value
var alpha := float(arr[3]) if arr.size() >= 4 else 1.0
return Color(float(arr[0]), float(arr[1]), float(arr[2]), alpha)
return null
static func parse_vector3(value: Variant) -> Variant:
if value is Vector3:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector3(float(d.get("x", 0)), float(d.get("y", 0)), float(d.get("z", 0)))
if value is Array and value.size() >= 3:
return Vector3(float(value[0]), float(value[1]), float(value[2]))
return null
static func parse_vector2(value: Variant) -> Variant:
if value is Vector2:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector2(float(d.get("x", 0)), float(d.get("y", 0)))
if value is Array and value.size() >= 2:
return Vector2(float(value[0]), float(value[1]))
return null
## Parse a {stops: [{time, color}]} gradient dict into a Gradient resource.
static func parse_gradient(value: Variant) -> Variant:
if value is Gradient:
return value
if not (value is Dictionary):
return null
var d: Dictionary = value
if not d.has("stops"):
return null
var stops_array = d.get("stops")
if not (stops_array is Array):
return null
var offsets: PackedFloat32Array = PackedFloat32Array()
var colors: PackedColorArray = PackedColorArray()
for stop in stops_array:
if not (stop is Dictionary):
return null
var t := float(stop.get("time", 0.0))
var c = parse_color(stop.get("color"))
if c == null:
return null
offsets.append(t)
colors.append(c)
var grad := Gradient.new()
grad.offsets = offsets
grad.colors = colors
return grad
## Load a Texture2D from a res:// / uid:// / user:// path (validate_loadable_path).
## Returns null on failure (including a path that fails confinement / traversal).
static func load_texture(path: String) -> Texture2D:
if not McpPathValidator.validate_loadable_path(path).is_empty():
return null
if not ResourceLoader.exists(path):
return null
var res := ResourceLoader.load(path)
if res is Texture2D:
return res
return null
## Coerce a JSON-shaped value for a material property.
## Returns a dict {ok: true, value: ...} on success, or {ok: false, error: "..."} on failure.
## For properties the coercer doesn't have special logic for, falls back to target_type.
static func coerce_material_value(property: String, value: Variant, target_type: int) -> Dictionary:
# Enum-by-name: must match before generic TYPE_INT coercion.
if _ENUM_TABLES.has(property):
if value is String:
var enum_val = resolve_enum(property, value)
if enum_val == null:
return {
"ok": false,
"error": "Invalid %s value: '%s'. Valid: %s" % [
property, value, ", ".join(_ENUM_TABLES[property].keys())
],
}
return {"ok": true, "value": int(enum_val)}
if value is int or value is float:
return {"ok": true, "value": int(value)}
match target_type:
TYPE_COLOR:
var c = parse_color(value)
if c == null:
return {"ok": false, "error": "Invalid color for %s: %s" % [property, value]}
return {"ok": true, "value": c}
TYPE_VECTOR3:
var v3 = parse_vector3(value)
if v3 == null:
return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]}
return {"ok": true, "value": v3}
TYPE_VECTOR2:
var v2 = parse_vector2(value)
if v2 == null:
return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]}
return {"ok": true, "value": v2}
TYPE_BOOL:
if value is bool:
return {"ok": true, "value": value}
if value is int or value is float:
return {"ok": true, "value": bool(value)}
return {"ok": false, "error": "Expected bool for %s" % property}
TYPE_INT:
if value is int:
return {"ok": true, "value": value}
if value is float:
return {"ok": true, "value": int(value)}
return {"ok": false, "error": "Expected int for %s" % property}
TYPE_FLOAT:
if value is float:
return {"ok": true, "value": value}
if value is int:
return {"ok": true, "value": float(value)}
return {"ok": false, "error": "Expected number for %s" % property}
TYPE_OBJECT:
if value == null:
return {"ok": true, "value": null}
if value is Object:
return {"ok": true, "value": value}
if value is String:
var tex := load_texture(value)
if tex == null:
return {"ok": false, "error": "Resource not found or wrong type: %s" % value}
return {"ok": true, "value": tex}
return {"ok": false, "error": "Expected resource path (string) for %s" % property}
TYPE_STRING:
return {"ok": true, "value": String(value)}
# Unknown target type — pass through.
return {"ok": true, "value": value}
## Serialize a Variant into JSON-friendly shape for responses.
static func serialize_value(value: Variant) -> Variant:
if value == null:
return null
if value is Color:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
if value is Vector3:
return {"x": value.x, "y": value.y, "z": value.z}
if value is Vector2:
return {"x": value.x, "y": value.y}
if value is Resource:
var path := (value as Resource).resource_path
if path.is_empty():
return {"type": value.get_class(), "path": ""}
return {"type": value.get_class(), "path": path}
return value
@@ -0,0 +1 @@
uid://daqgjkflia8nk
+866
View File
@@ -0,0 +1,866 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
## Handles node creation and manipulation with undo/redo support.
const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
func create_node(params: Dictionary) -> Dictionary:
var node_type: String = params.get("type", "")
var node_name: String = params.get("name", "")
var parent_path: String = params.get("parent_path", "")
var scene_path: String = params.get("scene_path", "")
var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", ""))
if scene_check.has("error"):
return scene_check
var scene_root: Node = scene_check.node
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 new_node: Node
if not scene_path.is_empty():
# Scene instancing path — load and instantiate a PackedScene.
# GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real
# scene instance (foldout icon, the .tscn stores a reference instead of
# an exploded subtree). Descendants remain owned by their sub-scene;
# setting their owner to our scene_root would break the instance link.
var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path")
if scene_path_err != null:
return scene_path_err
if not ResourceLoader.exists(scene_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path)
var packed_scene = ResourceLoader.load(scene_path)
if packed_scene == null or not packed_scene is PackedScene:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a PackedScene" % scene_path)
new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
if new_node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path)
else:
# ClassDB path — create by type.
if node_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type (or provide scene_path)")
if not ClassDB.class_exists(node_type):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % node_type)
if not ClassDB.is_parent_class(node_type, "Node"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type)
new_node = ClassDB.instantiate(node_type)
if new_node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type)
if not node_name.is_empty():
new_node.name = node_name
_undo_redo.create_action("MCP: Create %s" % new_node.name)
_undo_redo.add_do_method(parent, "add_child", new_node, true)
_undo_redo.add_do_method(new_node, "set_owner", scene_root)
_undo_redo.add_do_reference(new_node)
_undo_redo.add_undo_method(parent, "remove_child", new_node)
_undo_redo.commit_action()
var response := {
"name": new_node.name,
"type": new_node.get_class(),
"path": McpScenePath.from_node(new_node, scene_root),
"parent_path": McpScenePath.from_node(parent, scene_root),
"undoable": true,
}
if not scene_path.is_empty():
response["scene_path"] = scene_path
return {"data": response}
func delete_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var root_err := _reject_if_scene_root(node, scene_root, "delete")
if root_err != null:
return root_err
var parent := node.get_parent()
var idx := node.get_index()
_undo_redo.create_action("MCP: Delete %s" % node.name)
_undo_redo.add_do_method(parent, "remove_child", node)
_undo_redo.add_undo_method(parent, "add_child", node, true)
_undo_redo.add_undo_method(parent, "move_child", node, idx)
_undo_redo.add_undo_method(node, "set_owner", scene_root)
_undo_redo.add_undo_reference(node)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"undoable": true,
}
}
func reparent_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var new_parent_path: String = params.get("new_parent", "")
if new_parent_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_parent")
var new_parent := McpScenePath.resolve(new_parent_path, scene_root)
if new_parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(new_parent_path, scene_root))
var root_err := _reject_if_scene_root(node, scene_root, "reparent")
if root_err != null:
return root_err
# Prevent reparenting a node to itself or to one of its own descendants.
# Godot's `A.is_ancestor_of(B)` returns true iff B is a descendant of A, so
# the direction here matters: we want `node.is_ancestor_of(new_parent)` to
# catch "new_parent is below node in the tree" and thus would create a
# cycle. The previous direction (`new_parent.is_ancestor_of(node)`) asked
# the opposite question — whether we were trying to move a node to one of
# its own ancestors — which is a perfectly valid operation. See issue #121.
if node == new_parent or node.is_ancestor_of(new_parent):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot reparent a node to itself or its descendant")
var old_parent := node.get_parent()
var old_idx := node.get_index()
_undo_redo.create_action("MCP: Reparent %s" % node.name)
_undo_redo.add_do_method(old_parent, "remove_child", node)
_undo_redo.add_do_method(new_parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
_undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(new_parent, "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()
# Re-set owner for all descendants (reparent can break ownership chain)
_set_owner_recursive(node, scene_root)
return {
"data": {
"path": McpScenePath.from_node(node, scene_root),
"old_parent": McpScenePath.from_node(old_parent, scene_root),
"new_parent": McpScenePath.from_node(new_parent, scene_root),
"undoable": true,
}
}
func set_property(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var property: String = params.get("property", "")
if property.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property")
if not "value" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var value = params.get("value")
var found := false
var prop_type: int = TYPE_NIL
for prop in node.get_property_list():
if prop.name == property:
found = true
prop_type = prop.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
var old_value = node.get(property)
# Prefer declared property type; fall back to runtime type for dynamic props
# (scripted @export vars can report TYPE_NIL in the property list).
var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value)
var instantiated_resource := false
# Some MCP clients (Cline) stringify the documented {"__class__": "BoxMesh", ...}
# value before sending. Promote that string back to a Dictionary here so the
# `__class__` branch below handles it, instead of the next branch treating
# the JSON blob as a res:// path and emitting "Resource not found: {...}".
# See #206.
if target_type == TYPE_OBJECT and value is String and value.begins_with("{"):
var json := JSON.new()
if json.parse(value) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"):
value = json.data
var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://")))
var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string)
if resource_string_value:
if value == "":
value = null
else:
var value_path_err = McpPathValidator.loadable_error(value, "value")
if value_path_err != null:
return value_path_err
if not ResourceLoader.exists(value):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value)
var loaded := ResourceLoader.load(value)
if loaded == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value)
value = loaded
elif target_type == TYPE_OBJECT and value is Dictionary and value.has("__class__"):
# Shortcut: {"__class__": "BoxMesh", "size": {...}} instantiates a
# fresh Resource subclass and applies the remaining keys as
# properties. Mirrors resource_create's inline-assign path but
# avoids a separate tool call for the common case.
var type_str: String = value.get("__class__", "")
var class_err := ResourceHandler._validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null or not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource" % type_str
)
var res: Resource = instance
var remaining: Dictionary = (value as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
var apply_err := ResourceHandler._apply_resource_properties(res, remaining)
if apply_err != null:
return apply_err
value = res
instantiated_resource = true
else:
value = _coerce_value(value, target_type)
## Refuse any value that didn't land as the target compound Variant
## — wrong-shape dict (#123) or non-dict input like list / JSON string
## that used to silently default-construct Vector3.ZERO (#191).
var coerce_err := _check_coerced(value, target_type)
if coerce_err != null:
return coerce_err
_undo_redo.create_action("MCP: Set %s.%s" % [node.name, property])
_undo_redo.add_do_property(node, property, value)
_undo_redo.add_undo_property(node, property, old_value)
if instantiated_resource:
_undo_redo.add_do_reference(value)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"property": property,
"value": _serialize_value(node.get(property)),
"old_value": _serialize_value(old_value),
"undoable": true,
}
}
func rename_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var new_name: String = params.get("new_name", "")
if new_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_name")
## The scene root's name is baked into the .tscn serialization and is
## referenced by every NodePath that starts with `/<root>` (AnimationPlayer
## tracks, RemoteTransform3D targets, exported NodePath @vars, etc.).
## Renaming it silently breaks those references. The MCP tool's docstring
## has always promised "Cannot rename the scene root" — enforce it. #122
if node == scene_root:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot rename the scene root")
if new_name.validate_node_name() != new_name:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid characters in name: %s" % new_name)
var old_name := String(node.name)
if old_name == new_name:
return {
"data": {
"path": node_path,
"name": new_name,
"old_name": old_name,
"unchanged": true,
"undoable": false,
"reason": "Name unchanged",
}
}
var parent := node.get_parent()
for sibling in parent.get_children():
if sibling != node and String(sibling.name) == new_name:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name)
_undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name])
_undo_redo.add_do_property(node, "name", new_name)
_undo_redo.add_undo_property(node, "name", old_name)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, scene_root),
"old_path": node_path,
"name": String(node.name),
"old_name": old_name,
"undoable": true,
}
}
func duplicate_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var root_err := _reject_if_scene_root(node, scene_root, "duplicate")
if root_err != null:
return root_err
var parent := node.get_parent()
var dup: Node = node.duplicate()
if dup == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to duplicate node")
# Apply optional name
var new_name: String = params.get("name", "")
if not new_name.is_empty():
dup.name = new_name
_undo_redo.create_action("MCP: Duplicate %s" % node.name)
_undo_redo.add_do_method(parent, "add_child", dup, true)
_undo_redo.add_do_method(dup, "set_owner", scene_root)
_undo_redo.add_do_reference(dup)
_undo_redo.add_undo_method(parent, "remove_child", dup)
_undo_redo.commit_action()
# Set owner for all descendants of the duplicate
_set_owner_recursive(dup, scene_root)
return {
"data": {
"path": McpScenePath.from_node(dup, scene_root),
"original_path": node_path,
"name": dup.name,
"type": dup.get_class(),
"undoable": true,
}
}
func move_node(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var root_err := _reject_if_scene_root(node, scene_root, "reorder")
if root_err != null:
return root_err
if not "index" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: index")
var new_index: int = params.get("index", 0)
var parent := node.get_parent()
var old_index := node.get_index()
var sibling_count := parent.get_child_count()
if new_index < 0 or new_index >= sibling_count:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Index %d out of range (0..%d)" % [new_index, sibling_count - 1])
_undo_redo.create_action("MCP: Move %s to index %d" % [node.name, new_index])
_undo_redo.add_do_method(parent, "move_child", node, new_index)
_undo_redo.add_undo_method(parent, "move_child", node, old_index)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"old_index": old_index,
"new_index": new_index,
"undoable": true,
}
}
func add_to_group(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var group_value: Variant = params.get("group", "")
var type_err := McpParamValidators.require_string("group", group_value)
if type_err != null:
return type_err
var group := String(group_value)
if group.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group")
if node.is_in_group(group):
return {"data": {"path": node_path, "group": group, "already_member": true, "undoable": false, "reason": "No change made"}}
_undo_redo.create_action("MCP: Add %s to group %s" % [node.name, group])
_undo_redo.add_do_method(node, "add_to_group", group, true)
_undo_redo.add_undo_method(node, "remove_from_group", group)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"group": group,
"undoable": true,
}
}
func remove_from_group(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var group_value: Variant = params.get("group", "")
var type_err := McpParamValidators.require_string("group", group_value)
if type_err != null:
return type_err
var group := String(group_value)
if group.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group")
if not node.is_in_group(group):
return {"data": {"path": node_path, "group": group, "not_member": true, "undoable": false, "reason": "Node not in group"}}
_undo_redo.create_action("MCP: Remove %s from group %s" % [node.name, group])
_undo_redo.add_do_method(node, "remove_from_group", group)
_undo_redo.add_undo_method(node, "add_to_group", group, true)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"group": group,
"undoable": true,
}
}
func set_selection(params: Dictionary) -> Dictionary:
var paths: Array = params.get("paths", [])
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 selection := EditorInterface.get_selection()
selection.clear()
var selected: Array[String] = []
var not_found: Array[String] = []
for path_variant in paths:
var path: String = str(path_variant)
var node := McpScenePath.resolve(path, scene_root)
if node:
selection.add_node(node)
selected.append(path)
else:
not_found.append(path)
return {
"data": {
"selected": selected,
"not_found": not_found,
"count": selected.size(),
"undoable": false,
"reason": "Selection changes are not tracked in undo history",
}
}
func _set_owner_recursive(node: Node, owner: Node) -> void:
for child in node.get_children():
child.set_owner(owner)
_set_owner_recursive(child, owner)
## Canonical dict-key sets for dict→Variant coercion. Alpha on `COLOR_KEYS`
## is optional — the coercer defaults it to 1.0 when absent.
const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
## End-to-end coerce check for compound JSON-shaped targets
## (Vector2/Vector3/Color). Returns a full `make(...)`-shaped error dict
## if `value` didn't land as the target Variant after `_coerce_value`,
## else null. Wrong-shape dicts get the `_check_dict_coerce_failed`
## message (expected-vs-got keys); non-dict inputs (Array, String,
## primitive) name the received type and a JSON shape hint. No-op for
## non-compound targets — Godot's setter handles those.
##
## Used by set_property, resource_handler, and validation handlers
## (curve, texture). Issue #191 — passing a list, JSON string, or
## anything else to a Vector3 property used to silently store
## Vector3.ZERO; this gates that path.
static func _check_coerced(value: Variant, target_type: int, prefix: String = "") -> Variant:
var ok := false
match target_type:
TYPE_VECTOR2:
ok = value is Vector2
TYPE_VECTOR3:
ok = value is Vector3
TYPE_COLOR:
ok = value is Color
TYPE_PACKED_VECTOR2_ARRAY:
ok = value is PackedVector2Array
TYPE_PACKED_VECTOR3_ARRAY:
ok = value is PackedVector3Array
TYPE_PACKED_COLOR_ARRAY:
ok = value is PackedColorArray
TYPE_PACKED_INT32_ARRAY:
ok = value is PackedInt32Array
TYPE_PACKED_INT64_ARRAY:
ok = value is PackedInt64Array
TYPE_PACKED_FLOAT32_ARRAY:
ok = value is PackedFloat32Array
TYPE_PACKED_FLOAT64_ARRAY:
ok = value is PackedFloat64Array
TYPE_PACKED_STRING_ARRAY:
ok = value is PackedStringArray
_:
return null
if ok:
return null
var dict_err := _check_dict_coerce_failed(value, target_type)
if dict_err != null:
return ErrorCodes.prefix_message(dict_err, prefix)
## Wording stays neutral on shape — `_shape_hint` already produces a
## dict-shaped string for Vector2/3/Color and a list-shaped one for
## the Packed*Array slots. The old "expected a dict like [...]" phrasing
## read self-contradictory for packed targets (PR #424 review).
var err := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot coerce %s to %s; expected %s" % [
type_string(typeof(value)), type_string(target_type), _shape_hint(target_type),
],
)
return ErrorCodes.prefix_message(err, prefix)
## Build a "{\"x\":1,...}" hint string from the canonical key constants
## so adding a key (e.g. Vector4) only touches VECTORN_KEYS. Packed*Array
## targets short-circuit to a literal list-shaped hint.
static func _shape_hint(target_type: int) -> String:
match target_type:
TYPE_PACKED_VECTOR2_ARRAY:
return "[{\"x\":0,\"y\":0}, ...]"
TYPE_PACKED_VECTOR3_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
TYPE_PACKED_COLOR_ARRAY:
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
return "[int, ...]"
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
return "[float, ...]"
TYPE_PACKED_STRING_ARRAY:
return "[\"...\", ...]"
var keys: Array[String] = []
match target_type:
TYPE_VECTOR2: keys = VECTOR2_KEYS
TYPE_VECTOR3: keys = VECTOR3_KEYS
TYPE_COLOR: keys = COLOR_KEYS
var pairs: Array[String] = []
for k in keys:
pairs.append("\"%s\":0" % k)
return "{" + ",".join(pairs) + "}"
## Detect a failed dict→typed-Variant coercion. Returns an INVALID_PARAMS
## error dict if `value` is still a Dictionary after a coercion attempt
## targeting a Vector2/Vector3/Color slot, else null. Message names the
## expected keys and the keys actually received so agents self-correct
## on the next retry.
static func _check_dict_coerce_failed(value: Variant, target_type: int) -> Variant:
if not (value is Dictionary):
return null
var expected: Array[String] = []
var type_name := ""
match target_type:
TYPE_VECTOR2:
expected = VECTOR2_KEYS
type_name = "Vector2"
TYPE_VECTOR3:
expected = VECTOR3_KEYS
type_name = "Vector3"
TYPE_COLOR:
expected = COLOR_KEYS
type_name = "Color"
_:
return null
var got_keys: Array = (value as Dictionary).keys()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot coerce dict to %s: expected keys %s; got %s" % [type_name, str(expected), str(got_keys)]
)
## Coerce JSON-shaped values into Godot Variants when the target property
## type is known. Returns the coerced value on success, or the input
## unchanged on failure — callers detect the type mismatch via an
## `is <Type>` check (curve_handler, texture_handler) or via the
## `_check_dict_coerce_failed` helper (set_property, resource_handler).
##
## Dictionary→Vector2/Vector3/Color cases REQUIRE all canonical keys;
## wrong-shape dicts flow through unchanged. See issue #123 — previous
## `dict.get(key, 0)` defaults silently zero-filled missing axes.
static func _coerce_value(value: Variant, target_type: int) -> Variant:
match target_type:
TYPE_VECTOR2:
if value is Dictionary and value.has_all(VECTOR2_KEYS):
return Vector2(value["x"], value["y"])
TYPE_VECTOR3:
if value is Dictionary and value.has_all(VECTOR3_KEYS):
return Vector3(value["x"], value["y"], value["z"])
TYPE_COLOR:
if value is Dictionary and value.has_all(COLOR_KEYS):
return Color(value["r"], value["g"], value["b"], value.get("a", 1.0))
if value is String:
return Color(value)
TYPE_BOOL:
if value is float or value is int:
return bool(value)
TYPE_INT:
if value is float:
return int(value)
TYPE_FLOAT:
if value is int:
return float(value)
TYPE_STRING_NAME:
if value is String:
return StringName(value)
TYPE_NODE_PATH:
if value is String:
return NodePath(value)
if value == null:
return NodePath()
TYPE_OBJECT:
# Resource loading is handled in set_property so we can return a
# typed error; here we only pass through cleared values.
if value == null:
return null
TYPE_ARRAY:
if value is Array:
return value
TYPE_DICTIONARY:
if value is Dictionary:
return value
TYPE_PACKED_VECTOR2_ARRAY:
if value is Array:
var out := PackedVector2Array()
for item in value:
if item is Vector2:
out.append(item)
elif item is Dictionary and item.has_all(VECTOR2_KEYS):
out.append(Vector2(item["x"], item["y"]))
else:
return value # leave for _check_coerced to flag
return out
TYPE_PACKED_VECTOR3_ARRAY:
if value is Array:
var out := PackedVector3Array()
for item in value:
if item is Vector3:
out.append(item)
elif item is Dictionary and item.has_all(VECTOR3_KEYS):
out.append(Vector3(item["x"], item["y"], item["z"]))
else:
return value
return out
TYPE_PACKED_COLOR_ARRAY:
if value is Array:
var out := PackedColorArray()
for item in value:
if item is Color:
out.append(item)
elif item is Dictionary and item.has_all(COLOR_KEYS):
out.append(Color(item["r"], item["g"], item["b"], item.get("a", 1.0)))
elif item is String:
out.append(Color(item))
else:
return value
return out
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
if value is Array:
var out: Variant = PackedInt32Array() if target_type == TYPE_PACKED_INT32_ARRAY else PackedInt64Array()
for item in value:
if item is int or item is float:
out.append(int(item))
else:
return value
return out
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
if value is Array:
var out: Variant = PackedFloat32Array() if target_type == TYPE_PACKED_FLOAT32_ARRAY else PackedFloat64Array()
for item in value:
if item is float or item is int:
out.append(float(item))
else:
return value
return out
TYPE_PACKED_STRING_ARRAY:
if value is Array:
var out := PackedStringArray()
for item in value:
if item is String:
out.append(item)
else:
return value
return out
# PackedByteArray intentionally unhandled — needs design decision
# (base64 string vs. raw int list); JSON has no native byte type.
return value
func get_node_properties(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var properties: Array[Dictionary] = []
for prop in node.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
# Safe read: custom script getters can error; skip bad properties
# rather than letting one bad read timeout the entire request.
var value = node.get(prop.name)
if value == null and prop.type != TYPE_NIL:
continue
properties.append({
"name": prop.name,
"type": type_string(prop.type),
"value": _serialize_value(value),
})
return {
"data": {
"path": node_path,
"node_type": node.get_class(),
"properties": properties,
"count": properties.size(),
}
}
func get_children(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var scene_root: Node = resolved.scene_root
var children: Array[Dictionary] = []
for child in node.get_children():
children.append({
"name": child.name,
"type": child.get_class(),
"path": McpScenePath.from_node(child, scene_root),
"children_count": child.get_child_count(),
})
return {
"data": {
"parent_path": node_path,
"children": children,
"count": children.size(),
}
}
func get_groups(params: Dictionary) -> Dictionary:
var resolved := _resolve_node(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var groups: Array[String] = []
for group in node.get_groups():
# Skip internal groups (start with underscore)
if not str(group).begins_with("_"):
groups.append(str(group))
return {
"data": {
"path": node_path,
"groups": groups,
"count": groups.size(),
}
}
## Validate path param, resolve to node. Returns dict with node/path/scene_root
## on success, or an error dict (has "error" key) on failure. Thin wrapper
## around the shared `McpNodeValidator.resolve_or_error` helper (audit-v2 #20).
func _resolve_node(params: Dictionary) -> Dictionary:
return McpNodeValidator.resolve_or_error(
params.get("path", ""), "path", params.get("scene_file", ""),
)
## Reject operations targeting the scene root. Returns an INVALID_PARAMS error
## dict with "Cannot <op> the scene root", or null if `node` is not the root.
static func _reject_if_scene_root(node: Node, scene_root: Node, op: String) -> Variant:
if node == scene_root:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot %s the scene root" % op)
return null
## Convert a Godot Variant to a JSON-safe value. Compound geometry types
## (AABB, Rect2, Transforms, …) and packed arrays serialize as structured
## dicts/arrays so agents can inspect fields instead of parsing Godot's
## debug repr — see issue #214.
static func _serialize_value(value: Variant) -> Variant:
return VariantSerializer.serialize(value)
@@ -0,0 +1 @@
uid://qhhd5mm5awym
@@ -0,0 +1,761 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles particle emitter authoring (GPU + CPU, 2D + 3D).
##
## All write operations bundle node creation and sub-resource spawns
## (ParticleProcessMaterial, default QuadMesh) in a single create_action
## so Ctrl-Z rolls back the whole effect atomically.
const ParticleValues := preload("res://addons/godot_ai/handlers/particle_values.gd")
const ParticlePresets := preload("res://addons/godot_ai/handlers/particle_presets.gd")
const _VALID_TYPES := {
"gpu_3d": "GPUParticles3D",
"gpu_2d": "GPUParticles2D",
"cpu_3d": "CPUParticles3D",
"cpu_2d": "CPUParticles2D",
}
const _MAIN_KEYS := [
"amount",
"lifetime",
"one_shot",
"explosiveness",
"preprocess",
"speed_scale",
"randomness",
"fixed_fps",
"emitting",
"local_coords",
"interp_to_end",
]
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# particle_create
# ============================================================================
func create_particle(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "Particles")
var type_str: String = params.get("type", "gpu_3d")
if not _VALID_TYPES.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid particle 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_particle(type_str)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate particle node")
if not node_name.is_empty():
node.name = node_name
var process_mat: ParticleProcessMaterial = null
var process_material_created := false
var draw_mesh: Mesh = null
var draw_material: StandardMaterial3D = null
var draw_pass_mesh_created := false
var draw_material_created := false
if type_str == "gpu_3d" or type_str == "gpu_2d":
process_mat = ParticleProcessMaterial.new()
process_material_created = true
if type_str == "gpu_3d":
draw_mesh = QuadMesh.new()
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
# Without a material, the mesh renders flat white — ignoring
# ParticleProcessMaterial.color_ramp entirely. Give it the standard
# billboard + vertex-color-as-albedo setup so color_ramp works.
draw_material = ParticleValues.build_draw_material({})
(draw_mesh as QuadMesh).material = draw_material
draw_pass_mesh_created = true
draw_material_created = true
_undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
_undo_redo.add_do_method(parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
if process_mat != null:
_undo_redo.add_do_property(node, "process_material", process_mat)
_undo_redo.add_do_reference(process_mat)
if draw_mesh != null:
_undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
_undo_redo.add_do_reference(draw_mesh)
if draw_material != null:
_undo_redo.add_do_reference(draw_material)
_undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
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],
"process_material_created": process_material_created,
"draw_pass_mesh_created": draw_pass_mesh_created,
"draw_material_created": draw_material_created,
"undoable": true,
}
}
# ============================================================================
# particle_set_main
# ============================================================================
func set_main(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var properties: Dictionary = params.get("properties", {})
if properties.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
var coerced: Dictionary = {}
var old_values: Dictionary = {}
for property in properties:
var prop_name: String = String(property)
if not (prop_name in _MAIN_KEYS):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Unknown main property '%s'. Valid: %s" % [prop_name, ", ".join(_MAIN_KEYS)]
)
var prop_type := _node_property_type(node, prop_name)
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 := ParticleValues.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: Set particle main on %s" % node.name)
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])
_undo_redo.commit_action()
var applied: Array[String] = []
var serialized_values: Dictionary = {}
for prop_name in coerced:
applied.append(prop_name)
serialized_values[prop_name] = ParticleValues.serialize(coerced[prop_name])
return {
"data": {
"path": node_path,
"applied": applied,
"values": serialized_values,
"undoable": true,
}
}
# ============================================================================
# particle_set_process
# ============================================================================
func set_process(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var properties: Dictionary = params.get("properties", {})
if properties.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
# GPU: work through process_material; CPU: properties live on node directly.
if node is GPUParticles3D or node is GPUParticles2D:
return _set_process_gpu(node, node_path, properties)
return _set_process_cpu(node, node_path, properties)
func _set_process_gpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
var existing_mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
var process_material_created := false
var mat: ParticleProcessMaterial = existing_mat
if mat == null:
mat = ParticleProcessMaterial.new()
process_material_created = true
var coerced: Dictionary = {}
for property in properties:
var prop_name: String = String(property)
var prop_type := _object_property_type(mat, prop_name)
if prop_type == TYPE_NIL:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not present on ParticleProcessMaterial" % prop_name
)
var coerce_result := ParticleValues.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
_undo_redo.create_action("MCP: Set particle process on %s" % node.name)
if process_material_created:
_undo_redo.add_do_property(node, "process_material", mat)
_undo_redo.add_undo_property(node, "process_material", null)
_undo_redo.add_do_reference(mat)
# Apply new values directly on the (newly created) material. No old values to restore.
for prop_name in coerced:
mat.set(prop_name, coerced[prop_name])
else:
# Use the reusable apply/restore pattern for existing material.
var old_values: Dictionary = {}
for prop_name in coerced:
old_values[prop_name] = mat.get(prop_name)
for prop_name in coerced:
_undo_redo.add_do_property(mat, prop_name, coerced[prop_name])
_undo_redo.add_undo_property(mat, prop_name, old_values[prop_name])
_undo_redo.commit_action()
var applied: Array[String] = []
var serialized: Dictionary = {}
for prop_name in coerced:
applied.append(prop_name)
serialized[prop_name] = ParticleValues.serialize(mat.get(prop_name))
return {
"data": {
"path": node_path,
"applied": applied,
"values": serialized,
"process_material_created": process_material_created,
"undoable": true,
}
}
func _set_process_cpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
# CPU particles expose the same property vocabulary directly on the node,
# so property names pass through unchanged.
var coerced: Dictionary = {}
var old_values: Dictionary = {}
for property in properties:
var prop_name: String = String(property)
var prop_type := _node_property_type(node, prop_name)
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 := ParticleValues.coerce(prop_name, properties[property], 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: Set particle process on %s" % node.name)
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])
_undo_redo.commit_action()
var applied: Array[String] = []
var serialized: Dictionary = {}
for prop_name in coerced:
applied.append(prop_name)
serialized[prop_name] = ParticleValues.serialize(coerced[prop_name])
return {
"data": {
"path": node_path,
"applied": applied,
"values": serialized,
"process_material_created": false,
"undoable": true,
}
}
# ============================================================================
# particle_set_draw_pass
# ============================================================================
func set_draw_pass(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var pass_idx: int = int(params.get("pass", 1))
var mesh_path: String = params.get("mesh", "")
var texture_path: String = params.get("texture", "")
var material_path: String = params.get("material", "")
if node is GPUParticles3D:
return _set_draw_pass_gpu_3d(node, node_path, pass_idx, mesh_path, material_path)
if node is CPUParticles3D:
return _set_draw_pass_cpu_3d(node, node_path, mesh_path, material_path)
if node is GPUParticles2D or node is CPUParticles2D:
return _set_draw_pass_2d(node, node_path, texture_path)
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node %s is not a particle node" % node.get_class())
func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: int, mesh_path: String, material_path: String) -> Dictionary:
if pass_idx < 1 or pass_idx > 4:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "pass must be 1..4 (got %d)" % pass_idx)
var mesh: Mesh = null
var mesh_created := false
var property_name := "draw_pass_%d" % pass_idx
# draw_pass_N is only a live property when draw_passes >= N. Probe via
# get_property_list so we don't read a ghost value.
var existing_mesh: Mesh = null
if int(node.draw_passes) >= pass_idx:
existing_mesh = node.get(property_name) as Mesh
if not mesh_path.is_empty():
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
if mesh_path_err != null:
return mesh_path_err
if not ResourceLoader.exists(mesh_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
var loaded := ResourceLoader.load(mesh_path)
if not (loaded is Mesh):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
mesh = loaded
else:
if existing_mesh == null:
mesh = QuadMesh.new()
(mesh as QuadMesh).size = Vector2(0.25, 0.25)
mesh_created = true
else:
mesh = existing_mesh
var material: Material = null
if not material_path.is_empty():
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
if material_path_err != null:
return material_path_err
if not ResourceLoader.exists(material_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
var loaded_mat := ResourceLoader.load(material_path)
if not (loaded_mat is Material):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
material = loaded_mat
var old_draw_passes: int = int(node.draw_passes)
var new_draw_passes: int = max(old_draw_passes, pass_idx)
var old_value = existing_mesh # Null if draw_passes < pass_idx
var old_material: Material = null
if material != null:
old_material = node.material_override
_undo_redo.create_action("MCP: Set %s.draw_pass_%d" % [node.name, pass_idx])
# Grow draw_passes first so draw_pass_N property exists before we set it.
if new_draw_passes != old_draw_passes:
_undo_redo.add_do_property(node, "draw_passes", new_draw_passes)
_undo_redo.add_undo_property(node, "draw_passes", old_draw_passes)
if not mesh_path.is_empty() or mesh_created:
_undo_redo.add_do_property(node, property_name, mesh)
_undo_redo.add_undo_property(node, property_name, old_value)
if mesh_created:
_undo_redo.add_do_reference(mesh)
if material != null:
_undo_redo.add_do_property(node, "material_override", material)
_undo_redo.add_undo_property(node, "material_override", old_material)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"pass": pass_idx,
"mesh_path": mesh_path,
"mesh_class": mesh.get_class() if mesh else "",
"material_path": material_path,
"draw_pass_mesh_created": mesh_created,
"draw_passes_grown": new_draw_passes != old_draw_passes,
"undoable": true,
}
}
func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: String, material_path: String) -> Dictionary:
if mesh_path.is_empty() and material_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "CPUParticles3D requires mesh or material param")
var mesh: Mesh = node.mesh
var old_mesh: Mesh = mesh
if not mesh_path.is_empty():
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
if mesh_path_err != null:
return mesh_path_err
if not ResourceLoader.exists(mesh_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
var loaded := ResourceLoader.load(mesh_path)
if not (loaded is Mesh):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
mesh = loaded
var material: Material = null
var old_material: Material = node.material_override
if not material_path.is_empty():
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
if material_path_err != null:
return material_path_err
if not ResourceLoader.exists(material_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
var loaded_mat := ResourceLoader.load(material_path)
if not (loaded_mat is Material):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
material = loaded_mat
_undo_redo.create_action("MCP: Set CPU particle draw on %s" % node.name)
if not mesh_path.is_empty():
_undo_redo.add_do_property(node, "mesh", mesh)
_undo_redo.add_undo_property(node, "mesh", old_mesh)
if material != null:
_undo_redo.add_do_property(node, "material_override", material)
_undo_redo.add_undo_property(node, "material_override", old_material)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"mesh_path": mesh_path,
"material_path": material_path,
"draw_pass_mesh_created": false,
"undoable": true,
}
}
func _set_draw_pass_2d(node: Node, node_path: String, texture_path: String) -> Dictionary:
if texture_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "2D particles require texture param")
var texture_path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
if texture_path_err != null:
return texture_path_err
if not ResourceLoader.exists(texture_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
var tex := ResourceLoader.load(texture_path)
if not (tex is Texture2D):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path)
var old_texture: Texture2D = node.get("texture")
_undo_redo.create_action("MCP: Set 2D particle texture on %s" % node.name)
_undo_redo.add_do_property(node, "texture", tex)
_undo_redo.add_undo_property(node, "texture", old_texture)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"texture_path": texture_path,
"undoable": true,
}
}
# ============================================================================
# particle_restart
# ============================================================================
func restart_particle(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
if node.has_method("restart"):
node.restart()
return {
"data": {
"path": node_path,
"undoable": false,
"reason": "Restart is a runtime operation, not tracked in undo history",
}
}
# ============================================================================
# particle_get
# ============================================================================
func get_particle(params: Dictionary) -> Dictionary:
var resolved := _resolve_particle(params)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var type_str := ""
for key in _VALID_TYPES:
if node.get_class() == _VALID_TYPES[key]:
type_str = key
break
var main_values: Dictionary = {}
var node_prop_names := _property_names(node)
for key in _MAIN_KEYS:
if node_prop_names.has(key):
main_values[key] = ParticleValues.serialize(node.get(key))
var process_data: Dictionary = {}
if node is GPUParticles3D or node is GPUParticles2D:
var mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
if mat != null:
var process_props: Dictionary = {}
for prop in mat.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var v = mat.get(prop.name)
if v == null:
continue
process_props[prop.name] = ParticleValues.serialize(v)
process_data = {
"class": "ParticleProcessMaterial",
"properties": process_props,
}
var draw_passes: Array[Dictionary] = []
if node is GPUParticles3D:
var active_draw_pass_count: int = min(int(node.draw_passes), 4)
for i in range(1, active_draw_pass_count + 1):
var prop_name := "draw_pass_%d" % i
var m: Mesh = node.get(prop_name) as Mesh
draw_passes.append({
"pass": i,
"mesh_class": m.get_class() if m != null else "",
})
var texture_path := ""
if node is GPUParticles2D or node is CPUParticles2D:
var t: Texture2D = node.get("texture")
if t != null:
texture_path = t.resource_path
return {
"data": {
"path": node_path,
"type": type_str,
"class": node.get_class(),
"main": main_values,
"process": process_data,
"draw_passes": draw_passes,
"texture_path": texture_path,
}
}
# ============================================================================
# particle_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 = ParticlePresets.build(preset_name, overrides)
if blueprint == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(ParticlePresets.list())]
)
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "")
var type_str: String = params.get("type", "gpu_3d")
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 particle 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_particle(type_str)
node.name = node_name
var is_gpu := type_str == "gpu_3d" or type_str == "gpu_2d"
var is_3d := type_str == "gpu_3d" or type_str == "cpu_3d"
var process_mat: ParticleProcessMaterial = null
var process_material_created := false
if is_gpu:
process_mat = ParticleProcessMaterial.new()
process_material_created = true
var draw_mesh: Mesh = null
var draw_material: StandardMaterial3D = null
var draw_pass_mesh_created := false
var draw_material_created := false
if type_str == "gpu_3d":
draw_mesh = QuadMesh.new()
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
var draw_config: Dictionary = blueprint.get("draw", {})
draw_material = ParticleValues.build_draw_material(draw_config)
(draw_mesh as QuadMesh).material = draw_material
draw_pass_mesh_created = true
draw_material_created = true
# Pre-apply preset values to in-memory targets (no undo needed; nodes not in tree yet).
var main_values: Dictionary = blueprint.get("main", {})
var process_values: Dictionary = blueprint.get("process", {})
var applied_main: Array[String] = []
var applied_process: Array[String] = []
for prop in main_values:
var prop_name := String(prop)
var prop_type := _object_property_type(node, prop_name)
if prop_type == TYPE_NIL:
continue # Silently skip: not all main keys apply to all types.
var coerce_result := ParticleValues.coerce(prop_name, main_values[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_main.append(prop_name)
# Apply process: GPU targets the ParticleProcessMaterial; CPU targets the node.
var process_target: Object = process_mat if is_gpu else node
for prop in process_values:
var prop_name := String(prop)
var prop_type := _object_property_type(process_target, prop_name)
if prop_type == TYPE_NIL:
continue # Silently skip: preset property doesn't apply to this variant.
var coerce_result := ParticleValues.coerce(prop_name, process_values[prop_name], prop_type)
if not coerce_result.ok:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
process_target.set(prop_name, coerce_result.value)
applied_process.append(prop_name)
_undo_redo.create_action("MCP: Apply preset %s" % preset_name)
_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 process_mat != null:
_undo_redo.add_do_property(node, "process_material", process_mat)
_undo_redo.add_do_reference(process_mat)
if draw_mesh != null:
_undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
_undo_redo.add_do_reference(draw_mesh)
if draw_material != null:
_undo_redo.add_do_reference(draw_material)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
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_main": applied_main,
"applied_process": applied_process,
"process_material_created": process_material_created,
"draw_pass_mesh_created": draw_pass_mesh_created,
"draw_material_created": draw_material_created,
"is_3d": is_3d,
"undoable": true,
}
}
# ============================================================================
# Helpers
# ============================================================================
static func _instantiate_particle(type_str: String) -> Node:
match type_str:
"gpu_3d":
return GPUParticles3D.new()
"gpu_2d":
return GPUParticles2D.new()
"cpu_3d":
return CPUParticles3D.new()
"cpu_2d":
return CPUParticles2D.new()
return null
func _resolve_particle(params: Dictionary) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(
params.get("node_path", ""), "node_path",
)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var node_path: String = resolved.path
var is_particle := node is GPUParticles3D or node is GPUParticles2D \
or node is CPUParticles3D or node is CPUParticles2D
if not is_particle:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a particle node (got %s)" % [node_path, node.get_class()]
)
return {"node": node, "path": node_path}
static func _node_property_type(node: Object, name: String) -> int:
return _object_property_type(node, name)
static func _object_property_type(obj: Object, name: String) -> int:
if obj == null:
return TYPE_NIL
for prop in obj.get_property_list():
if prop.name == name:
return int(prop.get("type", TYPE_NIL))
return TYPE_NIL
static func _property_names(obj: Object) -> Dictionary:
var out: Dictionary = {}
if obj == null:
return out
for prop in obj.get_property_list():
out[prop.name] = true
return out
@@ -0,0 +1 @@
uid://byfc0pnyb5qww
@@ -0,0 +1,282 @@
@tool
extends RefCounted
## Curated particle effect blueprints.
##
## Each preset returns {main, process, draw}. The handler applies them
## through the normal write path (one undo action wraps all spawns).
## Each preset has {main, process, draw}. `draw` configures the StandardMaterial3D
## attached to the auto-created QuadMesh in draw_pass_1 (GPU 3D only); if
## omitted, the handler falls back to a sensible billboard-particles default.
## `blend_mode: "add"` is what makes fire/magic/explosion glow — without
## additive blending, additively-layered particles just stack to gray.
const _PRESETS := {
"fire": {
"main": {
"amount": 80,
"lifetime": 1.2,
"one_shot": false,
"explosiveness": 0.0,
"preprocess": 0.5,
"local_coords": false,
},
"process": {
"emission_shape": "sphere",
"emission_sphere_radius": 0.3,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 15.0,
"initial_velocity_min": 2.0,
"initial_velocity_max": 4.0,
"gravity": {"x": 0.0, "y": 1.0, "z": 0.0}, # buoyancy
"scale_min": 0.4,
"scale_max": 0.8,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [1.0, 1.0, 0.9, 1.0]},
{"time": 0.3, "color": [1.0, 0.6, 0.1, 1.0]},
{"time": 0.7, "color": [0.8, 0.1, 0.05, 0.7]},
{"time": 1.0, "color": [0.2, 0.05, 0.05, 0.0]},
]
},
},
"draw": {"blend_mode": "add"},
},
"smoke": {
"main": {
"amount": 40,
"lifetime": 3.0,
"one_shot": false,
"explosiveness": 0.0,
"local_coords": false,
},
"process": {
"emission_shape": "sphere",
"emission_sphere_radius": 0.4,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 20.0,
"initial_velocity_min": 0.5,
"initial_velocity_max": 1.5,
"gravity": {"x": 0.0, "y": 0.2, "z": 0.0},
"scale_min": 0.6,
"scale_max": 1.4,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [0.3, 0.3, 0.3, 0.0]},
{"time": 0.25, "color": [0.35, 0.35, 0.35, 0.7]},
{"time": 0.75, "color": [0.2, 0.2, 0.2, 0.5]},
{"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]},
]
},
},
# Smoke uses regular alpha blending so it darkens the background.
"draw": {"blend_mode": "mix"},
},
"spark_burst": {
"main": {
"amount": 60,
"lifetime": 0.8,
"one_shot": true,
"explosiveness": 1.0,
"local_coords": false,
},
"process": {
"emission_shape": "point",
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 180.0,
"initial_velocity_min": 5.0,
"initial_velocity_max": 12.0,
"gravity": {"x": 0.0, "y": -9.8, "z": 0.0},
"scale_min": 0.05,
"scale_max": 0.12,
"color": {"r": 1.0, "g": 0.9, "b": 0.2, "a": 1.0},
},
"draw": {
"blend_mode": "add",
"emission_enabled": true,
"emission": {"r": 1.0, "g": 0.8, "b": 0.2, "a": 1.0},
"emission_energy_multiplier": 2.0,
},
},
"magic_swirl": {
"main": {
"amount": 120,
"lifetime": 2.0,
"one_shot": false,
"explosiveness": 0.0,
"local_coords": false,
},
"process": {
"emission_shape": "ring",
"emission_ring_radius": 0.8,
"emission_ring_inner_radius": 0.6,
"emission_ring_height": 0.0,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 30.0,
"initial_velocity_min": 1.0,
"initial_velocity_max": 2.0,
"gravity": {"x": 0.0, "y": 0.0, "z": 0.0},
"angular_velocity_min": 90.0,
"angular_velocity_max": 180.0,
"scale_min": 0.1,
"scale_max": 0.2,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [0.4, 0.9, 1.0, 0.0]},
{"time": 0.3, "color": [0.5, 0.7, 1.0, 1.0]},
{"time": 0.7, "color": [1.0, 0.4, 0.9, 1.0]},
{"time": 1.0, "color": [0.8, 0.2, 0.7, 0.0]},
]
},
},
"draw": {"blend_mode": "add"},
},
"rain": {
"main": {
"amount": 500,
"lifetime": 1.5,
"one_shot": false,
"explosiveness": 0.0,
"local_coords": false,
},
"process": {
"emission_shape": "box",
"emission_box_extents": {"x": 10.0, "y": 0.1, "z": 10.0},
"direction": {"x": 0.0, "y": -1.0, "z": 0.0},
"spread": 2.0,
"initial_velocity_min": 15.0,
"initial_velocity_max": 18.0,
"gravity": {"x": 0.0, "y": -2.0, "z": 0.0},
"scale_min": 0.02,
"scale_max": 0.04,
"color": {"r": 0.7, "g": 0.85, "b": 1.0, "a": 0.5},
},
# Rain drops render as streaks; fixed_y aligns them vertically.
"draw": {"billboard_mode": "fixed_y", "blend_mode": "mix"},
},
"explosion": {
"main": {
"amount": 200,
"lifetime": 1.5,
"one_shot": true,
"explosiveness": 1.0,
"local_coords": false,
},
"process": {
"emission_shape": "sphere",
"emission_sphere_radius": 0.1,
"direction": {"x": 0.0, "y": 1.0, "z": 0.0},
"spread": 180.0,
"initial_velocity_min": 6.0,
"initial_velocity_max": 10.0,
"gravity": {"x": 0.0, "y": -4.0, "z": 0.0},
"scale_min": 0.3,
"scale_max": 0.7,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [1.0, 0.95, 0.5, 1.0]},
{"time": 0.2, "color": [1.0, 0.4, 0.1, 1.0]},
{"time": 0.7, "color": [0.3, 0.15, 0.1, 0.7]},
{"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]},
]
},
},
"draw": {
"blend_mode": "add",
"emission_enabled": true,
"emission": {"r": 1.0, "g": 0.5, "b": 0.1, "a": 1.0},
"emission_energy_multiplier": 1.5,
},
},
"lightning": {
# Short, bright, electric-blue spark burst. One-shot — call
# particle_restart to re-trigger. Pairs well with a scene-wide flash.
"main": {
"amount": 40,
"lifetime": 0.35,
"one_shot": true,
"explosiveness": 1.0,
"local_coords": false,
},
"process": {
"emission_shape": "box",
"emission_box_extents": {"x": 0.1, "y": 1.5, "z": 0.1},
"direction": {"x": 0.0, "y": -1.0, "z": 0.0},
"spread": 8.0,
"initial_velocity_min": 18.0,
"initial_velocity_max": 28.0,
"gravity": {"x": 0.0, "y": 0.0, "z": 0.0},
"scale_min": 0.08,
"scale_max": 0.18,
"color_ramp": {
"stops": [
{"time": 0.0, "color": [1.0, 1.0, 1.0, 1.0]},
{"time": 0.2, "color": [0.6, 0.85, 1.0, 1.0]},
{"time": 0.6, "color": [0.3, 0.5, 1.0, 0.9]},
{"time": 1.0, "color": [0.1, 0.2, 0.7, 0.0]},
]
},
},
"draw": {
"blend_mode": "add",
"emission_enabled": true,
"emission": {"r": 0.5, "g": 0.8, "b": 1.0, "a": 1.0},
"emission_energy_multiplier": 4.0,
},
},
}
static func list() -> Array:
return _PRESETS.keys()
static func has(preset_name: String) -> bool:
return _PRESETS.has(preset_name)
## Return deep-copied {main, process, draw} blueprint with overrides merged in.
## Overrides may include top-level "main" / "process" / "draw" dicts, or bare
## keys that get routed based on which group they belong to.
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var entry: Dictionary = _PRESETS[preset_name].duplicate(true)
var main: Dictionary = entry.get("main", {})
var process: Dictionary = entry.get("process", {})
var draw: Dictionary = entry.get("draw", {})
for key in overrides:
var val = overrides[key]
if key == "main" and val is Dictionary:
for k in val:
main[k] = val[k]
elif key == "process" and val is Dictionary:
for k in val:
process[k] = val[k]
elif key == "draw" and val is Dictionary:
for k in val:
draw[k] = val[k]
elif _MAIN_KEYS.has(key):
main[key] = val
else:
process[key] = val
entry["main"] = main
entry["process"] = process
entry["draw"] = draw
return entry
const _MAIN_KEYS := {
"amount": true,
"lifetime": true,
"one_shot": true,
"explosiveness": true,
"preprocess": true,
"speed_scale": true,
"randomness": true,
"fixed_fps": true,
"emitting": true,
"local_coords": true,
"interp_to_end": true,
}
@@ -0,0 +1 @@
uid://bss2ccpmsxo4p
+228
View File
@@ -0,0 +1,228 @@
@tool
extends RefCounted
## Value coercion + gradient/curve builders for particle properties.
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
const _EMISSION_SHAPES := {
"point": ParticleProcessMaterial.EMISSION_SHAPE_POINT,
"sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE,
"sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE,
"box": ParticleProcessMaterial.EMISSION_SHAPE_BOX,
"points": ParticleProcessMaterial.EMISSION_SHAPE_POINTS,
"directed_points": ParticleProcessMaterial.EMISSION_SHAPE_DIRECTED_POINTS,
"ring": ParticleProcessMaterial.EMISSION_SHAPE_RING,
}
## Resolve a shape name to the int enum, or return null.
static func resolve_emission_shape(value: Variant) -> Variant:
if value is int:
return value
if value is float:
return int(value)
if value is String:
var key := String(value).to_lower()
if _EMISSION_SHAPES.has(key):
return _EMISSION_SHAPES[key]
return null
static func emission_shape_names() -> Array:
return _EMISSION_SHAPES.keys()
## Build a Gradient from {stops: [{time, color}]} dict.
static func build_gradient(value: Variant) -> Variant:
if value is Gradient:
return value
if value is GradientTexture1D:
return (value as GradientTexture1D).gradient
if not (value is Dictionary):
return null
var d: Dictionary = value
if not d.has("stops"):
return null
var stops_array = d.get("stops")
if not (stops_array is Array):
return null
var offsets := PackedFloat32Array()
var colors := PackedColorArray()
for stop in stops_array:
if not (stop is Dictionary):
return null
offsets.append(float(stop.get("time", 0.0)))
var c = MaterialValues.parse_color(stop.get("color"))
if c == null:
return null
colors.append(c)
var grad := Gradient.new()
grad.offsets = offsets
grad.colors = colors
return grad
## Build a GradientTexture1D wrapping a Gradient (what ParticleProcessMaterial.color_ramp wants).
static func build_gradient_texture(value: Variant) -> Variant:
if value is GradientTexture1D:
return value
var grad = build_gradient(value)
if grad == null:
return null
var tex := GradientTexture1D.new()
tex.gradient = grad
return tex
## Build a Curve from [{time, value}] or {points: [...]} (float-over-time).
static func build_curve(value: Variant) -> Variant:
if value is Curve:
return value
if value is CurveTexture:
return (value as CurveTexture).curve
var points_array: Variant = null
if value is Array:
points_array = value
elif value is Dictionary and value.has("points"):
points_array = value["points"]
if not (points_array is Array):
return null
var curve := Curve.new()
for pt in points_array:
if not (pt is Dictionary):
return null
var t := float(pt.get("time", 0.0))
var v := float(pt.get("value", 0.0))
curve.add_point(Vector2(t, v))
return curve
static func build_curve_texture(value: Variant) -> Variant:
if value is CurveTexture:
return value
var curve = build_curve(value)
if curve == null:
return null
var tex := CurveTexture.new()
tex.curve = curve
return tex
## Coerce a particle property value to the appropriate type.
## Handles: Vector3/gravity/direction, Color, float, int, bool, enum strings.
## For color_ramp returns a GradientTexture1D; for *_curve returns CurveTexture.
static func coerce(property: String, value: Variant, target_type: int) -> Dictionary:
# Special-cased properties.
if property == "emission_shape":
var shape = resolve_emission_shape(value)
if shape == null:
return {
"ok": false,
"error": "Invalid emission_shape '%s'. Valid: %s" % [
value, ", ".join(emission_shape_names())
],
}
return {"ok": true, "value": int(shape)}
if property == "color_ramp" or property == "color_initial_ramp":
var tex = build_gradient_texture(value)
if tex == null:
return {"ok": false, "error": "Invalid gradient for %s (expected {stops: [{time, color}]})" % property}
return {"ok": true, "value": tex}
if property == "color" and value is Dictionary and not (value as Dictionary).has("stops"):
# color is a single Color, not a ramp.
var c = MaterialValues.parse_color(value)
if c == null:
return {"ok": false, "error": "Invalid color"}
return {"ok": true, "value": c}
if property.ends_with("_curve"):
var tex = build_curve_texture(value)
if tex == null:
return {"ok": false, "error": "Invalid curve for %s (expected [{time, value}])" % property}
return {"ok": true, "value": tex}
# Fall through to the material coercer (handles Color/Vec3/Vec2/float/int/bool/enum).
return MaterialValues.coerce_material_value(property, value, target_type)
## Build a StandardMaterial3D suitable for GPUParticles3D draw-pass rendering.
##
## Godot's default Mesh has no material, which means ParticleProcessMaterial's
## color_ramp (which drives the COLOR varying) gets ignored and particles
## render as flat white squares that don't face the camera. A correct default
## must have vertex_color_use_as_albedo=true, billboard=particles, unshaded,
## and alpha transparency so the gradient actually modulates the pixels.
##
## Config is an optional dict that overrides individual properties. Supported
## keys match BaseMaterial3D properties (plus enum-by-name via MaterialValues):
## blend_mode: "mix" | "add" | "sub" | "mul"
## transparency: "disabled" | "alpha" | "alpha_scissor" | "alpha_hash" | "alpha_depth_pre_pass"
## shading_mode: "unshaded" | "per_pixel" | "per_vertex"
## billboard_mode: "disabled" | "enabled" | "fixed_y" | "particles"
## vertex_color_use_as_albedo: bool
## emission_enabled: bool
## emission: Color
## emission_energy_multiplier: float
## albedo_color: Color
## albedo_texture: res:// path
## (anything else accepted by BaseMaterial3D.set())
static func build_draw_material(config: Dictionary) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
# Sensible defaults for particle draw-pass rendering.
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
mat.billboard_keep_scale = true
# Configure from dict overrides.
for key in config:
var prop_name := String(key)
var prop_type := _object_property_type(mat, prop_name)
if prop_type == TYPE_NIL:
continue
var coerce_result := MaterialValues.coerce_material_value(
prop_name, config[prop_name], prop_type
)
if coerce_result.ok:
mat.set(prop_name, coerce_result.value)
return mat
static func _object_property_type(obj: Object, name: String) -> int:
if obj == null:
return TYPE_NIL
for prop in obj.get_property_list():
if prop.name == name:
return int(prop.get("type", TYPE_NIL))
return TYPE_NIL
## Serialize for response.
static func serialize(value: Variant) -> Variant:
if value == null:
return null
if value is GradientTexture1D:
var grad := (value as GradientTexture1D).gradient
if grad == null:
return {"type": "GradientTexture1D", "stops": []}
var stops: Array = []
for i in grad.offsets.size():
var c: Color = grad.colors[i]
stops.append({
"time": grad.offsets[i],
"color": {"r": c.r, "g": c.g, "b": c.b, "a": c.a},
})
return {"type": "GradientTexture1D", "stops": stops}
if value is CurveTexture:
var curve := (value as CurveTexture).curve
if curve == null:
return {"type": "CurveTexture", "points": []}
var points: Array = []
for i in curve.get_point_count():
var p := curve.get_point_position(i)
points.append({"time": p.x, "value": p.y})
return {"type": "CurveTexture", "points": points}
return MaterialValues.serialize_value(value)
@@ -0,0 +1 @@
uid://bnnnjq06dmclc
@@ -0,0 +1,337 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
## bounds. Auto-creates the concrete Shape subclass when the slot is empty
## or the requested type differs — bundling creation and sizing in a single
## undo action.
##
## Shape type defaults: Box for 3D, Rectangle for 2D.
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
const _SHAPE_3D_CLASSES := {
"box": "BoxShape3D",
"sphere": "SphereShape3D",
"capsule": "CapsuleShape3D",
"cylinder": "CylinderShape3D",
}
const _SHAPE_2D_CLASSES := {
"rectangle": "RectangleShape2D",
"circle": "CircleShape2D",
"capsule": "CapsuleShape2D",
}
func autofit(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
var is_3d := node is CollisionShape3D
var is_2d := node is CollisionShape2D
if not (is_3d or is_2d):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is %s — must be CollisionShape3D or CollisionShape2D" % [node_path, node.get_class()]
)
var source_path: String = params.get("source_path", "")
var source: Node = null
if source_path.is_empty():
var search := _find_bounds_visual(node, is_3d, scene_root)
if search.has("error"):
return search.error
source = search.source
else:
source = McpScenePath.resolve(source_path, scene_root)
if source == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Source node not found: %s" % source_path)
var shape_type: String = params.get("shape_type", "box" if is_3d else "rectangle")
var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES
# Accept either the short form ("box") or the matching Godot class name
# ("BoxShape3D") — every other tool in the server takes class names, and
# resource_get_info(type="Shape3D") surfaces concrete_subclasses by class.
if not type_map.has(shape_type):
for short_form in type_map:
if type_map[short_form] == shape_type:
shape_type = short_form
break
if not type_map.has(shape_type):
var valid_pairs: Array[String] = []
for short_form in type_map:
valid_pairs.append("%s (%s)" % [short_form, type_map[short_form]])
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid shape_type '%s' for %s. Valid: %s" % [shape_type, node.get_class(), ", ".join(valid_pairs)]
)
var shape_class: String = type_map[shape_type]
# Measure the visual.
var bounds := _measure_bounds(source, is_3d)
if bounds.has("error"):
return bounds.error
# Reuse the existing shape if it already matches the requested class;
# otherwise create a fresh one of the right type in the same undo action.
var existing_shape: Shape3D = null
var existing_shape_2d: Shape2D = null
if is_3d:
existing_shape = node.shape
else:
existing_shape_2d = node.shape
var needs_new_shape := false
if is_3d:
needs_new_shape = existing_shape == null or existing_shape.get_class() != shape_class
else:
needs_new_shape = existing_shape_2d == null or existing_shape_2d.get_class() != shape_class
var target_shape: Resource
if needs_new_shape:
var instance := ClassDB.instantiate(shape_class)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class)
target_shape = instance
else:
target_shape = existing_shape if is_3d else existing_shape_2d
# Compute and apply size.
var size_info := _apply_shape_size(target_shape, shape_type, bounds, is_3d)
var old_shape = existing_shape if is_3d else existing_shape_2d
_undo_redo.create_action("MCP: Autofit %s on %s" % [shape_class, node.name])
if needs_new_shape:
_undo_redo.add_do_property(node, "shape", target_shape)
_undo_redo.add_undo_property(node, "shape", old_shape)
_undo_redo.add_do_reference(target_shape)
else:
# Existing shape stays, but its size changes — snapshot size for undo.
for key in size_info.applied:
var new_val = target_shape.get(key)
var old_val = size_info.previous.get(key)
_undo_redo.add_do_property(target_shape, key, new_val)
_undo_redo.add_undo_property(target_shape, key, old_val)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"source_path": McpScenePath.from_node(source, scene_root) if source_path.is_empty() else source_path,
"shape_type": shape_type,
"shape_class": shape_class,
"shape_created": needs_new_shape,
"size": size_info.size_response,
"undoable": true,
}
}
## Returns `{source: Node}` on success, `{error: <error dict>}` on failure.
## Ambiguous tier-2 matches put candidate scene paths in
## `error.data.candidates` so callers can pick one explicitly.
static func _find_bounds_visual(collision_node: Node, is_3d: bool, scene_root: Node) -> Dictionary:
var parent := collision_node.get_parent()
if parent == null:
return {"error": _no_visual_error(is_3d)}
# Tier 1: direct siblings of the collision shape. Uses the broad
# VisualInstance3D filter for backwards compatibility — callers who put
# the visual directly next to the collision picked it on purpose.
var siblings := _measurable_visuals(parent.get_children(), collision_node, is_3d, false)
if not siblings.is_empty():
return {"source": siblings[0]}
# Tier 2: parent siblings (uncles). Tighten the filter to
# GeometryInstance3D so we don't auto-pick a Light3D / DirectionalLight3D
# as a collision source. Auto-pick only when unambiguous; surface
# multiple candidates so the agent chooses.
var grandparent := parent.get_parent()
if grandparent == null:
return {"error": _no_visual_error(is_3d)}
var uncles := _measurable_visuals(grandparent.get_children(), parent, is_3d, true)
if uncles.size() == 1:
return {"source": uncles[0]}
if uncles.size() > 1:
var paths: Array[String] = []
for n in uncles:
paths.append(McpScenePath.from_node(n, scene_root))
var msg := "Multiple visual candidates near %s — pass source_path explicitly. Candidates: %s" % [
McpScenePath.from_node(collision_node, scene_root),
", ".join(paths),
]
var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
err["error"]["data"] = {"candidates": paths}
return {"error": err}
return {"error": _no_visual_error(is_3d)}
## Filter `nodes` for ones we can measure as a collision source. When
## `strict` is true (tier 2 / uncles) only GeometryInstance3D counts in 3D —
## avoids picking up lights as accidental sources. 2D filter is already
## narrow enough that strictness doesn't change behavior.
static func _measurable_visuals(nodes: Array, exclude: Node, is_3d: bool, strict: bool) -> Array[Node]:
var out: Array[Node] = []
for n in nodes:
if n == exclude:
continue
if is_3d:
if strict:
if n is GeometryInstance3D:
out.append(n)
elif n is VisualInstance3D:
out.append(n)
elif n is Sprite2D or n is TextureRect:
out.append(n)
return out
static func _no_visual_error(is_3d: bool) -> Dictionary:
var hint := "MeshInstance3D" if is_3d else "Sprite2D"
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"No visual found near collision shape — searched siblings and parent-siblings. Pass source_path explicitly (e.g. a %s)" % hint,
)
## Measure the visual bounds of `source`. Returns {aabb: AABB} for 3D or
## {rect: Rect2} for 2D on success, or {error: ...} on failure.
## Bounds are returned in world-ish size (local extents scaled by the source
## node's own transform scale) so a MeshInstance3D at scale=(2,2,2) gives an
## 8× volume collider, not a unit collider.
static func _measure_bounds(source: Node, is_3d: bool) -> Dictionary:
if is_3d:
if source is VisualInstance3D:
var aabb: AABB = (source as VisualInstance3D).get_aabb()
# get_aabb() is local-space; pre-multiply by the source's scale
# so the collider tracks what you actually see in the viewport.
var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale()
aabb.position = aabb.position * scale_3d
aabb.size = aabb.size * scale_3d
return {"aabb": aabb}
return {"error": ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %s has no measurable 3D bounds (must be VisualInstance3D subclass)" % source.get_class()
)}
# 2D
if source is Sprite2D:
var s: Sprite2D = source
var srect: Rect2 = s.get_rect()
# get_rect() reports the local texture rect and ignores scale.
srect.position = srect.position * s.scale
srect.size = srect.size * s.scale
return {"rect": srect}
if source is TextureRect:
var tr: TextureRect = source
# tr.size is the Control's laid-out size, which is Vector2.ZERO
# before the first layout pass (e.g. just after the node was created
# via MCP). Fall back to the texture's own size when that happens,
# so autofit doesn't silently produce a zero-sized shape.
var tr_size: Vector2 = tr.size
if tr_size.is_zero_approx():
if tr.texture != null:
tr_size = tr.texture.get_size() * tr.scale
else:
return {"error": ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"TextureRect at %s has zero layout size and no texture to fall back to — autofit would produce a zero-sized shape" % source.name
)}
return {"rect": Rect2(Vector2.ZERO, tr_size)}
return {"error": ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %s has no measurable 2D bounds (must be Sprite2D or TextureRect)" % source.get_class()
)}
## Apply size to `shape` based on `bounds` and the requested shape_type.
## Returns {applied: [property_names], previous: {name: old_value}, size_response: dict}.
static func _apply_shape_size(shape: Resource, shape_type: String, bounds: Dictionary, is_3d: bool) -> Dictionary:
var applied: Array[String] = []
var previous := {}
var size_response := {}
if is_3d:
var aabb: AABB = bounds.aabb
var size_v: Vector3 = aabb.size
match shape_type:
"box":
previous["size"] = shape.get("size")
(shape as BoxShape3D).size = size_v
applied.append("size")
size_response = {"x": size_v.x, "y": size_v.y, "z": size_v.z}
"sphere":
var r := maxf(maxf(size_v.x, size_v.y), size_v.z) * 0.5
previous["radius"] = shape.get("radius")
(shape as SphereShape3D).radius = r
applied.append("radius")
size_response = {"radius": r}
"capsule":
var cap := shape as CapsuleShape3D
var r2 := maxf(size_v.x, size_v.z) * 0.5
var h := size_v.y
previous["radius"] = cap.radius
previous["height"] = cap.height
# CapsuleShape3D enforces height >= 2*radius and silently
# clamps setters that would violate it. Read back the
# stored values so the response reflects reality.
cap.radius = r2
cap.height = h
applied.append("radius")
applied.append("height")
size_response = {"radius": cap.radius, "height": cap.height}
"cylinder":
var cyl := shape as CylinderShape3D
var r3 := maxf(size_v.x, size_v.z) * 0.5
var ch := size_v.y
previous["radius"] = cyl.radius
previous["height"] = cyl.height
cyl.radius = r3
cyl.height = ch
applied.append("radius")
applied.append("height")
size_response = {"radius": cyl.radius, "height": cyl.height}
else:
var rect: Rect2 = bounds.rect
var sz: Vector2 = rect.size
match shape_type:
"rectangle":
previous["size"] = shape.get("size")
(shape as RectangleShape2D).size = sz
applied.append("size")
size_response = {"x": sz.x, "y": sz.y}
"circle":
var cr := maxf(sz.x, sz.y) * 0.5
previous["radius"] = shape.get("radius")
(shape as CircleShape2D).radius = cr
applied.append("radius")
size_response = {"radius": cr}
"capsule":
var cap2 := shape as CapsuleShape2D
var cr2 := sz.x * 0.5
var ch2 := sz.y
previous["radius"] = cap2.radius
previous["height"] = cap2.height
# CapsuleShape2D has the same height >= 2*radius invariant
# as its 3D counterpart; read back what Godot actually kept.
cap2.radius = cr2
cap2.height = ch2
applied.append("radius")
applied.append("height")
size_response = {"radius": cap2.radius, "height": cap2.height}
return {"applied": applied, "previous": previous, "size_response": size_response}
@@ -0,0 +1 @@
uid://cdg8kthqla1cj
+262
View File
@@ -0,0 +1,262 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles project settings and filesystem search commands.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
var _connection: McpConnection
var _debugger_plugin
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
func get_project_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
if not ProjectSettings.has_setting(key):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key)
var value = ProjectSettings.get_setting(key)
return {
"data": {
"key": key,
"value": NodeHandler._serialize_value(value),
"type": type_string(typeof(value)),
}
}
func set_project_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
if not params.has("value"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var value = params.get("value")
var had_setting := ProjectSettings.has_setting(key)
var old_value = ProjectSettings.get_setting(key) if had_setting else null
# JSON has no distinct int type: Godot parses `1920` as float. If the
# existing setting is TYPE_INT, coerce whole-number floats back to int so
# we don't silently flip typed-int settings (viewport_width, etc.) to
# floats on disk. See issue #31.
if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value:
value = int(value)
ProjectSettings.set_setting(key, value)
var err := ProjectSettings.save()
if err != OK:
if had_setting:
ProjectSettings.set_setting(key, old_value)
else:
ProjectSettings.clear(key)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err)
return {
"data": {
"key": key,
"value": NodeHandler._serialize_value(value),
"old_value": NodeHandler._serialize_value(old_value),
"type": type_string(typeof(value)),
"undoable": false,
"reason": "ProjectSettings changes are saved to disk",
}
}
func run_project(params: Dictionary) -> Dictionary:
var mode: String = params.get("mode", "main")
var autosave: bool = params.get("autosave", true)
# Idempotent: a project that's already running satisfies the caller's intent.
# Returning INVALID_PARAMS here punished agents that legitimately called run
# to ensure the project is playing (87+ installs/day hit the matching
# stop-not-running case in telemetry). Surface state via was_already_running
# so a caller wanting a *different* scene can detect and stop+restart.
if EditorInterface.is_playing_scene():
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": true,
"undoable": false,
"reason": "Project was already running; no action taken",
}
}
var validation_error: Variant = null
if mode == "custom":
var custom_scene: String = params.get("scene", "")
if custom_scene.is_empty():
validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')")
elif mode != "main" and mode != "current":
validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode)
if validation_error != null:
return validation_error
# play_*_scene internally triggers try_autosave() → _save_scene_with_preview()
# which renders a preview thumbnail and calls frame processing. If our
# WebSocket connection's _process() re-enters during that render, the
# engine crashes (SIGABRT in _save_scene_with_preview). Pause processing
# around the play call — same pattern as SceneHandler.save_scene.
if _connection:
_connection.pause_processing = true
# try_autosave() reads run/auto_save/save_before_running every call, so
# toggling it off around the play call suppresses the save without
# touching the user's persisted preference. Issue #81.
var autosave_key := "run/auto_save/save_before_running"
var editor_settings: EditorSettings = null
if not autosave:
editor_settings = EditorInterface.get_editor_settings()
var prior_autosave: bool = true
var restore_setting := false
if editor_settings != null and editor_settings.has_setting(autosave_key):
prior_autosave = bool(editor_settings.get_setting(autosave_key))
editor_settings.set_setting(autosave_key, false)
restore_setting = true
if _debugger_plugin != null:
_debugger_plugin.begin_game_run()
match mode:
"main":
EditorInterface.play_main_scene()
"current":
EditorInterface.play_current_scene()
"custom":
var scene_path: String = params.get("scene", "")
EditorInterface.play_custom_scene(scene_path)
if restore_setting:
editor_settings.set_setting(autosave_key, prior_autosave)
if _connection:
_connection.pause_processing = false
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": false,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
}
func stop_project(params: Dictionary) -> Dictionary:
# Idempotent: a project that's already stopped satisfies the caller's intent.
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
# project_manage failures (87 installs/24h). was_running=false lets callers
# distinguish a no-op stop from one that actually halted a running session.
if not EditorInterface.is_playing_scene():
return {
"data": {
"stopped": true,
"was_running": false,
"undoable": false,
"reason": "Project was not running; no action taken",
}
}
if _debugger_plugin != null:
_debugger_plugin.end_game_run()
EditorInterface.stop_playing_scene()
# stop_playing_scene() is async — is_playing_scene() only flips to false on
# the next frame, and readiness_changed follows in _process. Defer the
# response so we can reply with authoritative readiness instead of letting
# the server poll for the event. Issue #29.
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
_finish_stop_project_deferred(request_id)
return McpDispatcher.DEFERRED_RESPONSE
# Fallback for contexts without a connection (e.g. batch_execute via
# dispatch_direct, or unit tests that instantiate the handler with null).
return {
"data": {
"stopped": true,
"was_running": true,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
}
func _finish_stop_project_deferred(request_id: String) -> void:
# Wait two frames so Godot can tick the stop-play state change. After this
# is_playing_scene() reflects truth and get_readiness() is authoritative.
# If the plugin tears down (_exit_tree frees _connection) during the await,
# is_instance_valid() goes false and we drop the response silently — the
# server's 5s request timeout will surface the failure to the caller.
var tree := _connection.get_tree()
await tree.process_frame
await tree.process_frame
if not is_instance_valid(_connection):
return
_connection.send_deferred_response(request_id, {
"data": {
"stopped": true,
"was_running": true,
"undoable": false,
"reason": "Play/stop is a runtime action",
"readiness_after": McpConnection.get_readiness(),
}
})
func search_filesystem(params: Dictionary) -> Dictionary:
var name_filter: String = params.get("name", "")
var type_filter: String = params.get("type", "")
var path_filter: String = params.get("path", "")
if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required")
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var results: Array[Dictionary] = []
_scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results)
return {"data": {"files": results, "count": results.size()}}
func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
var file_type := dir.get_file_type(i)
var matches := true
if not name_filter.is_empty():
if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1:
matches = false
if matches and not type_filter.is_empty():
if file_type != type_filter:
matches = false
if matches and not path_filter.is_empty():
if file_path.to_lower().find(path_filter.to_lower()) == -1:
matches = false
if matches:
out.append({
"path": file_path,
"type": file_type,
})
for i in dir.get_subdir_count():
_scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out)
@@ -0,0 +1 @@
uid://brf8u32hvha68
@@ -0,0 +1,398 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
## Handles resource search, inspection, and assignment to nodes.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
func search_resources(params: Dictionary) -> Dictionary:
var type_filter: String = params.get("type", "")
var path_filter: String = params.get("path", "")
if type_filter.is_empty() and path_filter.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (type, path) is required")
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var results: Array[Dictionary] = []
_scan_resources(efs.get_filesystem(), type_filter, path_filter, results)
return {"data": {"resources": results, "count": results.size()}}
func _scan_resources(dir: EditorFileSystemDirectory, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
var file_type := dir.get_file_type(i)
var matches := true
if not type_filter.is_empty():
# Check if the file type matches or is a subclass of the requested type
if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter):
matches = false
if matches and not path_filter.is_empty():
if file_path.to_lower().find(path_filter.to_lower()) == -1:
matches = false
if matches:
out.append({
"path": file_path,
"type": file_type,
})
for i in dir.get_subdir_count():
_scan_resources(dir.get_subdir(i), type_filter, path_filter, out)
func load_resource(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.loadable_error(path, "path")
if path_err != null:
return path_err
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % path)
var res: Resource = load(path)
if res == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % path)
var properties: Array[Dictionary] = []
for prop in res.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var value = res.get(prop.name)
if value == null and prop.type != TYPE_NIL:
continue
properties.append({
"name": prop.name,
"type": type_string(prop.type),
"value": NodeHandler._serialize_value(value),
})
return {
"data": {
"path": path,
"type": res.get_class(),
"properties": properties,
"property_count": properties.size(),
}
}
func assign_resource(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if property.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property")
if resource_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: resource_path")
var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
if rpath_err != null:
return rpath_err
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
# Verify property exists
var found := false
for prop in node.get_property_list():
if prop.name == property:
found = true
break
if not found:
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
if not ResourceLoader.exists(resource_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
var res: Resource = load(resource_path)
if res == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % resource_path)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Assign %s to %s.%s" % [resource_path.get_file(), node.name, property])
_undo_redo.add_do_property(node, property, res)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"property": property,
"resource_path": resource_path,
"resource_type": res.get_class(),
"undoable": true,
}
}
## Instantiate a built-in Resource subclass, optionally apply `properties`,
## and either assign it to a node slot (undoable) or save it to a .tres file
## (not undoable — mirrors material_create). Exactly one home is required;
## a resource with no home would be GC'd after the handler returns.
func create_resource(params: Dictionary) -> Dictionary:
var type_str: String = params.get("type", "")
if type_str.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
var properties: Dictionary = params.get("properties", {})
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
var overwrite: bool = params.get("overwrite", false)
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var has_file_target := not resource_path.is_empty()
var class_err := _validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
if not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
)
var res: Resource = instance
if not properties.is_empty():
var apply_err := _apply_resource_properties(res, properties)
if apply_err != null:
return apply_err
if has_file_target:
return _save_created_resource(res, type_str, resource_path, overwrite, properties.size())
return _assign_created_resource(res, type_str, node_path, property, properties.size())
## Validate that `type_str` names a concrete Resource subclass that we can
## instantiate. Returns an error dict on failure, or null on success.
static func _validate_resource_class(type_str: String) -> Variant:
if not ClassDB.class_exists(type_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is a Node type, not a Resource — use node_create instead" % type_str
)
if not ClassDB.is_parent_class(type_str, "Resource"):
var parent := ClassDB.get_parent_class(type_str)
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is not a Resource type (extends %s)" % [type_str, parent]
)
if not ClassDB.can_instantiate(type_str):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is abstract and cannot be instantiated — use a concrete subclass (e.g. BoxMesh, BoxShape3D, StyleBoxFlat)" % type_str
)
return null
## Apply a dict of property values to a freshly-instantiated Resource,
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
## Returns null on success or an error dict on failure.
static func _apply_resource_properties(res: Resource, properties: Dictionary) -> Variant:
var prop_types := {}
for prop in res.get_property_list():
prop_types[prop.name] = prop.get("type", TYPE_NIL)
for key in properties.keys():
if not prop_types.has(key):
var valid: Array[String] = []
for prop in res.get_property_list():
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
valid.append(prop.name)
valid.sort()
var err := ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
)
err["error"]["data"] = {"valid_properties": valid}
return err
var target_type: int = prop_types[key]
if target_type == TYPE_NIL:
target_type = typeof(res.get(key))
var v = properties[key]
if target_type == TYPE_OBJECT and v is String:
if v == "":
v = null
else:
var vpath_err = McpPathValidator.loadable_error(v, "property '%s'" % key)
if vpath_err != null:
return vpath_err
var loaded := ResourceLoader.load(v)
if loaded == null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Resource not found at path '%s' for property '%s'" % [v, key]
)
v = loaded
elif target_type == TYPE_OBJECT and v is Dictionary and v.has("__class__"):
# Nested shortcut: the same {"__class__": "X", ...} form that
# node_handler.set_property accepts, now also supported here so
# resource_create/environment_create callers can populate
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
var sub_type: String = v.get("__class__", "")
var class_err := _validate_resource_class(sub_type)
if class_err != null:
return class_err
var sub_instance := ClassDB.instantiate(sub_type)
if sub_instance == null or not (sub_instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
)
var sub_res: Resource = sub_instance
var remaining: Dictionary = (v as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
var nested_err := _apply_resource_properties(sub_res, remaining)
if nested_err != null:
return nested_err
v = sub_res
else:
v = NodeHandler._coerce_value(v, target_type)
## Mirror set_property's coerce check: wrong-shape dicts (#123) and
## non-dict inputs that don't land as the target compound Variant
## (#191) both error here instead of writing zero-filled Variants.
var coerce_err := NodeHandler._check_coerced(v, target_type, "Property '%s'" % key)
if coerce_err != null:
return coerce_err
res.set(key, v)
return null
func _assign_created_resource(res: Resource, type_str: String, node_path: String, property: String, applied_count: int) -> Dictionary:
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var found := false
var prop_type: int = TYPE_NIL
for prop in node.get_property_list():
if prop.name == property:
found = true
prop_type = prop.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s" % [property, node.get_class()]
)
if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' on %s is not an Object slot (type %s)" % [property, node.get_class(), type_string(prop_type)]
)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Create %s for %s.%s" % [type_str, node.name, property])
_undo_redo.add_do_property(node, property, res)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.add_do_reference(res)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"property": property,
"type": type_str,
"resource_class": res.get_class(),
"properties_applied": applied_count,
"undoable": true,
}
}
func _save_created_resource(res: Resource, type_str: String, resource_path: String, overwrite: bool, applied_count: int) -> Dictionary:
return McpResourceIO.save_to_disk(res, resource_path, overwrite, "Resource", {
"type": type_str,
"resource_class": res.get_class(),
"properties_applied": applied_count,
}, _connection)
## Introspect a Resource class — return its editor-visible properties, parent,
## whether it's abstract, and (for abstract bases) the list of concrete
## subclasses that resource_create can instantiate. Read-only.
func get_resource_info(params: Dictionary) -> Dictionary:
var type_str: String = params.get("type", "")
if type_str.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
if not ClassDB.class_exists(type_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is a Node type, not a Resource — use node_* tools for node introspection" % type_str
)
if not ClassDB.is_parent_class(type_str, "Resource") and type_str != "Resource":
var parent := ClassDB.get_parent_class(type_str)
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"%s is not a Resource type (extends %s)" % [type_str, parent]
)
var can_instantiate: bool = ClassDB.can_instantiate(type_str)
var class_info := ClassIntrospection.build(type_str, {
"sections": ["properties"],
"include_inherited": true,
"include_inheritors": not can_instantiate,
"limit": 0,
})
var data: Dictionary = {
"type": type_str,
"parent_class": class_info.parent_class,
"can_instantiate": can_instantiate,
"is_abstract": not can_instantiate,
"properties": class_info.properties,
"property_count": class_info.property_count,
}
# For abstract bases (Shape3D, Material, Texture, StyleBox, ...) surface
# the concrete Resource subclasses an agent could try next.
if not can_instantiate:
data["concrete_subclasses"] = class_info.concrete_inheritors
return {"data": data}
@@ -0,0 +1 @@
uid://dwwd0n3c56ir
+267
View File
@@ -0,0 +1,267 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles scene tree reading and node search.
var _connection: McpConnection
var _save_scene_callable: Callable = Callable()
var _save_scene_as_callable: Callable = Callable()
func _init(connection: McpConnection = null) -> void:
_connection = connection
func get_scene_tree(params: Dictionary) -> Dictionary:
var max_depth: int = params.get("depth", 10)
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return {"data": {"nodes": [], "message": "No scene open"}}
var nodes: Array[Dictionary] = []
_walk_tree(scene_root, nodes, 0, max_depth, scene_root)
return {"data": {"nodes": nodes, "total_count": nodes.size()}}
func get_open_scenes(_params: Dictionary) -> Dictionary:
var scene_paths := EditorInterface.get_open_scenes()
var scene_root := EditorInterface.get_edited_scene_root()
var current := scene_root.scene_file_path if scene_root else ""
return {
"data": {
"scenes": scene_paths,
"current_scene": current,
"count": scene_paths.size(),
}
}
func find_nodes(params: Dictionary) -> Dictionary:
var name_filter: String = params.get("name", "")
var type_filter: String = params.get("type", "")
var group_filter: String = params.get("group", "")
if name_filter.is_empty() and type_filter.is_empty() and group_filter.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, group) is required")
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 results: Array[Dictionary] = []
_find_recursive(scene_root, scene_root, name_filter, type_filter, group_filter, results)
return {"data": {"nodes": results, "count": results.size()}}
func _find_recursive(node: Node, scene_root: Node, name_filter: String, type_filter: String, group_filter: String, out: Array[Dictionary]) -> void:
var matches := true
if not name_filter.is_empty():
if node.name.to_lower().find(name_filter.to_lower()) == -1:
matches = false
if matches and not type_filter.is_empty():
if node.get_class() != type_filter:
matches = false
if matches and not group_filter.is_empty():
if not node.is_in_group(group_filter):
matches = false
if matches:
out.append({
"name": node.name,
"type": node.get_class(),
"path": McpScenePath.from_node(node, scene_root),
})
for child in node.get_children():
_find_recursive(child, scene_root, name_filter, type_filter, group_filter, out)
## Create a new scene with the given root node type, save to disk, and open it.
func create_scene(params: Dictionary) -> Dictionary:
var root_type: String = params.get("root_type", "Node3D")
var path: String = params.get("path", "")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
path += ".tscn"
if not ClassDB.class_exists(root_type):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % root_type)
if not ClassDB.is_parent_class(root_type, "Node"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % root_type)
# Ensure parent directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var root: Node = ClassDB.instantiate(root_type)
if root == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % root_type)
var root_name: String = params.get("root_name", "")
if root_name.is_empty():
root_name = path.get_file().get_basename()
root.name = root_name
var packed := PackedScene.new()
packed.pack(root)
root.free()
if _connection:
_connection.pause_processing = true
var err := ResourceSaver.save(packed, path)
EditorInterface.open_scene_from_path(path)
if _connection:
_connection.pause_processing = false
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err))
return {
"data": {
"path": path,
"root_type": root_type,
"root_name": root_name,
"undoable": false,
"reason": "Scene creation involves file system operations",
}
}
## Open an existing scene by file path.
func open_scene(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.loadable_error(path, "path")
if path_err != null:
return path_err
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
EditorInterface.open_scene_from_path(path)
return {
"data": {
"path": path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
}
## Save the currently edited scene.
## Pauses WebSocket processing during save to prevent re-entrant _process()
## calls during EditorNode::_save_scene_with_preview's thumbnail render.
func save_scene(_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 path := scene_root.scene_file_path
if path.is_empty():
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Current scene has never been saved; call scene_manage(op='save_as') with a res://... path ending in .tscn or .scn."
)
if _connection:
_connection.pause_processing = true
var err := _save_current_scene()
if _connection:
_connection.pause_processing = false
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err))
return {
"data": {
"path": path,
"undoable": false,
"reason": "File save cannot be undone via editor undo",
}
}
## Save the currently edited scene to a new file path.
func save_scene_as(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
path += ".tscn"
var _scene_check := McpNodeValidator.require_scene_or_error()
if _scene_check.has("error"):
return _scene_check
var scene_root: Node = _scene_check.scene_root
# Ensure parent directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
if _connection:
_connection.pause_processing = true
_save_current_scene_as(path)
if _connection:
_connection.pause_processing = false
return {
"data": {
"path": path,
"undoable": false,
"reason": "File save cannot be undone via editor undo",
}
}
func _save_current_scene() -> int:
if _save_scene_callable.is_valid():
return int(_save_scene_callable.call())
return EditorInterface.save_scene()
func _save_current_scene_as(path: String) -> void:
if _save_scene_as_callable.is_valid():
_save_scene_as_callable.call(path)
return
EditorInterface.save_scene_as(path)
func _walk_tree(node: Node, out: Array[Dictionary], depth: int, max_depth: int, scene_root: Node) -> void:
if depth > max_depth:
return
out.append({
"name": node.name,
"type": node.get_class(),
"path": McpScenePath.from_node(node, scene_root),
"children_count": node.get_child_count(),
})
for child in node.get_children():
_walk_tree(child, out, depth + 1, max_depth, scene_root)
@@ -0,0 +1 @@
uid://7ms40gm6t2r4
+398
View File
@@ -0,0 +1,398 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
# Bounded settle window for `ResourceLoader.exists(path)` after `scan()` so
# that an agent calling create_script -> attach_script back-to-back doesn't
# race the editor's import pipeline (#261). Polled once per frame, with an
# elapsed-time cap below the dispatcher's create_script deferred timeout. If
# import is still not visible at the cap, we still return committed=true
# instead of letting the already-written file surface as DEFERRED_TIMEOUT.
const _IMPORT_SETTLE_MAX_FRAMES := 300
const _IMPORT_SETTLE_MAX_MSEC := 3500
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
func create_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var content: String = params.get("content", "")
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not path.ends_with(".gd"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd")
# Ensure parent directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
var existed_before := FileAccess.file_exists(path)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
file.store_string(content)
file.close()
# Register just this file with the editor instead of a full recursive
# scan(). A scan() per write stacks `update_scripts_classes` /
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
# script creation ("Task ... already exists" / "!tasks.has(p_task)"), which
# races the global-class registry and can SIGABRT in
# ScriptServer::remove_global_class_by_path (see dsarno/godot#6).
# update_file() is the single-file path the rest of the plugin already uses.
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
var data := {
"path": path,
"size": content.length(),
"committed": true,
"import_settled": existed_before,
"import_settle": "already_known" if existed_before else "not_waited",
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
# can rm the full set in one go.
McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"])
# scan() is async — ResourceLoader.exists(path) returns false until Godot's
# filesystem pipeline finishes. If we reply now, an immediate attach_script
# races and 404s (#261). Defer the response until the resource is visible
# (or a bounded timeout elapses). For freshly-created files we wait; on
# overwrite the resource was already known to ResourceLoader, so reply now.
var request_id: String = params.get("_request_id", "")
if not existed_before and _connection != null and not request_id.is_empty():
_finish_create_script_deferred(_connection, request_id, path, data)
return McpDispatcher.DEFERRED_RESPONSE
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
# (no connection) get the immediate reply that the previous behaviour gave.
return {"data": data}
# `static` is load-bearing: the deferred completion captures no `self`, so the
# coroutine survives even if the ScriptHandler RefCounted is freed mid-await.
# Under concurrent script_create storms with editor_reload_plugin fired during
# the burst, the handler instance is otherwise GC'd between `await` and resume,
# producing "Resumed function '_finish_create_script_deferred()' after await,
# but class instance is gone" errors and dropping the response. Keep this
# function static and parameterise everything it needs explicitly — do not
# reference instance state.
static func _finish_create_script_deferred(
connection: McpConnection,
request_id: String,
path: String,
data: Dictionary,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
var deadline_ms := Time.get_ticks_msec() + _IMPORT_SETTLE_MAX_MSEC
# Let _dispatch() return DEFERRED_RESPONSE and register the request before
# this coroutine can send a committed result. ResourceLoader.exists(path)
# may already be true on fast imports; without this handoff the connection
# treats the response as late/unregistered and drops it, then the dispatcher
# times out a file that was already written (#324). The deadline starts
# before this await so a slow handoff frame is counted against the bounded
# settle window.
await tree.process_frame
var frames := 0
while (
frames < _IMPORT_SETTLE_MAX_FRAMES
and Time.get_ticks_msec() < deadline_ms
and not ResourceLoader.exists(path)
):
await tree.process_frame
frames += 1
# If the plugin tears down (_exit_tree frees the connection) during the
# await, is_instance_valid() goes false and we drop the response silently —
# the server's request timeout will surface the failure to the caller.
if not is_instance_valid(connection):
return
var payload := data.duplicate()
var settled := ResourceLoader.exists(path)
payload["import_settled"] = settled
payload["import_settle"] = "settled" if settled else "timeout"
payload["import_pending"] = not settled
connection.send_deferred_response(request_id, {"data": payload})
func read_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
var content := file.get_as_text()
file.close()
return {
"data": {
"path": path,
"content": content,
"size": content.length(),
"line_count": content.count("\n") + (1 if not content.is_empty() else 0),
}
}
func patch_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var old_text: String = params.get("old_text", "")
var new_text: String = params.get("new_text", "")
var replace_all: bool = params.get("replace_all", false)
var path_err = McpPathValidator.path_error(path, "path", true)
if path_err != null:
return path_err
if not "old_text" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
if not "new_text" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
if not path.ends_with(".gd"):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)")
if old_text.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
var read := FileAccess.open(path, FileAccess.READ)
if read == null:
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path)
var content := read.get_as_text()
read.close()
var match_count := content.count(old_text)
if match_count == 0:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path)
if match_count > 1 and not replace_all:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"old_text matches %d times; pass replace_all=true or provide a more specific snippet" % match_count,
)
var new_content: String
var replacements: int
if replace_all:
new_content = content.replace(old_text, new_text)
replacements = match_count
else:
var idx := content.find(old_text)
new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length())
replacements = 1
var write := FileAccess.open(path, FileAccess.WRITE)
if write == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
write.store_string(new_content)
write.close()
# Single-file register, not a full scan() — see create_script (dsarno/godot#6).
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {
"data": {
"path": path,
"replacements": replacements,
"size": new_content.length(),
"old_size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
}
func attach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var script_path: String = params.get("script_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if script_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: script_path")
var spath_err = McpPathValidator.loadable_error(script_path, "script_path")
if spath_err != null:
return spath_err
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
if not ResourceLoader.exists(script_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path)
var script: Script = load(script_path)
if script == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path)
var old_script: Script = node.get_script()
_undo_redo.create_action("MCP: Attach script to %s" % node.name)
_undo_redo.add_do_method(node, "set_script", script)
_undo_redo.add_undo_method(node, "set_script", old_script)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"script_path": script_path,
"had_previous_script": old_script != null,
"undoable": true,
}
}
func detach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var old_script: Script = node.get_script()
if old_script == null:
return {"data": {"path": node_path, "had_script": false, "undoable": false, "reason": "No script attached"}}
_undo_redo.create_action("MCP: Detach script from %s" % node.name)
_undo_redo.add_do_method(node, "set_script", null)
_undo_redo.add_undo_method(node, "set_script", old_script)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"removed_script": old_script.resource_path if old_script.resource_path else "(inline)",
"undoable": true,
}
}
func find_symbols(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var path_err = McpPathValidator.path_error(path, "path")
if path_err != null:
return path_err
if not FileAccess.file_exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
var content := file.get_as_text()
file.close()
var functions: Array[Dictionary] = []
var signals_list: Array[String] = []
var exports: Array[Dictionary] = []
var class_name_str := ""
var extends_str := ""
var lines := content.split("\n")
for i in lines.size():
var line := lines[i].strip_edges()
# class_name
if line.begins_with("class_name "):
class_name_str = line.substr(11).strip_edges()
# extends
if line.begins_with("extends "):
extends_str = line.substr(8).strip_edges()
# signal
if line.begins_with("signal "):
var sig_text := line.substr(7).strip_edges()
# Strip any parameters for the name
var paren_idx := sig_text.find("(")
if paren_idx >= 0:
signals_list.append(sig_text.substr(0, paren_idx).strip_edges())
else:
signals_list.append(sig_text)
# func (including `static func` — strip the leading `static ` first)
var func_line := line.substr(7).strip_edges() if line.begins_with("static func ") else line
if func_line.begins_with("func "):
var func_text := func_line.substr(5).strip_edges()
var paren_idx := func_text.find("(")
if paren_idx >= 0:
functions.append({
"name": func_text.substr(0, paren_idx).strip_edges(),
"line": i + 1,
})
# @export
if line.begins_with("@export"):
# Next non-empty line should have the var declaration
# But often export and var are on the same logical flow
# Try to find "var" on the same line or the next line
var var_line := line
if var_line.find("var ") == -1 and i + 1 < lines.size():
var_line = lines[i + 1].strip_edges()
var var_idx := var_line.find("var ")
if var_idx >= 0:
var rest := var_line.substr(var_idx + 4).strip_edges()
# Extract variable name (up to : or = or end)
var end_idx := rest.length()
for ch_idx in rest.length():
if rest[ch_idx] == ":" or rest[ch_idx] == "=" or rest[ch_idx] == " ":
end_idx = ch_idx
break
exports.append({
"name": rest.substr(0, end_idx),
"line": i + 1,
})
return {
"data": {
"path": path,
"class_name": class_name_str,
"extends": extends_str,
"functions": functions,
"signals": signals_list,
"exports": exports,
"function_count": functions.size(),
"signal_count": signals_list.size(),
"export_count": exports.size(),
}
}
@@ -0,0 +1 @@
uid://dhub87454jxb3
+258
View File
@@ -0,0 +1,258 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles signal listing, connecting, and disconnecting on scene nodes.
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
func list_signals(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var _resolved := McpNodeValidator.resolve_or_error(path, "path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
## Default: hide editor-internal connections (SceneTreeEditor observers
## live on every scene node and would otherwise dominate the response).
## Pass include_editor=true to see them. See #213.
var include_editor: bool = params.get("include_editor", false)
var signals: Array[Dictionary] = []
for sig in node.get_signal_list():
var args: Array[Dictionary] = []
for arg in sig.get("args", []):
args.append({"name": arg.get("name", ""), "type": type_string(arg.get("type", 0))})
signals.append({
"name": sig.get("name", ""),
"args": args,
})
var connections: Array[Dictionary] = []
var editor_connection_count := 0
for sig in signals:
for conn in node.get_signal_connection_list(sig.name):
var callable: Callable = conn.get("callable", Callable())
var target := callable.get_object()
if target == null:
continue # skip connections to freed objects
if not include_editor and _is_editor_internal_target(target, scene_root):
editor_connection_count += 1
continue
connections.append({
"signal": sig.name,
"target": _format_target_path(target, scene_root),
"method": callable.get_method(),
})
return {
"data": {
"path": McpScenePath.from_node(node, scene_root),
"signals": signals,
"signal_count": signals.size(),
"connections": connections,
"connection_count": connections.size(),
"editor_connection_count": editor_connection_count,
}
}
## A target is "editor-internal" when it's a Node sitting outside the edited
## scene tree AND not anywhere under a declared autoload — typical case is
## the SceneTreeEditor dock listening for visibility/script/state changes on
## every scene node. Connections to autoloads (declared under ``autoload/*``
## in ProjectSettings) are user-authored even though they live under
## ``/root/<Name>`` rather than under the edited scene root, so the autoload
## root *and* any descendant of it stay visible. Non-Node targets
## (anonymous Callables, RefCounted listeners etc.) also stay visible — we
## can't reliably classify them.
func _is_editor_internal_target(target: Object, scene_root: Node) -> bool:
if not (target is Node):
return false
var node_target: Node = target
if node_target == scene_root:
return false
if scene_root.is_ancestor_of(node_target):
return false
if _is_under_autoload(node_target):
return false
return true
## True if `node` is a declared autoload root or sits anywhere under one.
## When the node is in the SceneTree we read its absolute path
## (``/root/<Name>/...``) and check the first segment after ``/root/``;
## this covers connections to deep descendants of editor-instanced
## autoloads (e.g. ``/root/MyAutoload/Foo/Bar``). When the node isn't in
## the tree (test fixtures often construct nodes in isolation), we walk
## the parent chain and match each ancestor's ``name`` against the
## autoload key as a best-effort fallback.
static func _is_under_autoload(node: Node) -> bool:
if node.is_inside_tree():
var path := str(node.get_path())
if not path.begins_with("/root/"):
return false
var first_segment := path.substr(6).split("/", true, 1)[0]
return ProjectSettings.has_setting("autoload/" + first_segment)
var cursor: Node = node
while cursor != null:
if ProjectSettings.has_setting("autoload/" + str(cursor.name)):
return true
cursor = cursor.get_parent()
return false
## Serialize a connection's target path. Descendants of (or equal to) the
## edited scene root render as the usual scene-relative form
## (``/Main/Camera3D``). Non-descendants — autoload subtrees in particular
## — render as their canonical absolute SceneTree path
## (``/root/MyAutoload/Child``) instead of a scene-relative path full of
## ``..`` segments, which agents can't navigate back to. Non-Node targets
## (anonymous Callables, etc.) fall back to their string representation.
static func _format_target_path(target: Object, scene_root: Node) -> String:
if not (target is Node):
return str(target)
var node_target: Node = target
if node_target == scene_root or scene_root.is_ancestor_of(node_target):
return McpScenePath.from_node(node_target, scene_root)
if node_target.is_inside_tree():
return str(node_target.get_path())
return McpScenePath.from_node(node_target, scene_root)
func connect_signal(params: Dictionary) -> Dictionary:
var resolved := _resolve_signal_params(params)
if resolved.has("error"):
return resolved
var source: Node = resolved.source
var target: Node = resolved.target
var signal_name: String = resolved.signal_name
var method: String = resolved.method
var scene_root: Node = resolved.scene_root
if not source.has_signal(signal_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Signal '%s' not found on %s" % [signal_name, params.path])
if not target.has_method(method):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Method '%s' not found on %s" % [method, params.target])
var callable := Callable(target, method)
if source.is_connected(signal_name, callable):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
_undo_redo.add_do_method(source, "connect", signal_name, callable)
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
_undo_redo.commit_action()
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
func disconnect_signal(params: Dictionary) -> Dictionary:
var resolved := _resolve_signal_params(params)
if resolved.has("error"):
return resolved
var source: Node = resolved.source
var target: Node = resolved.target
var signal_name: String = resolved.signal_name
var method: String = resolved.method
var scene_root: Node = resolved.scene_root
var callable := Callable(target, method)
if not source.is_connected(signal_name, callable):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
_undo_redo.commit_action()
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
func _resolve_signal_params(params: Dictionary) -> Dictionary:
for key in ["path", "signal", "target", "method"]:
## Type-check before calling .is_empty(): a non-string value (e.g. an
## int or dict) has no is_empty() and would crash the handler, which
## the dispatcher only reports as an opaque "malformed result" (#210).
var value = params.get(key, "")
var type_err = McpParamValidators.require_string(key, value)
if type_err != null:
return type_err
if value.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % key)
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 source_result := _resolve_node_or_autoload(params.path, scene_root, "Source")
if source_result.has("error"):
return source_result
var source: Node = source_result.node
var target_result := _resolve_node_or_autoload(params.target, scene_root, "Target")
if target_result.has("error"):
return target_result
var target: Node = target_result.node
return {
"source": source,
"target": target,
"signal_name": params.signal,
"method": params.method,
"scene_root": scene_root,
}
## Resolve a path to a Node, with three distinct outcomes:
## 1. Found in the edited scene tree → returns {node}
## 2. Declared as an autoload AND instantiated at edit time → returns {node}
## 3. Declared as an autoload but NOT instantiated at edit time → returns
## INVALID_PARAMS with guidance. Most autoloads are runtime-only, so a
## silent "not found" hides the real reason the connection can't be made.
## 4. Not in scene and not a declared autoload → returns INVALID_PARAMS.
func _resolve_node_or_autoload(path: String, scene_root: Node, role: String) -> Dictionary:
var node := McpScenePath.resolve(path, scene_root)
if node != null:
return {"node": node}
var name := path.trim_prefix("/")
if ProjectSettings.has_setting("autoload/" + name):
# Autoload is declared — see if the editor has it instanced.
var tree := Engine.get_main_loop()
if tree is SceneTree:
var live := (tree as SceneTree).root.get_node_or_null(name)
if live != null:
return {"node": live}
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"%s '%s' is a declared autoload but isn't instantiated in the editor. " % [role, name] +
"Most autoloads are runtime-only; edit-time signal connection isn't supported for them. " +
"Connect it from a script attached to the scene using @onready + connect(), " +
"or enable editor-instancing for this autoload in Project Settings > Autoload.")
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
"%s node not found: %s (not in scene tree or autoloads)" % [role, path])
func _signal_response(source: Node, signal_name: String, target: Node, method: String, scene_root: Node) -> Dictionary:
return {
"source": McpScenePath.from_node(source, scene_root),
"signal": signal_name,
"target": McpScenePath.from_node(target, scene_root),
"method": method,
"undoable": true,
}
@@ -0,0 +1 @@
uid://b4n8byjeqeddm
+82
View File
@@ -0,0 +1,82 @@
@tool
extends RefCounted
## Discovers and runs McpTestSuite scripts from res://tests/.
## Exposes run_tests and get_test_results as MCP commands.
var _runner: McpTestRunner
var _undo_redo: EditorUndoRedoManager
var _log_buffer: McpLogBuffer
func _init(undo_redo: EditorUndoRedoManager, log_buffer: McpLogBuffer) -> void:
_runner = McpTestRunner.new()
_undo_redo = undo_redo
_log_buffer = log_buffer
func run_tests(params: Dictionary) -> Dictionary:
var suite_filter: String = params.get("suite", "")
var test_filter: String = params.get("test_name", "")
var exclude_test_filter: String = params.get("exclude_test_name", "")
var verbose: bool = params.get("verbose", false)
var discovery := _discover_suites()
var suites: Array = discovery.suites
if suites.is_empty():
var msg := "No test suites found in res://tests/"
if not discovery.errors.is_empty():
msg += " (%d script(s) failed to load: %s)" % [
discovery.errors.size(),
", ".join(discovery.errors),
]
return {"data": {"error": msg, "total": 0, "load_errors": discovery.errors}}
var ctx := {
"undo_redo": _undo_redo,
"log_buffer": _log_buffer,
}
var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
if not discovery.errors.is_empty():
results["load_errors"] = discovery.errors
return {"data": results}
func get_test_results(params: Dictionary) -> Dictionary:
var verbose: bool = params.get("verbose", false)
return {"data": _runner.get_results(verbose)}
func _discover_suites() -> Dictionary:
## Returns {"suites": Array, "errors": Array[String]}.
## Resilient: a broken script doesn't kill discovery of the rest.
var suites := []
var errors: Array[String] = []
var dir := DirAccess.open("res://tests")
if dir == null:
return {"suites": suites, "errors": ["DirAccess.open('res://tests') returned null — directory may not exist"]}
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("test_") and file_name.ends_with(".gd"):
var path := "res://tests/" + file_name
var script = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
if script == null:
errors.append("%s (load failed — check for parse errors or duplicate methods)" % file_name)
elif script.can_instantiate():
var instance = script.new()
if instance is McpTestSuite:
suites.append(instance)
else:
errors.append("%s (not a McpTestSuite subclass)" % file_name)
else:
errors.append("%s (cannot instantiate — abstract or broken)" % file_name)
file_name = dir.get_next()
## Sort by suite name for deterministic order.
suites.sort_custom(func(a, b) -> bool:
return a.suite_name() < b.suite_name()
)
return {"suites": suites, "errors": errors}
@@ -0,0 +1 @@
uid://bfg3c6iinhwmx
+199
View File
@@ -0,0 +1,199 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Creates procedural textures — GradientTexture2D (wrapping a Gradient)
## and NoiseTexture2D (wrapping a FastNoiseLite). Assigns to a node slot
## (undoable, bundles sub-resources) or saves to a .tres file.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
_undo_redo = undo_redo
_connection = connection
const _FILL_MODES := {
"linear": GradientTexture2D.FILL_LINEAR,
"radial": GradientTexture2D.FILL_RADIAL,
"square": GradientTexture2D.FILL_SQUARE,
}
const _NOISE_TYPES := {
"simplex": FastNoiseLite.TYPE_SIMPLEX,
"simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH,
"perlin": FastNoiseLite.TYPE_PERLIN,
"cellular": FastNoiseLite.TYPE_CELLULAR,
"value": FastNoiseLite.TYPE_VALUE,
"value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC,
}
# ============================================================================
# gradient_texture_create
# ============================================================================
func create_gradient_texture(params: Dictionary) -> Dictionary:
var stops: Array = params.get("stops", [])
var width: int = params.get("width", 256)
var height: int = params.get("height", 1)
var fill: String = params.get("fill", "linear")
if stops.size() < 2:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"gradient_texture_create requires at least 2 stops, got %d" % stops.size()
)
if not _FILL_MODES.has(fill):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid fill '%s'. Valid: %s" % [fill, ", ".join(_FILL_MODES.keys())]
)
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var gradient := Gradient.new()
var offsets := PackedFloat32Array()
var colors := PackedColorArray()
for i in range(stops.size()):
var stop = stops[i]
if not stop is Dictionary:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"stops[%d] must be a dict with 'offset' and 'color' keys" % i
)
if not stop.has("offset") or not stop.has("color"):
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"stops[%d] missing 'offset' or 'color' key" % i
)
offsets.append(float(stop["offset"]))
var color_value = NodeHandler._coerce_value(stop["color"], TYPE_COLOR)
var color_err := NodeHandler._check_coerced(color_value, TYPE_COLOR, "stops[%d].color" % i)
if color_err != null:
return color_err
colors.append(color_value)
gradient.offsets = offsets
gradient.colors = colors
var tex := GradientTexture2D.new()
tex.gradient = gradient
tex.width = width
tex.height = height
tex.fill = _FILL_MODES[fill]
return _finalize(tex, [gradient], params, "Gradient texture", {
"texture_class": "GradientTexture2D",
"gradient_class": "Gradient",
"stop_count": stops.size(),
"fill": fill,
})
# ============================================================================
# noise_texture_create
# ============================================================================
func create_noise_texture(params: Dictionary) -> Dictionary:
var noise_type: String = params.get("noise_type", "simplex_smooth")
var width: int = params.get("width", 512)
var height: int = params.get("height", 512)
var frequency: float = params.get("frequency", 0.01)
var seed_value: int = params.get("seed", 0)
var fractal_octaves: int = params.get("fractal_octaves", 0) # 0 = leave default
if not _NOISE_TYPES.has(noise_type):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid noise_type '%s'. Valid: %s" % [noise_type, ", ".join(_NOISE_TYPES.keys())]
)
var home_err := McpResourceIO.validate_home(params)
if home_err != null:
return home_err
var noise := FastNoiseLite.new()
noise.noise_type = _NOISE_TYPES[noise_type]
noise.frequency = frequency
noise.seed = seed_value
if fractal_octaves > 0:
noise.fractal_octaves = fractal_octaves
var tex := NoiseTexture2D.new()
tex.noise = noise
tex.width = width
tex.height = height
return _finalize(tex, [noise], params, "Noise texture", {
"texture_class": "NoiseTexture2D",
"noise_class": "FastNoiseLite",
"noise_type": noise_type,
})
# ============================================================================
# shared helpers
# ============================================================================
func _finalize(tex: Resource, sub_resources: Array, params: Dictionary, label: String, extra: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
var property: String = params.get("property", "")
var resource_path: String = params.get("resource_path", "")
var overwrite: bool = params.get("overwrite", false)
if not resource_path.is_empty():
return McpResourceIO.save_to_disk(tex, resource_path, overwrite, label, extra, _connection)
return _assign_texture(tex, sub_resources, node_path, property, label, extra)
func _assign_texture(tex: Resource, sub_resources: Array, node_path: String, property: String, label: String, extra: Dictionary) -> Dictionary:
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
var found := false
var prop_type: int = TYPE_NIL
for prop in node.get_property_list():
if prop.name == property:
found = true
prop_type = prop.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s" % [property, node.get_class()]
)
if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' on %s is not an Object slot" % [property, node.get_class()]
)
var old_value = node.get(property)
_undo_redo.create_action("MCP: Create %s for %s.%s" % [label, node.name, property])
_undo_redo.add_do_property(node, property, tex)
_undo_redo.add_undo_property(node, property, old_value)
_undo_redo.add_do_reference(tex)
for sub in sub_resources:
_undo_redo.add_do_reference(sub)
_undo_redo.commit_action()
var data := {
"path": node_path,
"property": property,
"undoable": true,
}
data.merge(extra)
return {"data": data}
@@ -0,0 +1 @@
uid://cmloikhre8lhe
+488
View File
@@ -0,0 +1,488 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles Theme resource authoring: creating, modifying color/constant/font-size/
## stylebox slots, and applying a theme to a Control subtree.
##
## Themes are Godot's equivalent of USS: a Theme holds (class, name) -> value
## entries (colors, constants, fonts, font_sizes, styleboxes, icons) which
## cascade down a Control subtree when the theme is assigned at any ancestor.
## One well-authored theme replaces hundreds of per-node property sets.
const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# theme_create
# ============================================================================
func create_theme(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var overwrite: bool = params.get("overwrite", false)
var err := _validate_res_path(path, ".tres", "path", true)
if err != null:
return err
# Capture whether the file was already there BEFORE the save so we can
# report `overwritten` accurately (after save the file always exists).
var existed_before := FileAccess.file_exists(path)
if existed_before and not overwrite:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Theme already exists at %s (pass overwrite=true to replace)" % path
)
# Ensure parent directory exists. make_dir_recursive is idempotent —
# no need to check dir_exists first (avoids TOCTOU race).
var dir_path := path.get_base_dir()
var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
)
var theme := Theme.new()
var save_err := ResourceSaver.save(theme, path)
if save_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to save theme to %s: %s (error %d)" % [path, error_string(save_err), save_err]
)
# Make sure the editor's filesystem picks up the new file.
var efs := EditorInterface.get_resource_filesystem()
if efs != null:
efs.update_file(path)
return {
"data": {
"path": path,
"overwritten": existed_before,
"undoable": false,
"reason": "File creation is persistent; delete the file manually to revert",
}
}
# ============================================================================
# theme_set_color / theme_set_constant / theme_set_font_size
# ============================================================================
func set_color(params: Dictionary) -> Dictionary:
return _set_scalar(params, "color", func(theme, name, cls): return theme.get_color(name, cls),
func(theme, name, cls, val): theme.set_color(name, cls, val),
func(theme, name, cls): theme.clear_color(name, cls),
func(theme, name, cls): return theme.has_color(name, cls),
func(v): return _parse_color(v))
# constant / font_size parsers validate before coercing: int("abc")/int({})/int([])
# all return 0 in GDScript (never null), so a bare `int(v)` would silently store
# garbage as 0 and report success. Returning null for non-numeric input lets
# _set_scalar's null guard surface a VALUE_OUT_OF_RANGE error, matching the
# color path's contract.
func set_constant(params: Dictionary) -> Dictionary:
return _set_scalar(params, "constant", func(theme, name, cls): return theme.get_constant(name, cls),
func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
func(theme, name, cls): theme.clear_constant(name, cls),
func(theme, name, cls): return theme.has_constant(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
func set_font_size(params: Dictionary) -> Dictionary:
return _set_scalar(params, "font_size", func(theme, name, cls): return theme.get_font_size(name, cls),
func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
func(theme, name, cls): theme.clear_font_size(name, cls),
func(theme, name, cls): return theme.has_font_size(name, cls),
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
# Shared implementation for scalar Theme slots (color, constant, font_size).
# Captures old value, applies new value, saves to disk, registers undo that
# restores the old value and saves again.
func _set_scalar(
params: Dictionary,
kind: String,
getter: Callable,
setter: Callable,
clearer: Callable,
has_fn: Callable,
parser: Callable,
) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
if not "value" in params:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
var raw_value = params.get("value")
if raw_value == null:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: null (pass a concrete value; use the appropriate clear command to remove a slot)" % kind
)
var parsed = parser.call(raw_value)
if parsed == null:
## color slots want a color hint; constant/font_size are integer slots.
var hint := _COLOR_HINT if kind == "color" else "expected an integer"
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid %s value: %s (%s)" % [kind, raw_value, hint])
var had_before: bool = has_fn.call(theme, name, class_name_param)
var before_value = getter.call(theme, name, class_name_param) if had_before else null
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
_undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
if had_before:
_undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
else:
_undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
_undo_redo.commit_action()
return {
"data": {
"path": theme_path,
"kind": kind,
"class_name": class_name_param,
"name": name,
"value": _serialize_value(parsed),
"previous_value": _serialize_value(before_value) if had_before else null,
"undoable": true,
}
}
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
setter.call(theme, name, class_name_param, value)
ResourceSaver.save(theme, theme_path)
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
clearer.call(theme, name, class_name_param)
ResourceSaver.save(theme, theme_path)
# ============================================================================
# theme_set_stylebox_flat
# ============================================================================
## Compose a StyleBoxFlat and assign it to a theme slot.
##
## Parameters (beyond theme_path / class_name / name):
## bg_color (Color, "#rrggbb", "#rrggbbaa", or {r,g,b,a})
## border_color (Color)
## border {all|top|bottom|left|right: int} — side keys override `all`
## corners {all|top_left|top_right|bottom_left|bottom_right: int}
## margins {all|top|bottom|left|right: float}
## shadow {color, size: int, offset_x: float, offset_y: float}
## anti_aliasing (bool)
##
## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
## typos fail loudly instead of silently being ignored.
func set_stylebox_flat(params: Dictionary) -> Dictionary:
var load_result := _load_theme_from_params(params)
if load_result.has("error"):
return load_result
var theme: Theme = load_result.theme
var theme_path: String = load_result.path
var class_name_param: String = params.get("class_name", "")
if class_name_param.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
var name: String = params.get("name", "")
if name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
var sb := StyleBoxFlat.new()
if params.has("bg_color"):
var bg := _parse_color(params.bg_color)
if bg == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
sb.bg_color = bg
if params.has("border_color"):
var bc := _parse_color(params.border_color)
if bc == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
sb.border_color = bc
# border: {all, top, bottom, left, right} — int widths
if params.has("border"):
var err := _apply_sides(sb, params.border, "border",
["top", "bottom", "left", "right"],
"border_width_",
TYPE_INT)
if err != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err)
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii
if params.has("corners"):
var err2 := _apply_sides(sb, params.corners, "corners",
["top_left", "top_right", "bottom_left", "bottom_right"],
"corner_radius_",
TYPE_INT)
if err2 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2)
# margins: {all, top, bottom, left, right} — float padding
if params.has("margins"):
var err3 := _apply_sides(sb, params.margins, "margins",
["top", "bottom", "left", "right"],
"content_margin_",
TYPE_FLOAT)
if err3 != "":
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3)
# shadow: {color, size, offset_x, offset_y}
if params.has("shadow"):
if typeof(params.shadow) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
var shadow: Dictionary = params.shadow
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
for k in shadow.keys():
if not allowed_shadow_keys.has(k):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
if shadow.has("color"):
var sc := _parse_color(shadow.color)
if sc == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
sb.shadow_color = sc
if shadow.has("size"):
sb.shadow_size = int(shadow.size)
if shadow.has("offset_x") or shadow.has("offset_y"):
sb.shadow_offset = Vector2(
float(shadow.get("offset_x", 0)),
float(shadow.get("offset_y", 0)),
)
if params.has("anti_aliasing"):
sb.anti_aliasing = bool(params.anti_aliasing)
var had_before := theme.has_stylebox(name, class_name_param)
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
if had_before:
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
else:
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
_undo_redo.commit_action()
return {
"data": {
"path": theme_path,
"class_name": class_name_param,
"name": name,
"stylebox_class": "StyleBoxFlat",
"bg_color": _serialize_value(sb.bg_color),
"border": {
"top": sb.border_width_top,
"bottom": sb.border_width_bottom,
"left": sb.border_width_left,
"right": sb.border_width_right,
},
"corners": {
"top_left": sb.corner_radius_top_left,
"top_right": sb.corner_radius_top_right,
"bottom_left": sb.corner_radius_bottom_left,
"bottom_right": sb.corner_radius_bottom_right,
},
"margins": {
"top": sb.content_margin_top,
"bottom": sb.content_margin_bottom,
"left": sb.content_margin_left,
"right": sb.content_margin_right,
},
"undoable": true,
}
}
## Parse a {all, <side1>, <side2>, ...} dict and apply it to StyleBoxFlat via
## its set_<prop_prefix><side> properties. Returns "" on success, an error
## message on failure. Validates that only known keys are present.
func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String,
side_names: Array, prop_prefix: String, value_type: int) -> String:
if typeof(sides_dict) != TYPE_DICTIONARY:
return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name
var valid_keys := {"all": true}
for s in side_names:
valid_keys[s] = true
for k in sides_dict.keys():
if not valid_keys.has(k):
return "Unknown key in '%s': %s (valid: all, %s)" % [
dict_name, k, ", ".join(side_names)
]
# Apply `all` first, then override with side-specific keys.
if sides_dict.has("all"):
var all_val: Variant = sides_dict.all
for s in side_names:
var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val)
sb.set(prop_prefix + s, v)
for s in side_names:
if sides_dict.has(s):
var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s])
sb.set(prop_prefix + s, v2)
return ""
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
theme.set_stylebox(name, class_name_param, sb)
ResourceSaver.save(theme, theme_path)
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void:
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null:
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
return
theme.clear_stylebox(name, class_name_param)
ResourceSaver.save(theme, theme_path)
# ============================================================================
# theme_apply — assign a theme to a Control
# ============================================================================
func apply_theme(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
var theme_path: String = params.get("theme_path", "")
var theme: Theme = null
if not theme_path.is_empty():
var path_err := _validate_res_path(theme_path, ".tres")
if path_err != null:
return path_err
if not ResourceLoader.exists(theme_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
theme = ResourceLoader.load(theme_path)
if theme == null or not theme is Theme:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var _scene_root: Node = _resolved.scene_root
if not node is Control and not node is Window:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control or Window (got %s)" % [node_path, node.get_class()]
)
var before_theme: Theme = node.theme
_undo_redo.create_action("MCP: Apply theme to %s" % node.name)
_undo_redo.add_do_property(node, "theme", theme)
_undo_redo.add_undo_property(node, "theme", before_theme)
_undo_redo.commit_action()
return {
"data": {
"node_path": node_path,
"theme_path": theme_path if theme != null else "",
"cleared": theme == null,
"undoable": true,
}
}
# ============================================================================
# Helpers
# ============================================================================
func _load_theme_from_params(params: Dictionary) -> Dictionary:
var theme_path: String = params.get("theme_path", "")
var err := _validate_res_path(theme_path, ".tres", "theme_path", true)
if err != null:
return err
if not ResourceLoader.exists(theme_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
var theme: Theme = ResourceLoader.load(theme_path)
if theme == null or not theme is Theme:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
return {"theme": theme, "path": theme_path}
static func _validate_res_path(path: String, required_suffix: String, param_name: String = "theme_path", for_write: bool = false) -> Variant:
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
var path_err := McpPathValidator.validate_resource_path(path, for_write)
if not path_err.is_empty():
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
if not path.ends_with(required_suffix):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"%s must end with %s (got %s)" % [param_name, required_suffix, path]
)
return null
## Parse a color from Color, "#rrggbb", "#rrggbbaa", named (red/blue/...) or dict.
## Returns null if the input cannot be parsed.
static func _parse_color(value: Variant) -> Variant:
if value is Color:
return value
if value is String:
var s: String = value
# Color.from_string returns the default on parse failure, so call it twice
# with distinct sentinels — if both agree, parsing succeeded.
var sentinel_a := Color(0, 0, 0, 0)
var sentinel_b := Color(1, 1, 1, 1)
var a := Color.from_string(s, sentinel_a)
var b := Color.from_string(s, sentinel_b)
if a != b:
return null
return a
if value is Dictionary:
var d: Dictionary = value
if d.has("r") and d.has("g") and d.has("b"):
return Color(float(d.r), float(d.g), float(d.b), float(d.get("a", 1.0)))
return null
static func _serialize_value(value: Variant) -> Variant:
if value == null:
return null
if value is Color:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
if value is Vector2:
return {"x": value.x, "y": value.y}
return value
@@ -0,0 +1 @@
uid://gjyldaddj7mu
+533
View File
@@ -0,0 +1,533 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles UI-specific (Control) layout helpers: anchor presets, etc.
##
## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time.
## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so
## callers can set a whole layout with one command, with proper undo.
var _undo_redo: EditorUndoRedoManager
const _PRESETS := {
"top_left": Control.PRESET_TOP_LEFT,
"top_right": Control.PRESET_TOP_RIGHT,
"bottom_left": Control.PRESET_BOTTOM_LEFT,
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
"center_left": Control.PRESET_CENTER_LEFT,
"center_top": Control.PRESET_CENTER_TOP,
"center_right": Control.PRESET_CENTER_RIGHT,
"center_bottom": Control.PRESET_CENTER_BOTTOM,
"center": Control.PRESET_CENTER,
"left_wide": Control.PRESET_LEFT_WIDE,
"top_wide": Control.PRESET_TOP_WIDE,
"right_wide": Control.PRESET_RIGHT_WIDE,
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
"full_rect": Control.PRESET_FULL_RECT,
}
const _RESIZE_MODES := {
"minsize": Control.PRESET_MODE_MINSIZE,
"keep_width": Control.PRESET_MODE_KEEP_WIDTH,
"keep_height": Control.PRESET_MODE_KEEP_HEIGHT,
"keep_size": Control.PRESET_MODE_KEEP_SIZE,
}
const _ANCHOR_OFFSET_PROPS := [
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
"offset_left", "offset_top", "offset_right", "offset_bottom",
]
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Apply a Control layout preset (anchors + offsets) to a UI node.
##
## Params:
## path - scene path to a Control node (required)
## preset - preset name: full_rect, center, top_left, ... (required)
## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize)
## margin - integer margin in pixels from the anchor edges (default: 0)
func set_anchor_preset(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
var preset_name: String = str(params.get("preset", "")).to_lower()
if preset_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
if not _PRESETS.has(preset_name):
var names := _PRESETS.keys()
names.sort()
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)]
)
var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower()
if not _RESIZE_MODES.has(resize_mode_name):
var names := _RESIZE_MODES.keys()
names.sort()
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)]
)
var margin: int = int(params.get("margin", 0))
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
if not node is Control:
var got_class: String = node.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)%s" % [
node_path, got_class, _canvas_layer_overlay_hint(got_class)
]
)
var control := node as Control
var preset_value: int = _PRESETS[preset_name]
var resize_mode_value: int = _RESIZE_MODES[resize_mode_name]
# Snapshot before so we can undo every property the preset may have touched.
var before: Dictionary = {}
for prop in _ANCHOR_OFFSET_PROPS:
before[prop] = control.get(prop)
_undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name])
_undo_redo.add_do_method(
control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin
)
for prop in _ANCHOR_OFFSET_PROPS:
_undo_redo.add_undo_property(control, prop, before[prop])
_undo_redo.commit_action()
var after: Dictionary = {}
for prop in _ANCHOR_OFFSET_PROPS:
after[prop] = control.get(prop)
return {
"data": {
"path": node_path,
"preset": preset_name,
"resize_mode": resize_mode_name,
"margin": margin,
"anchors": {
"left": after.anchor_left,
"top": after.anchor_top,
"right": after.anchor_right,
"bottom": after.anchor_bottom,
},
"offsets": {
"left": after.offset_left,
"top": after.offset_top,
"right": after.offset_right,
"bottom": after.offset_bottom,
},
"undoable": true,
}
}
## Set the visible `text` property on a UI Control (Label, Button + subclasses,
## LineEdit, TextEdit, RichTextLabel, LinkButton). Undoable.
func set_text(params: Dictionary) -> Dictionary:
var node_path: String = params.get("path", "")
if node_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
if not params.has("text"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text")
var text_value: Variant = params["text"]
if typeof(text_value) != TYPE_STRING:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string")
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
if _resolved.has("error"):
return _resolved
var node: Node = _resolved.node
var scene_root: Node = _resolved.scene_root
var node_type := node.get_class()
if not node is Control:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node %s is not a Control (got %s)" % [node_path, node_type]
)
# Scan get_property_list() (matches set_property / _apply_property in this
# repo) so we can both confirm `text` exists and that it's actually a String
# — guards against a custom Control whose `text` happens to be some other
# type, where set()-ing a String would silently mis-coerce.
var text_prop_type := TYPE_NIL
var has_text := false
for prop in node.get_property_list():
if prop.get("name", "") == "text":
has_text = true
text_prop_type = prop.get("type", TYPE_NIL)
break
if not has_text:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Control %s has no 'text' property (got %s)" % [node_path, node_type]
)
if text_prop_type != TYPE_STRING:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Control %s has a non-string 'text' property (got %s)" % [node_path, node_type]
)
var old_value: String = node.get("text")
_undo_redo.create_action("MCP: Set %s text" % node.name)
_undo_redo.add_do_property(node, "text", text_value)
_undo_redo.add_undo_property(node, "text", old_value)
_undo_redo.commit_action()
return {
"data": {
"path": node_path,
"text": text_value,
"old_text": old_value,
"node_type": node_type,
"undoable": true,
}
}
# ============================================================================
# build_layout — declarative nested-dict → Control tree in one undo action
# ============================================================================
## Build a tree of Control nodes atomically.
##
## Params:
## tree - Dictionary describing the root node. Required fields: "type".
## Optional: "name", "properties" (dict), "anchor_preset",
## "anchor_margin", "theme" (res://, uid:// or user:// path), "children" (array).
## parent_path - Parent scene path. Empty or "/" = scene root.
##
## Validation is done before any scene mutation: class names, property
## existence, and res:// paths are all checked up-front. If anything is
## invalid, no node is created.
func build_layout(params: Dictionary) -> Dictionary:
var tree = params.get("tree")
if not params.has("tree"):
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: tree")
if typeof(tree) != TYPE_DICTIONARY:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tree must be a 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 parent_path: String = params.get("parent_path", "")
var parent: Node = scene_root
if not parent_path.is_empty() and parent_path != "/":
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))
# Validate + build in memory first; if anything fails, free and bail.
var built := _build_subtree(tree)
if built.has("error"):
return built
var root_node: Node = built.node
var created: Array[Node] = built.created
_undo_redo.create_action("MCP: Build UI layout (%d nodes)" % created.size())
_undo_redo.add_do_method(parent, "add_child", root_node, true)
_undo_redo.add_do_method(root_node, "set_owner", scene_root)
for n in created:
_undo_redo.add_do_method(n, "set_owner", scene_root)
_undo_redo.add_do_reference(n)
_undo_redo.add_undo_method(parent, "remove_child", root_node)
_undo_redo.commit_action()
return {
"data": {
"root_path": McpScenePath.from_node(root_node, scene_root),
"node_count": created.size(),
"undoable": true,
}
}
## Recursively instantiate + configure a node and its children in memory.
## Returns {"node": root, "created": [all descendants incl. root]} or {"error": ...}.
func _build_subtree(spec: Dictionary) -> Dictionary:
var node_type: String = spec.get("type", "")
if node_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Every layout node requires a 'type'")
if not ClassDB.class_exists(node_type):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown type: %s" % node_type)
if not ClassDB.is_parent_class(node_type, "Node"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type)
var node: Node = ClassDB.instantiate(node_type)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type)
var node_name: String = spec.get("name", "")
if not node_name.is_empty():
node.name = node_name
# Properties.
if spec.has("properties"):
var props = spec.get("properties")
if typeof(props) != TYPE_DICTIONARY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "properties must be a dictionary")
for key in props:
var value = props[key]
var apply_err := _apply_property(node, str(key), value)
if apply_err != null:
node.free()
return apply_err
# Theme (res:// / uid:// / user:// path -> Resource).
if spec.has("theme"):
var theme_path: String = str(spec.get("theme", ""))
if not theme_path.is_empty():
var theme_path_err = McpPathValidator.loadable_error(theme_path, "theme")
if theme_path_err != null:
node.free()
return theme_path_err
if not ResourceLoader.exists(theme_path):
node.free()
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
var theme_res: Resource = ResourceLoader.load(theme_path)
if theme_res == null or not theme_res is Theme:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "theme path must point to a Theme resource: %s" % theme_path)
if not node is Control and not node is Window:
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme can only be set on Control / Window (got %s)%s" % [
node_type, _canvas_layer_overlay_hint(node_type)
]
)
node.theme = theme_res as Theme
# Anchor preset — applied before children so children inherit sensible anchors.
if spec.has("anchor_preset"):
var preset_name: String = str(spec.get("anchor_preset", "")).to_lower()
if not _PRESETS.has(preset_name):
node.free()
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown anchor_preset: %s" % preset_name)
if not node is Control:
node.free()
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"anchor_preset requires a Control (got %s)%s" % [
node_type, _canvas_layer_overlay_hint(node_type)
]
)
var preset_value: int = _PRESETS[preset_name]
var margin: int = int(spec.get("anchor_margin", 0))
(node as Control).set_anchors_and_offsets_preset(preset_value, Control.PRESET_MODE_MINSIZE, margin)
var created: Array[Node] = [node]
if spec.has("children"):
var children = spec.get("children")
if typeof(children) != TYPE_ARRAY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "children must be an array")
for child_spec in children:
if typeof(child_spec) != TYPE_DICTIONARY:
node.free()
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "each child must be a dictionary")
var child_result := _build_subtree(child_spec)
if child_result.has("error"):
node.free()
return child_result
var child_node: Node = child_result.node
node.add_child(child_node)
for n in child_result.created:
created.append(n)
return {"node": node, "created": created}
## Mapping from theme_override_* property prefixes to their add/remove methods.
const _THEME_OVERRIDE_MAP := {
"theme_override_colors/": {
"add": "add_theme_color_override",
"remove": "remove_theme_color_override",
"coerce_type": TYPE_COLOR,
},
"theme_override_constants/": {
"add": "add_theme_constant_override",
"remove": "remove_theme_constant_override",
"coerce_type": TYPE_INT,
},
"theme_override_font_sizes/": {
"add": "add_theme_font_size_override",
"remove": "remove_theme_font_size_override",
"coerce_type": TYPE_INT,
},
"theme_override_styles/": {
"add": "add_theme_stylebox_override",
"remove": "remove_theme_stylebox_override",
"coerce_type": TYPE_OBJECT,
},
}
## Apply a property to a newly-instantiated node. Handles Color/Vector2/NodePath
## coercion from JSON-friendly forms. Returns null on success, error dict on failure.
func _apply_property(node: Node, prop: String, value: Variant) -> Variant:
# Handle theme_override_* pseudo-properties before the regular property scan.
for prefix in _THEME_OVERRIDE_MAP:
if prop.begins_with(prefix):
if not node is Control:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme_override_* requires a Control node (got %s)" % node.get_class()
)
var override_name := prop.substr(prefix.length())
var info: Dictionary = _THEME_OVERRIDE_MAP[prefix]
var coerce_type: int = info.coerce_type
# For stylebox overrides, load from a res:// / uid:// / user:// path.
if coerce_type == TYPE_OBJECT:
if value is String and (value.begins_with("res://") or value.begins_with("uid://") or value.begins_with("user://")):
var style_path_err = McpPathValidator.loadable_error(value, "stylebox")
if style_path_err != null:
return style_path_err
var res := ResourceLoader.load(value)
if res == null or not res is StyleBox:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Style resource not found or not a StyleBox: %s" % value
)
node.call(info.add, override_name, res)
else:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"theme_override_styles/ expects a res:// / uid:// / user:// path to a StyleBox"
)
else:
var coercion := _coerce_for_type(value, coerce_type)
if not coercion.ok:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot coerce '%s' for %s" % [value, prop]
)
node.call(info.add, override_name, coercion.value)
return null
var found := false
var prop_type := TYPE_NIL
for p in node.get_property_list():
if p.name == prop:
found = true
prop_type = p.get("type", TYPE_NIL)
break
if not found:
return ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
McpPropertyErrors.build_message(node, prop)
)
var coercion := _coerce_for_type(value, prop_type)
if not coercion.ok:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Property '%s' on %s expects type %s (cannot coerce %s)" % [
prop, node.get_class(), type_string(prop_type), value
]
)
node.set(prop, coercion.value)
return null
## Coerce a JSON-friendly value to the target Godot type. Returns
## {"ok": true, "value": coerced} on success, {"ok": false} on failure.
## For types we don't explicitly coerce, the value is returned as-is
## (Godot will typecheck at set() time and fail loudly if it disagrees).
static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary:
match prop_type:
TYPE_COLOR:
if value is Color:
return {"ok": true, "value": value}
if value is String:
var a := Color.from_string(value, Color(0, 0, 0, 0))
var b := Color.from_string(value, Color(1, 1, 1, 1))
if a == b:
return {"ok": true, "value": a}
return {"ok": false}
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
return {
"ok": true,
"value": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0))),
}
return {"ok": false}
TYPE_VECTOR2:
if value is Vector2:
return {"ok": true, "value": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": true, "value": Vector2(float(value.x), float(value.y))}
if value is Array and value.size() == 2:
return {"ok": true, "value": Vector2(float(value[0]), float(value[1]))}
return {"ok": false}
TYPE_VECTOR2I:
if value is Vector2i:
return {"ok": true, "value": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": true, "value": Vector2i(int(value.x), int(value.y))}
if value is Array and value.size() == 2:
return {"ok": true, "value": Vector2i(int(value[0]), int(value[1]))}
return {"ok": false}
TYPE_RECT2:
if value is Rect2:
return {"ok": true, "value": value}
if value is Array and value.size() == 4:
return {
"ok": true,
"value":
Rect2(float(value[0]), float(value[1]), float(value[2]), float(value[3])),
}
if value is Dictionary:
if value.has("x") and value.has("y") and value.has("w") and value.has("h"):
return {
"ok": true,
"value":
Rect2(float(value.x), float(value.y), float(value.w), float(value.h)),
}
if value.has("position") and value.has("size"):
var pos := _coerce_for_type(value.position, TYPE_VECTOR2)
var sz := _coerce_for_type(value.size, TYPE_VECTOR2)
if pos.ok and sz.ok:
return {"ok": true, "value": Rect2(pos.value, sz.value)}
return {"ok": false}
TYPE_NODE_PATH:
if value is NodePath:
return {"ok": true, "value": value}
if value is String:
return {"ok": true, "value": NodePath(value)}
return {"ok": false}
return {"ok": true, "value": value}
# CanvasLayer is the canonical HUD parent but isn't a Control, so applying
# Control-only properties (theme, anchor_preset) to it is a common mistake.
# The recovery shape is always the same: nest a Control child under the layer.
static func _canvas_layer_overlay_hint(node_class: String) -> String:
if node_class != "CanvasLayer":
return ""
return (
". CanvasLayer is not a Control — add a Control (e.g. Panel or Control "
+ "with anchor_preset=full_rect) as its child and apply theme / "
+ "anchor_preset to that overlay."
)
@@ -0,0 +1 @@
uid://ckm6f1objpgvw