Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user