first commit
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,30 @@
|
||||
@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. Only string params are guarded — int/bool params
|
||||
## can't be: Godot's JSON parser decodes every number as float (a wire `5`
|
||||
## arrives as `5.0`), so a strict int check would reject every legitimate
|
||||
## integer a client sends, and GDScript's typed assignment already converts
|
||||
## numeric Variants safely. Bool params arrive as real bools and a wrong
|
||||
## type surfaces through the dispatcher's malformed-result path.
|
||||
|
||||
|
||||
## 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)],
|
||||
)
|
||||
@@ -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.NODE_NOT_FOUND,
|
||||
"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,536 @@
|
||||
@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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"AnimationHandler not available", false)
|
||||
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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"AnimationHandler not available", false)
|
||||
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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"AnimationHandler not available", false)
|
||||
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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"AnimationHandler not available", false)
|
||||
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,442 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
|
||||
|
||||
## 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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"AnimationHandler not available", false)
|
||||
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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"AnimationHandler not available", false)
|
||||
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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"AnimationHandler not available", false)
|
||||
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:
|
||||
## Canonical strict parser (#714): same shapes as every other
|
||||
## color-accepting handler, including [r,g,b(,a)] arrays.
|
||||
var col = McpJsonValues.parse_color(value)
|
||||
if col != null:
|
||||
return {"ok": col}
|
||||
return {"error": "Cannot coerce value to Color for property '%s' (expected \"#rrggbb(aa)\"/named string, {r,g,b[,a]}, [r,g,b(,a)], or Color)" % prop_name}
|
||||
TYPE_VECTOR2:
|
||||
var v2 = McpJsonValues.parse_vector2(value)
|
||||
if v2 != null:
|
||||
return {"ok": v2}
|
||||
return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
|
||||
TYPE_VECTOR3:
|
||||
var v3 = McpJsonValues.parse_vector3(value)
|
||||
if v3 != null:
|
||||
return {"ok": v3}
|
||||
return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z}, [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:
|
||||
## Delegates to the shared serializer (#714) — the drifted private copy
|
||||
## stringified rotation_3d keyframe Quaternions into opaque text where
|
||||
## McpVariantSerializer emits the {x,y,z,w} dict callers can round-trip
|
||||
## (it also NaN/Inf-guards floats, matching the wire contract).
|
||||
return VariantSerializer.serialize(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.SUGGESTABLE_SECTION_TOKENS,
|
||||
3,
|
||||
0.3
|
||||
)
|
||||
var message := "Unknown class-info section(s): %s. Valid sections: %s (or \"all\" for all documentation sections; \"inheritors\" must be requested by name)" % [
|
||||
", ".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,361 @@
|
||||
@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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"EditorFileSystem not available", false)
|
||||
|
||||
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,170 @@
|
||||
@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.
|
||||
|
||||
## Commands that cannot run as batch sub-commands, each with the reason a batch
|
||||
## can't host it.
|
||||
## - batch_execute: would recurse.
|
||||
## - run_tests: a batch executes synchronously inside one dispatcher tick with
|
||||
## NO transport servicing, so a full suite starves the WebSocket heartbeat
|
||||
## (the exact disconnect the serviced test_run path exists to prevent) and
|
||||
## the Python batch handler only allows 30s anyway — call the test_run tool
|
||||
## directly.
|
||||
## - game_command: it is deferred — its reply flows out-of-band correlated by a
|
||||
## _request_id that dispatch_direct deliberately strips (see
|
||||
## McpDispatcher.dispatch_direct), so a game op nested in a batch would have
|
||||
## no completion channel and hang or lose its reply. input_sequence made this
|
||||
## concrete (#814); the whole game_command surface shares the deferred path.
|
||||
const FORBIDDEN_SUBCOMMANDS := {
|
||||
"batch_execute": "batch_execute cannot be nested inside another batch",
|
||||
"run_tests":
|
||||
"run_tests is not allowed as a sub-command — a batch runs synchronously "
|
||||
+ "with no transport servicing; call the test_run tool directly",
|
||||
"game_command":
|
||||
"game_command ops are deferred (their reply arrives out-of-band) and "
|
||||
+ "have no completion channel inside a batch — run them as their own tool call",
|
||||
}
|
||||
|
||||
## The whole batch executes synchronously inside one dispatcher tick,
|
||||
## outside the 4ms frame budget — an unbounded array freezes the editor
|
||||
## for the batch's full duration. 500 is far above any legitimate scene
|
||||
## edit while keeping worst-case stalls in check.
|
||||
const MAX_BATCH_COMMANDS := 500
|
||||
|
||||
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")
|
||||
if commands.size() > MAX_BATCH_COMMANDS:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"commands exceeds the %d-command batch cap (got %d) — split into multiple batches" % [MAX_BATCH_COMMANDS, commands.size()]
|
||||
)
|
||||
|
||||
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 FORBIDDEN_SUBCOMMANDS.has(cmd_name):
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"commands[%d]: %s" % [idx, FORBIDDEN_SUBCOMMANDS[cmd_name]])
|
||||
if not _dispatcher.has_command(cmd_name):
|
||||
return _unknown_command_error(idx, cmd_name)
|
||||
## Pre-validate params type: the execution loop's typed Dictionary
|
||||
## local would hard-error on a non-dict mid-batch, aborting AFTER
|
||||
## earlier mutations committed. Catching it here keeps the
|
||||
## all-or-nothing contract for malformed input.
|
||||
if typeof(item.get("params", {})) != TYPE_DICTIONARY:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d].params must be a dict" % idx)
|
||||
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,132 @@
|
||||
@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:
|
||||
## Camera-specific sugar kept from the pre-#714 copy: a bare number is
|
||||
## a uniform zoom, splatted to both axes. Everything else goes through
|
||||
## the canonical strict parser.
|
||||
if value is int or value is float:
|
||||
return Vector2(float(value), float(value))
|
||||
return McpJsonValues.parse_vector2(value)
|
||||
|
||||
|
||||
static func parse_vector3(value: Variant) -> Variant:
|
||||
return McpJsonValues.parse_vector3(value)
|
||||
|
||||
|
||||
## 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,123 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Handles MCP client configuration commands.
|
||||
|
||||
var _connection
|
||||
var _fallback_launch_context := {}
|
||||
var _status_workers: Array[Thread] = []
|
||||
var _status_tearing_down := false
|
||||
|
||||
|
||||
func _init(connection = null, fallback_launch_context = null) -> void:
|
||||
_connection = connection
|
||||
# Lazy-loading this handler can reload ClientConfigurator's static script and
|
||||
# clear its warmed snapshot. Retain the plugin-start capture as a safe
|
||||
# fallback; the worker still prefers capture_launch_context()'s live snapshot.
|
||||
if fallback_launch_context is Dictionary:
|
||||
_fallback_launch_context = fallback_launch_context.duplicate(true)
|
||||
|
||||
|
||||
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 request_id: String = params.get("_request_id", "")
|
||||
if _connection == null or request_id.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Client status requires a deferred request context.",
|
||||
)
|
||||
if _status_tearing_down:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Client status handler is shutting down.")
|
||||
# Match the dock refresh worker's cold-load guard. This is pure-memory and
|
||||
# performs no launcher discovery, CLI lookup, or config/status probe.
|
||||
McpClientConfigurator.warm_status_worker_bytecode()
|
||||
# The aggregate command has a 30-second budget. Its worker resolves the
|
||||
# shared Claude Desktop/Codex attach launch once, then reuses it for every
|
||||
# command-shaped client instead of repeating cold launcher discovery.
|
||||
# Start the worker from the deferred finisher after its first frame. That
|
||||
# lets the dispatcher register this request before any probe can complete.
|
||||
_finish_client_status_deferred(
|
||||
McpClientConfigurator.run_client_status_sweep.bind(_fallback_launch_context),
|
||||
request_id,
|
||||
_connection,
|
||||
)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
|
||||
## Called by McpDispatcher.clear() before releasing this lazy handler. Marking
|
||||
## teardown is deliberately non-blocking: the in-flight coroutine retains this
|
||||
## handler and its connection across frames, then joins each worker only after
|
||||
## is_alive() becomes false. New sweeps are rejected immediately.
|
||||
func prepare_for_teardown() -> void:
|
||||
_status_tearing_down = true
|
||||
|
||||
|
||||
## This instance coroutine intentionally keeps the lazily-created handler and
|
||||
## deferred-response connection alive until its worker has been polled and
|
||||
## joined. The first-frame yield is load-bearing: check_client_status() must
|
||||
## return the deferred sentinel before the worker can produce a response.
|
||||
func _finish_client_status_deferred(
|
||||
worker_callable: Callable, request_id: String, connection
|
||||
) -> void:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var tree: SceneTree = connection.get_tree()
|
||||
if tree == null:
|
||||
return
|
||||
await tree.process_frame
|
||||
if not is_instance_valid(connection) or _status_tearing_down:
|
||||
return
|
||||
var worker := Thread.new()
|
||||
_status_workers.append(worker)
|
||||
var start_error := worker.start(worker_callable)
|
||||
if start_error != OK:
|
||||
_status_workers.erase(worker)
|
||||
connection.send_deferred_response(request_id, ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Could not start client status worker (error %d)." % start_error,
|
||||
))
|
||||
return
|
||||
while worker.is_alive():
|
||||
await tree.process_frame
|
||||
var payload: Variant = worker.wait_to_finish()
|
||||
_status_workers.erase(worker)
|
||||
if _status_tearing_down:
|
||||
return
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
if not payload is Dictionary:
|
||||
payload = ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Client status worker returned an invalid response.",
|
||||
)
|
||||
elif payload.has("worker_error"):
|
||||
payload = ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
str(payload.get("worker_error", "Client status worker failed.")),
|
||||
)
|
||||
connection.send_deferred_response(request_id, payload)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmo4foc5fq75c
|
||||
@@ -0,0 +1,318 @@
|
||||
@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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
## 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,118 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## CSG authoring — create CSG shapes (box, sphere, cylinder, torus, prism)
|
||||
## and set their boolean operation (union / intersection / subtraction) so
|
||||
## agents can carve geometry (holes, caves, tunnels) directly in the editor.
|
||||
##
|
||||
## All ops target nodes in the currently edited scene by scene-relative
|
||||
## path. All write ops are undoable via EditorUndoRedoManager. Sibling CSG
|
||||
## shapes under the same parent combine automatically; use a CSGCombiner3D
|
||||
## parent when you need explicit grouping.
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
const SHAPES := {
|
||||
"box": "CSGBox3D",
|
||||
"sphere": "CSGSphere3D",
|
||||
"cylinder": "CSGCylinder3D",
|
||||
"torus": "CSGTorus3D",
|
||||
"polygon": "CSGPolygon3D",
|
||||
}
|
||||
|
||||
const OPERATIONS := {
|
||||
"union": CSGShape3D.OPERATION_UNION,
|
||||
"intersection": CSGShape3D.OPERATION_INTERSECTION,
|
||||
"subtraction": CSGShape3D.OPERATION_SUBTRACTION,
|
||||
}
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
## Create a CSG shape under a Node3D parent.
|
||||
## params: {parent_path, name="", shape="box", operation="union"}
|
||||
## Returns: {path, name, shape, operation, undoable}
|
||||
func create(params: Dictionary) -> Dictionary:
|
||||
var parent_path: String = params.get("parent_path", "")
|
||||
var shape: String = params.get("shape", "box")
|
||||
var operation: String = params.get("operation", "union")
|
||||
var scene_file: String = params.get("scene_file", "")
|
||||
|
||||
var shape_class: String = SHAPES.get(shape, "")
|
||||
if shape_class.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Unknown shape: %s. Valid shapes: %s" % [shape, ", ".join(SHAPES.keys())])
|
||||
if not OPERATIONS.has(operation):
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())])
|
||||
|
||||
var scene_check := McpScenePath.require_edited_scene(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))
|
||||
if not parent is Node3D:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||
"CSG parent must be a Node3D (got %s)" % parent.get_class())
|
||||
|
||||
var node: CSGShape3D = ClassDB.instantiate(shape_class)
|
||||
if node == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class)
|
||||
var node_name: String = params.get("name", "")
|
||||
if node_name.is_empty():
|
||||
node_name = shape_class
|
||||
node.name = node_name
|
||||
node.operation = OPERATIONS[operation]
|
||||
|
||||
_undo_redo.create_action("MCP: Create %s" % 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),
|
||||
"name": node.name,
|
||||
"shape": shape,
|
||||
"operation": operation,
|
||||
"undoable": true,
|
||||
}}
|
||||
|
||||
|
||||
## Set the boolean operation of a CSG shape.
|
||||
## params: {path, operation}
|
||||
## Returns: {operation, undoable}
|
||||
func set_operation(params: Dictionary) -> Dictionary:
|
||||
var operation: String = params.get("operation", "")
|
||||
if not OPERATIONS.has(operation):
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())])
|
||||
var resolved := McpNodeValidator.resolve_or_error(
|
||||
params.get("path", ""), "path", params.get("scene_file", ""))
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
if not node is CSGShape3D:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||
"Node is not a CSGShape3D: %s" % params.get("path", ""))
|
||||
var shape: CSGShape3D = node
|
||||
var prev: int = shape.operation
|
||||
var next: int = OPERATIONS[operation]
|
||||
## Target the node in both callbacks so the action lands in the scene
|
||||
## undo history (first-target routing); `set` exists on every Object.
|
||||
_undo_redo.create_action("MCP: CSG set_operation")
|
||||
_undo_redo.add_do_method(shape, "set", "operation", next)
|
||||
_undo_redo.add_undo_method(shape, "set", "operation", prev)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"operation": operation, "undoable": true}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4mb0tptu52x2
|
||||
@@ -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,
|
||||
McpPropertyErrors.build_message(node, property)
|
||||
)
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,312 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd")
|
||||
|
||||
## Handles file read/write operations and reimport within the Godot project.
|
||||
|
||||
## Bounds for the deferred scan wait. `write_file`/`reimport` register single
|
||||
## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem`
|
||||
## is the heavier, explicit "rebuild the class registry" path agents call after
|
||||
## adding `class_name` scripts headlessly (no window focus to trigger it).
|
||||
## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we
|
||||
## always send a real reply before a DEFERRED_TIMEOUT is synthesised.
|
||||
const _SCAN_START_GRACE_MSEC := 750
|
||||
const _SCAN_SETTLE_MAX_MSEC := 28000
|
||||
|
||||
## Sidecar the editor writes next to every imported resource. `reimport` reads
|
||||
## it to tell imported assets from files that merely have a filesystem entry
|
||||
## (see `_is_imported_resource`).
|
||||
const IMPORT_SIDECAR_SUFFIX := ".import"
|
||||
|
||||
## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't
|
||||
## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()`
|
||||
## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing
|
||||
## in that window would observe `false` and stack another scan() — the exact
|
||||
## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is
|
||||
## set before the first scan() and cleared when its settle coroutine finishes;
|
||||
## concurrent requests coalesce onto the running scan instead of starting one.
|
||||
## `static` so it's shared across handler instances; it resets on plugin reload
|
||||
## (script re-parse), which self-heals any latch orphaned by a mid-await teardown.
|
||||
static var _scan_in_flight := false
|
||||
|
||||
var _connection: McpConnection
|
||||
|
||||
|
||||
func _init(connection: McpConnection = null) -> void:
|
||||
_connection = connection
|
||||
|
||||
|
||||
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
|
||||
|
||||
var existed_before := FileAccess.file_exists(path)
|
||||
|
||||
# Shared write path (#714): parent mkdir + write/flush + explicit error
|
||||
# check live on McpResourceIO so this can't drift from create_script.
|
||||
var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content)
|
||||
if write_failure != null:
|
||||
return write_failure
|
||||
|
||||
# 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",
|
||||
}
|
||||
var is_gdscript := path.ends_with(".gd")
|
||||
## A .gd written through the filesystem tool used to skip the parse
|
||||
## diagnostics create_script attaches (#714) — the agent's broken
|
||||
## script reported plain success and the parse error surfaced only in
|
||||
## later editor logs. Same shared check, same response fields. A bare
|
||||
## ScriptHandler works here: the diagnostics path touches no instance
|
||||
## state (it stays an instance method only for test stubbing).
|
||||
if is_gdscript:
|
||||
ScriptHandler.new(null)._attach_gdscript_diagnostics(data, path, content)
|
||||
data["committed"] = true
|
||||
data["import_settled"] = existed_before
|
||||
data["import_settle"] = "already_known" if existed_before else "not_waited"
|
||||
McpResourceIO.attach_cleanup_hint(data, existed_before, [path])
|
||||
|
||||
## Fresh `.gd` writes take create_script's import-settle deferral (#714,
|
||||
## #261): reply only once ResourceLoader can see the new resource (or the
|
||||
## bounded window elapses), so write_file -> script_attach back-to-back
|
||||
## can't 404 on the not-yet-imported script. This CHANGES write_file's
|
||||
## response timing for that case — the reply lands up to
|
||||
## McpResourceIO.IMPORT_SETTLE_MAX_MSEC later instead of immediately.
|
||||
## Scoped to .gd: ResourceLoader never learns plain text files, so an
|
||||
## unconditional wait would burn the full window on every fresh .txt.
|
||||
## Overwrites, batch_execute (no request_id) and unit-test contexts (no
|
||||
## connection) keep the synchronous reply.
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
if is_gdscript and not existed_before and _connection != null and not request_id.is_empty():
|
||||
McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"EditorFileSystem not available", false)
|
||||
|
||||
var reimported: Array[String] = []
|
||||
var skipped_non_imported: 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)
|
||||
if _is_imported_resource(path):
|
||||
reimported.append(path)
|
||||
else:
|
||||
skipped_non_imported.append(path)
|
||||
|
||||
var data := {
|
||||
"reimported": reimported,
|
||||
"skipped_non_imported": skipped_non_imported,
|
||||
"not_found": not_found,
|
||||
"reimported_count": reimported.size(),
|
||||
"skipped_non_imported_count": skipped_non_imported.size(),
|
||||
"not_found_count": not_found.size(),
|
||||
"undoable": false,
|
||||
"reason": "Reimport is a file system operation",
|
||||
}
|
||||
## Only when it applies: a hint on every call would cost tokens on the
|
||||
## all-assets path this op is actually for.
|
||||
if not skipped_non_imported.is_empty():
|
||||
data["skipped_non_imported_hint"] = (
|
||||
"%d path(s) are not imported resources. Their editor filesystem entry was "
|
||||
+ "refreshed, but no import ran — a success here is not evidence that a "
|
||||
+ "script parsed or that diagnostics were produced. Use script_patch/"
|
||||
+ "script_create for GDScript diagnostics, or filesystem_manage(op=\"scan\") "
|
||||
+ "for an asset the editor has not imported yet."
|
||||
) % skipped_non_imported.size()
|
||||
return {"data": data}
|
||||
|
||||
|
||||
## #778: `update_file()` registers a path with the resource pipeline; it only
|
||||
## runs an *import* for files that have one. Scripts, scenes and hand-written
|
||||
## `.tres` are not imported resources, so listing them under `reimported` reads
|
||||
## as proof that a parse or import ran when nothing did.
|
||||
##
|
||||
## The `.import` sidecar is the editor's own record that a path goes through
|
||||
## the import pipeline, so it decides the split. An extension allow-list was
|
||||
## rejected: importers come and go with plugins, so the list would drift out of
|
||||
## agreement with the editor it claims to describe.
|
||||
##
|
||||
## Known edge: an asset the editor has never imported (just written, no scan
|
||||
## yet) has no sidecar and reports as non-imported. That is accurate at the
|
||||
## moment of the call — `update_file()` did not import it either — and the
|
||||
## hint names `scan` as the way through.
|
||||
##
|
||||
## Behaviour is unchanged for every path: `update_file()` still runs on all of
|
||||
## them, because refreshing an externally-edited `.tscn`/`.tres` is a real use
|
||||
## of this op. This splits the report, not the work.
|
||||
static func _is_imported_resource(path: String) -> bool:
|
||||
if path.ends_with(IMPORT_SIDECAR_SUFFIX):
|
||||
return false ## The sidecar itself is not an imported resource.
|
||||
return FileAccess.file_exists(path + IMPORT_SIDECAR_SUFFIX)
|
||||
|
||||
|
||||
## Force a full EditorFileSystem scan and wait for it to settle. This is the
|
||||
## headless equivalent of the editor regaining window focus: `update_file()`
|
||||
## (used by write_file/reimport/script_create) registers a single file with the
|
||||
## resource pipeline but does NOT rebuild the global `class_name` table, so a
|
||||
## freshly-created `class_name MyThing extends Resource` stays invisible to
|
||||
## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents
|
||||
## driving the editor without focus call this once after a batch of script
|
||||
## creates to make new types instantiable/referenceable. See issue #83.
|
||||
func scan_filesystem(params: Dictionary) -> Dictionary:
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs == null:
|
||||
return ErrorCodes.make_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"EditorFileSystem not available", false)
|
||||
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
# Async path: a scan can't be awaited on the calling frame without freezing
|
||||
# the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and
|
||||
# push the real reply from a static coroutine once the scan settles — by
|
||||
# which point new class_names are registered.
|
||||
if _connection != null and not request_id.is_empty():
|
||||
_finish_scan_deferred(_connection, request_id, efs)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
|
||||
# (no connection) can't await, so kick a single-flight scan and return
|
||||
# immediately without the settle confirmation. Respect the latch so we don't
|
||||
# stack onto a deferred scan; don't set it (there's no coroutine here to
|
||||
# clear it — the brief is_scanning() window covers the rest).
|
||||
var already := _scan_in_flight or efs.is_scanning()
|
||||
if not already:
|
||||
efs.scan()
|
||||
return {
|
||||
"data": {
|
||||
"scan_completed": false,
|
||||
"scan_settle": "not_waited",
|
||||
"was_already_scanning": already,
|
||||
"global_class_count": ProjectSettings.get_global_class_list().size(),
|
||||
# Present in both paths for a consistent response shape; the sync
|
||||
# path doesn't await, so it can't measure a delta.
|
||||
"global_classes_registered_delta": 0,
|
||||
"undoable": false,
|
||||
"reason": "Filesystem scan is an editor operation",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## `static` is load-bearing for the same reason as ScriptHandler's deferred
|
||||
## finish: the coroutine must outlive the handler RefCounted, which can be freed
|
||||
## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise
|
||||
## everything; reference no instance state.
|
||||
static func _finish_scan_deferred(
|
||||
connection: McpConnection,
|
||||
request_id: String,
|
||||
efs: EditorFileSystem,
|
||||
) -> void:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var tree := connection.get_tree()
|
||||
if tree == null:
|
||||
return
|
||||
var classes_before := ProjectSettings.get_global_class_list().size()
|
||||
# Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(),
|
||||
# which lags scan() by a frame or two — see the latch declaration). Only the
|
||||
# request that sets the latch calls scan(); concurrent requests coalesce and
|
||||
# just await the running scan. This is what actually prevents the stacked
|
||||
# scan() SIGABRT (dsarno/godot#6), even within the start-grace window.
|
||||
var was_already_scanning := _scan_in_flight or efs.is_scanning()
|
||||
var we_started := not was_already_scanning
|
||||
if we_started:
|
||||
_scan_in_flight = true
|
||||
efs.scan()
|
||||
# Hand back a frame so _dispatch() registers this request as deferred before
|
||||
# the coroutine can push a reply (mirrors McpResourceIO.finish_text_write_deferred).
|
||||
await tree.process_frame
|
||||
var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC
|
||||
var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC
|
||||
var saw_scanning := efs.is_scanning()
|
||||
while Time.get_ticks_msec() < deadline_ms:
|
||||
if efs.is_scanning():
|
||||
saw_scanning = true
|
||||
elif saw_scanning or Time.get_ticks_msec() > start_grace_ms:
|
||||
# Either the scan ran and finished, or it never flipped is_scanning()
|
||||
# within the grace window (a no-op scan because nothing changed).
|
||||
break
|
||||
await tree.process_frame
|
||||
# Clear the latch in all paths (no try/finally in GDScript): do it before the
|
||||
# is_instance_valid early-return so a freed connection can't orphan it.
|
||||
if we_started:
|
||||
_scan_in_flight = false
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var completed := not efs.is_scanning()
|
||||
var classes_after := ProjectSettings.get_global_class_list().size()
|
||||
connection.send_deferred_response(request_id, {
|
||||
"data": {
|
||||
"scan_completed": completed,
|
||||
"scan_settle": "settled" if completed else "timeout",
|
||||
"was_already_scanning": was_already_scanning,
|
||||
"global_class_count": classes_after,
|
||||
"global_classes_registered_delta": classes_after - classes_before,
|
||||
"undoable": false,
|
||||
"reason": "Filesystem scan is an editor operation",
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7ovtpdiumtju
|
||||
@@ -0,0 +1,192 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## GridMap authoring — set, fill, clear, and read 3D cells plus mesh-library
|
||||
## items directly in the editor scene with full undo/redo support.
|
||||
##
|
||||
## All ops target GridMap nodes in the currently edited scene by
|
||||
## scene-relative path (e.g. "/Main/Terrain"). All write ops are undoable
|
||||
## via EditorUndoRedoManager.
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const MAX_FILL_CELLS := 4096
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
## Set a single cell item. item = -1 erases the cell.
|
||||
## params: {path, item, map_x, map_y, map_z, orientation=0}
|
||||
## Returns: {map_x, map_y, map_z, item, orientation, undoable}
|
||||
func set_item(params: Dictionary) -> Dictionary:
|
||||
var gm := _resolve_gridmap(params)
|
||||
if gm.has("error"): return gm
|
||||
var node: GridMap = gm.node
|
||||
var pos := Vector3i(int(params.get("map_x", 0)), int(params.get("map_y", 0)), int(params.get("map_z", 0)))
|
||||
var item := int(params.get("item", 0))
|
||||
var orientation := int(params.get("orientation", 0))
|
||||
if orientation < 0 or orientation > 24:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation)
|
||||
var prev := _capture_cell_state(node, pos)
|
||||
_undo_redo.create_action("MCP: GridMap set_item")
|
||||
_undo_redo.add_do_method(node, "set_cell_item", pos, item, orientation)
|
||||
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"map_x": pos.x, "map_y": pos.y, "map_z": pos.z,
|
||||
"item": item, "orientation": orientation, "undoable": true}}
|
||||
|
||||
|
||||
## Fill a box region with one item in a single undo action.
|
||||
## params: {path, item, rect_x, rect_y, rect_z, rect_w, rect_h, rect_d, orientation=0}
|
||||
## Returns: {cells_filled, rect: {x, y, z, w, h, d}}
|
||||
func fill(params: Dictionary) -> Dictionary:
|
||||
var gm := _resolve_gridmap(params)
|
||||
if gm.has("error"): return gm
|
||||
var node: GridMap = gm.node
|
||||
var item := int(params.get("item", 0))
|
||||
var orientation := int(params.get("orientation", 0))
|
||||
if orientation < 0 or orientation > 24:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation)
|
||||
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0)); var rz := int(params.get("rect_z", 0))
|
||||
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1)); var rd := int(params.get("rect_d", 1))
|
||||
if rw <= 0 or rh <= 0 or rd <= 0:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"rect_w, rect_h and rect_d must be > 0 (got %d x %d x %d)" % [rw, rh, rd])
|
||||
var cell_count := rw * rh * rd
|
||||
if cell_count > MAX_FILL_CELLS:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Region too large: %d cells exceeds max %d" % [cell_count, MAX_FILL_CELLS])
|
||||
var snapshot: Array[Dictionary] = []
|
||||
for x in range(rx, rx + rw):
|
||||
for y in range(ry, ry + rh):
|
||||
for z in range(rz, rz + rd):
|
||||
var pos := Vector3i(x, y, z)
|
||||
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
|
||||
_undo_redo.create_action("MCP: GridMap fill %dx%dx%d" % [rw, rh, rd])
|
||||
## First callback targets the node so the action lands in the scene undo
|
||||
## history (first-target routing); _apply_fill batches the rest of the
|
||||
## region into a single history entry instead of one per cell.
|
||||
_undo_redo.add_do_method(node, "set_cell_item", snapshot[0].pos, item, orientation)
|
||||
_undo_redo.add_do_method(self, "_apply_fill", node, snapshot, item, orientation)
|
||||
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"cells_filled": snapshot.size(),
|
||||
"rect": {"x": rx, "y": ry, "z": rz, "w": rw, "h": rh, "d": rd}, "undoable": true}}
|
||||
|
||||
|
||||
## Remove all cells from the GridMap.
|
||||
## params: {path}
|
||||
## Returns: {cleared: true}
|
||||
func clear_layer(params: Dictionary) -> Dictionary:
|
||||
var gm := _resolve_gridmap(params)
|
||||
if gm.has("error"): return gm
|
||||
var node: GridMap = gm.node
|
||||
var used := node.get_used_cells()
|
||||
if used.size() > MAX_FILL_CELLS:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"GridMap has %d cells, exceeds max %d for undoable clear"
|
||||
% [used.size(), MAX_FILL_CELLS])
|
||||
var snapshot := _capture_used_cells_snapshot(node)
|
||||
_undo_redo.create_action("MCP: GridMap clear")
|
||||
_undo_redo.add_do_method(node, "clear")
|
||||
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"cleared": true, "undoable": true}}
|
||||
|
||||
|
||||
## Return all used cell coordinates.
|
||||
## params: {path}
|
||||
## Returns: {cells: [{x, y, z}, ...], count: int}
|
||||
func get_used_cells(params: Dictionary) -> Dictionary:
|
||||
var gm := _resolve_gridmap(params)
|
||||
if gm.has("error"): return gm
|
||||
var node: GridMap = gm.node
|
||||
var result: Array = []
|
||||
for c in node.get_used_cells():
|
||||
result.append({"x": c.x, "y": c.y, "z": c.z})
|
||||
return {"data": {"cells": result, "count": result.size()}}
|
||||
|
||||
|
||||
## List the items available in the GridMap's MeshLibrary, so agents can
|
||||
## discover item ids and names before placing cells (the 3D analogue of
|
||||
## tileset atlas inspection).
|
||||
## params: {path}
|
||||
## Returns: {library: res:// path or "", items: [{item, name, mesh}...], count}
|
||||
func list_library_items(params: Dictionary) -> Dictionary:
|
||||
var gm := _resolve_gridmap(params)
|
||||
if gm.has("error"): return gm
|
||||
var node: GridMap = gm.node
|
||||
var library: MeshLibrary = node.mesh_library
|
||||
if library == null:
|
||||
return {"data": {"library": "", "items": [], "count": 0}}
|
||||
var items: Array = []
|
||||
var ids := library.get_item_list()
|
||||
ids.sort()
|
||||
for item in ids:
|
||||
var mesh: Mesh = library.get_item_mesh(item)
|
||||
var mesh_path := mesh.resource_path if mesh != null else ""
|
||||
items.append({
|
||||
"item": item,
|
||||
"name": library.get_item_name(item),
|
||||
"mesh": mesh_path,
|
||||
})
|
||||
return {"data": {"library": library.resource_path, "items": items, "count": items.size()}}
|
||||
|
||||
|
||||
## Resolve a GridMap node from params["path"] in the currently edited
|
||||
## scene. Returns {"node": GridMap} on success, or an error dict.
|
||||
func _resolve_gridmap(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var scene_file: String = params.get("scene_file", "")
|
||||
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
if not node is GridMap:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||
"Node is not a GridMap: %s" % path)
|
||||
return {"node": node}
|
||||
|
||||
|
||||
func _capture_cell_state(node: GridMap, pos: Vector3i) -> Dictionary:
|
||||
var item := node.get_cell_item(pos)
|
||||
if item == -1:
|
||||
return {"has_item": false}
|
||||
return {"has_item": true, "item": item, "orientation": node.get_cell_item_orientation(pos)}
|
||||
|
||||
|
||||
func _capture_used_cells_snapshot(node: GridMap) -> Array[Dictionary]:
|
||||
var snapshot: Array[Dictionary] = []
|
||||
for pos in node.get_used_cells():
|
||||
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
|
||||
return snapshot
|
||||
|
||||
|
||||
func _restore_cells_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void:
|
||||
node.clear()
|
||||
for entry in snapshot:
|
||||
_restore_cell_state(node, entry.pos, entry.state)
|
||||
|
||||
|
||||
## Batched do-method for fill: one undo-history entry applies the whole
|
||||
## region instead of one entry per cell.
|
||||
func _apply_fill(node: GridMap, snapshot: Array[Dictionary], item: int, orientation: int) -> void:
|
||||
for entry in snapshot:
|
||||
node.set_cell_item(entry.pos, item, orientation)
|
||||
|
||||
|
||||
func _restore_rect_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void:
|
||||
for entry in snapshot:
|
||||
_restore_cell_state(node, entry.pos, entry.state)
|
||||
|
||||
|
||||
func _restore_cell_state(node: GridMap, pos: Vector3i, state: Dictionary) -> void:
|
||||
if not state.get("has_item", false):
|
||||
node.set_cell_item(pos, -1)
|
||||
return
|
||||
node.set_cell_item(pos, int(state.get("item", 0)), int(state.get("orientation", 0)))
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdv1yrjeyo5es
|
||||
@@ -0,0 +1,462 @@
|
||||
@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] = []
|
||||
var seen := {}
|
||||
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
|
||||
seen[name_str] = true
|
||||
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,
|
||||
"loaded_in_input_map": true,
|
||||
})
|
||||
for action_name in user_authored.keys():
|
||||
var name_str := str(action_name)
|
||||
if seen.has(name_str):
|
||||
continue
|
||||
var setting: Dictionary = user_authored.get(name_str, {})
|
||||
var events: Array[Dictionary] = []
|
||||
for event in setting.get("events", []):
|
||||
if event is InputEvent:
|
||||
events.append(_serialize_event(event))
|
||||
else:
|
||||
events.append({"type": type_string(typeof(event)), "string": str(event)})
|
||||
actions.append({
|
||||
"name": name_str,
|
||||
"events": events,
|
||||
"event_count": events.size(),
|
||||
"is_builtin": false,
|
||||
"loaded_in_input_map": false,
|
||||
})
|
||||
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"):
|
||||
var value = cfg.get_value("input", key, {})
|
||||
result[key] = value if value is Dictionary else {}
|
||||
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")
|
||||
|
||||
var deadzone_error := _validate_deadzone(deadzone)
|
||||
if deadzone_error.has("error"):
|
||||
return deadzone_error
|
||||
|
||||
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 ensure_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")
|
||||
|
||||
var deadzone_error := _validate_deadzone(deadzone)
|
||||
if deadzone_error.has("error"):
|
||||
return deadzone_error
|
||||
|
||||
var result := _ensure_action_state(action, deadzone)
|
||||
if result.has("error"):
|
||||
return result
|
||||
return {"data": result}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
var key := "input/%s" % action
|
||||
var was_loaded := InputMap.has_action(action)
|
||||
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
|
||||
|
||||
## An action can live in the editor process's InputMap, in project.godot,
|
||||
## or both. Actions persisted by a previous editor session exist only on
|
||||
## disk (`loaded_in_input_map: false` in list_actions) — those must still
|
||||
## be removable. #632
|
||||
if not was_loaded and old_setting == null:
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
|
||||
|
||||
if was_loaded:
|
||||
InputMap.erase_action(action)
|
||||
|
||||
if old_setting != null:
|
||||
ProjectSettings.clear(key)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
if was_loaded:
|
||||
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,
|
||||
"was_loaded": was_loaded,
|
||||
"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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func ensure_binding(params: Dictionary) -> Dictionary:
|
||||
var action: String = params.get("action", "")
|
||||
var event_type: String = params.get("event_type", "")
|
||||
var deadzone: float = params.get("deadzone", 0.5)
|
||||
|
||||
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")
|
||||
|
||||
var deadzone_error := _validate_deadzone(deadzone)
|
||||
if deadzone_error.has("error"):
|
||||
return deadzone_error
|
||||
|
||||
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
|
||||
|
||||
var ensured := _ensure_action_state(action, deadzone)
|
||||
if ensured.has("error"):
|
||||
return ensured
|
||||
|
||||
for existing in InputMap.action_get_events(action):
|
||||
if _events_match(existing, event):
|
||||
return {
|
||||
"data": {
|
||||
"action": action,
|
||||
"event": _serialize_event(existing),
|
||||
"already_bound": true,
|
||||
"action_created": ensured.get("created", false),
|
||||
"undoable": false,
|
||||
"reason": "Input binding already exists",
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
"already_bound": false,
|
||||
"action_created": ensured.get("created", false),
|
||||
"undoable": false,
|
||||
"reason": "Input bindings are saved to project.godot",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _validate_deadzone(deadzone: float) -> Dictionary:
|
||||
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)
|
||||
return {}
|
||||
|
||||
|
||||
func _ensure_action_state(action: String, deadzone: float) -> Dictionary:
|
||||
var key := "input/%s" % action
|
||||
var user_authored := _read_user_authored_actions()
|
||||
var existed_in_input_map := InputMap.has_action(action)
|
||||
var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key)
|
||||
var old_setting = user_authored.get(action, null) if user_authored.has(action) else null
|
||||
if old_setting == null and ProjectSettings.has_setting(key):
|
||||
old_setting = ProjectSettings.get_setting(key)
|
||||
|
||||
if not existed_in_input_map:
|
||||
var dz := deadzone
|
||||
if old_setting is Dictionary:
|
||||
dz = float(old_setting.get("deadzone", deadzone))
|
||||
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)
|
||||
|
||||
if not existed_in_project:
|
||||
var err := _save_action_events(action)
|
||||
if err != OK:
|
||||
if not existed_in_input_map:
|
||||
InputMap.erase_action(action)
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err])
|
||||
|
||||
var stored_deadzone := deadzone
|
||||
if ProjectSettings.has_setting(key):
|
||||
var stored = ProjectSettings.get_setting(key)
|
||||
if stored is Dictionary:
|
||||
stored_deadzone = float(stored.get("deadzone", deadzone))
|
||||
return {
|
||||
"action": action,
|
||||
"deadzone": stored_deadzone,
|
||||
"created": not existed_in_input_map and not existed_in_project,
|
||||
"already_exists": existed_in_input_map or existed_in_project,
|
||||
"loaded_in_input_map": true,
|
||||
"persisted": true,
|
||||
"undoable": false,
|
||||
"reason": "Input actions 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)
|
||||
ev.device = -1
|
||||
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
|
||||
ev.device = -1
|
||||
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 _events_match(a: InputEvent, b: InputEvent) -> bool:
|
||||
if a is InputEventKey and b is InputEventKey:
|
||||
return _key_events_match(a as InputEventKey, b as InputEventKey)
|
||||
return _serialize_event(a) == _serialize_event(b)
|
||||
|
||||
|
||||
func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool:
|
||||
if a.ctrl_pressed != b.ctrl_pressed:
|
||||
return false
|
||||
if a.alt_pressed != b.alt_pressed:
|
||||
return false
|
||||
if a.shift_pressed != b.shift_pressed:
|
||||
return false
|
||||
if a.meta_pressed != b.meta_pressed:
|
||||
return false
|
||||
var a_codes := [a.keycode, a.physical_keycode]
|
||||
var b_codes := [b.keycode, b.physical_keycode]
|
||||
for a_code in a_codes:
|
||||
if int(a_code) == KEY_NONE:
|
||||
continue
|
||||
for b_code in b_codes:
|
||||
if int(b_code) != KEY_NONE and int(a_code) == int(b_code):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
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)
|
||||
elif InputMap.has_action(action):
|
||||
deadzone = InputMap.action_get_deadzone(action)
|
||||
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,809 @@
|
||||
@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
|
||||
var _connection: McpConnection
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
|
||||
_undo_redo = undo_redo
|
||||
_connection = connection
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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 := McpResourceIO.guarded_save(mat, path, _connection)
|
||||
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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"EditorFileSystem not available", false)
|
||||
|
||||
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
|
||||
var overwritten := 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
|
||||
# Same clobber guard as create_material/apply_preset: agents reuse
|
||||
# names like res://materials/metal.tres, and a silent save here
|
||||
# destroys a hand-authored file that undo can't restore (undo only
|
||||
# reverts the node's slot assignment, not file contents). See #685.
|
||||
var existed_before := FileAccess.file_exists(save_to)
|
||||
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)" % save_to
|
||||
)
|
||||
overwritten = existed_before
|
||||
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 := McpResourceIO.guarded_save(mat, save_to, _connection)
|
||||
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 "",
|
||||
"overwritten": overwritten,
|
||||
"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. Validate the path BEFORE the exists/overwrite
|
||||
# check, matching create_material's order — an invalid path should
|
||||
# always be reported as invalid, not as an overwrite conflict.
|
||||
var path_err := _validate_material_path(path, "path", true)
|
||||
if path_err != null:
|
||||
return path_err
|
||||
|
||||
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 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 := McpResourceIO.guarded_save(mat, path, _connection)
|
||||
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:
|
||||
push_warning("MCP: Failed to load material for undo/redo: %s" % mat_path)
|
||||
return
|
||||
mat.set(property, value)
|
||||
McpResourceIO.guarded_save(mat, mat_path, _connection)
|
||||
|
||||
|
||||
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):
|
||||
push_warning("MCP: Failed to load shader material for undo/redo: %s" % mat_path)
|
||||
return
|
||||
(mat as ShaderMaterial).set_shader_parameter(param_name, value)
|
||||
McpResourceIO.guarded_save(mat, mat_path, _connection)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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,194 @@
|
||||
@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(aa)", named string, {r,g,b[,a]} dict,
|
||||
## or [r,g,b(,a)] array. Delegates to the canonical parser (#714); returns
|
||||
## null if the input cannot be parsed.
|
||||
static func parse_color(value: Variant) -> Variant:
|
||||
return McpJsonValues.parse_color(value)
|
||||
|
||||
|
||||
static func parse_vector3(value: Variant) -> Variant:
|
||||
return McpJsonValues.parse_vector3(value)
|
||||
|
||||
|
||||
static func parse_vector2(value: Variant) -> Variant:
|
||||
return McpJsonValues.parse_vector2(value)
|
||||
|
||||
|
||||
## 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://qhhd5mm5awym
|
||||
@@ -0,0 +1,860 @@
|
||||
@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({}).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
|
||||
|
||||
# User-supplied override keys per group. Preset-blueprint keys may skip
|
||||
# silently on types they don't apply to (presets are cross-type by
|
||||
# design); user-requested overrides must apply or error (#770).
|
||||
var user_keys: Dictionary = blueprint.get("user_keys", {})
|
||||
var user_main: Dictionary = user_keys.get("main", {})
|
||||
var user_process: Dictionary = user_keys.get("process", {})
|
||||
var user_draw: Dictionary = user_keys.get("draw", {})
|
||||
|
||||
var draw_mesh: Mesh = null
|
||||
var draw_material: StandardMaterial3D = null
|
||||
var draw_pass_mesh_created := false
|
||||
var draw_material_created := false
|
||||
var applied_draw: Array[String] = []
|
||||
var draw_config: Dictionary = blueprint.get("draw", {})
|
||||
if type_str == "gpu_3d":
|
||||
var draw_result := ParticleValues.build_draw_material(draw_config)
|
||||
if not draw_result.ok:
|
||||
node.free()
|
||||
var msg := String(draw_result.error)
|
||||
if draw_result.get("unknown_key", false):
|
||||
msg = _draw_key_unsupported_message(String(draw_result.key), type_str)
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
|
||||
draw_mesh = QuadMesh.new()
|
||||
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
|
||||
draw_material = draw_result.material
|
||||
(draw_mesh as QuadMesh).material = draw_material
|
||||
draw_pass_mesh_created = true
|
||||
draw_material_created = true
|
||||
for applied_key in draw_result.applied:
|
||||
applied_draw.append(String(applied_key))
|
||||
elif type_str == "gpu_2d":
|
||||
# GPUParticles2D has no draw-pass material; the one draw override it
|
||||
# supports is draw.texture → the node's texture property.
|
||||
for key in user_draw:
|
||||
if String(key) != "texture":
|
||||
node.free()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
_draw_key_unsupported_message(String(key), type_str)
|
||||
)
|
||||
if user_draw.has("texture"):
|
||||
var tex_result := _load_draw_texture(draw_config.get("texture"))
|
||||
if tex_result.has("error"):
|
||||
node.free()
|
||||
return tex_result
|
||||
node.set("texture", tex_result.texture)
|
||||
applied_draw.append("texture")
|
||||
else:
|
||||
# cpu_3d / cpu_2d: no draw support — reject user draw overrides
|
||||
# instead of dropping them.
|
||||
for key in user_draw:
|
||||
node.free()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
_draw_key_unsupported_message(String(key), type_str)
|
||||
)
|
||||
|
||||
# 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:
|
||||
if user_main.has(prop_name):
|
||||
var node_class := node.get_class()
|
||||
node.free()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"Override main.%s does not apply to type '%s' (no such property on %s)" % [
|
||||
prop_name, type_str, node_class
|
||||
]
|
||||
)
|
||||
continue # Blueprint key: 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:
|
||||
node.free()
|
||||
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:
|
||||
if user_process.has(prop_name):
|
||||
var target_class := process_target.get_class()
|
||||
node.free()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"Override process.%s does not apply to type '%s' (no such property on %s)" % [
|
||||
prop_name, type_str, target_class
|
||||
]
|
||||
)
|
||||
continue # Blueprint key: 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:
|
||||
node.free()
|
||||
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,
|
||||
"applied_draw": applied_draw,
|
||||
"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
|
||||
# ============================================================================
|
||||
|
||||
## Actionable rejection for a user draw override that can't apply to the
|
||||
## selected particle type: name the key and where it IS supported.
|
||||
static func _draw_key_unsupported_message(key: String, type_str: String) -> String:
|
||||
if key == "texture":
|
||||
return "draw.texture is only supported for gpu_2d (got type '%s')" % type_str
|
||||
var probe := StandardMaterial3D.new()
|
||||
if _object_property_type(probe, key) != TYPE_NIL:
|
||||
return "draw.%s is only supported for gpu_3d (got type '%s')" % [key, type_str]
|
||||
return (
|
||||
"Unknown draw key '%s' (draw overrides configure the gpu_3d "
|
||||
+ "draw-pass StandardMaterial3D; gpu_2d supports only draw.texture)"
|
||||
) % key
|
||||
|
||||
|
||||
## Load a Texture2D for the gpu_2d draw.texture override. Returns
|
||||
## {texture: Texture2D} or an error dict (same validation as set_draw_pass).
|
||||
static func _load_draw_texture(value: Variant) -> Dictionary:
|
||||
if not (value is String) or String(value).is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"draw.texture must be a non-empty res:// path string (got %s)" % type_string(typeof(value))
|
||||
)
|
||||
var texture_path := String(value)
|
||||
var texture_path_err = McpPathValidator.loadable_error(texture_path, "draw.texture")
|
||||
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)
|
||||
return {"texture": tex}
|
||||
|
||||
|
||||
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,293 @@
|
||||
@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,
|
||||
## plus "user_keys" ({main/process/draw: {key: true}}) recording which keys the
|
||||
## caller supplied. The handler needs that distinction: preset-blueprint keys
|
||||
## may skip silently on types they don't apply to (presets are cross-type by
|
||||
## design), but user-requested overrides must apply or error (#770).
|
||||
## Overrides may include top-level "main" / "process" / "draw" dicts, or bare
|
||||
## keys routed to main (_MAIN_KEYS) or process — draw keys must be nested.
|
||||
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", {})
|
||||
var user_keys := {"main": {}, "process": {}, "draw": {}}
|
||||
for key in overrides:
|
||||
var val = overrides[key]
|
||||
if key == "main" and val is Dictionary:
|
||||
for k in val:
|
||||
main[k] = val[k]
|
||||
user_keys.main[String(k)] = true
|
||||
elif key == "process" and val is Dictionary:
|
||||
for k in val:
|
||||
process[k] = val[k]
|
||||
user_keys.process[String(k)] = true
|
||||
elif key == "draw" and val is Dictionary:
|
||||
for k in val:
|
||||
draw[k] = val[k]
|
||||
user_keys.draw[String(k)] = true
|
||||
elif _MAIN_KEYS.has(key):
|
||||
main[key] = val
|
||||
user_keys.main[String(key)] = true
|
||||
else:
|
||||
process[key] = val
|
||||
user_keys.process[String(key)] = true
|
||||
entry["main"] = main
|
||||
entry["process"] = process
|
||||
entry["draw"] = draw
|
||||
entry["user_keys"] = user_keys
|
||||
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,246 @@
|
||||
@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())
|
||||
##
|
||||
## Returns {ok: true, material: StandardMaterial3D, applied: Array[String]},
|
||||
## or {ok: false, key, error, unknown_key} when a config key is not a
|
||||
## StandardMaterial3D property or its value fails coercion — draw config must
|
||||
## not disappear silently (#770).
|
||||
static func build_draw_material(config: Dictionary) -> Dictionary:
|
||||
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.
|
||||
var applied: Array[String] = []
|
||||
for key in config:
|
||||
var prop_name := String(key)
|
||||
var prop_type := _object_property_type(mat, prop_name)
|
||||
if prop_type == TYPE_NIL:
|
||||
return {
|
||||
"ok": false,
|
||||
"key": prop_name,
|
||||
"unknown_key": true,
|
||||
"error": "Unknown draw key '%s' (not a StandardMaterial3D property)" % prop_name,
|
||||
}
|
||||
var coerce_result := MaterialValues.coerce_material_value(
|
||||
prop_name, config[prop_name], prop_type
|
||||
)
|
||||
if not coerce_result.ok:
|
||||
return {
|
||||
"ok": false,
|
||||
"key": prop_name,
|
||||
"unknown_key": false,
|
||||
"error": "draw.%s: %s" % [prop_name, coerce_result.error],
|
||||
}
|
||||
mat.set(prop_name, coerce_result.value)
|
||||
applied.append(prop_name)
|
||||
return {"ok": true, "material": mat, "applied": applied}
|
||||
|
||||
|
||||
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,338 @@
|
||||
@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_path: %s" % McpScenePath.format_node_error(source_path, scene_root))
|
||||
|
||||
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,572 @@
|
||||
@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")
|
||||
const RUN_READY_WAIT_SEC := 3.0
|
||||
|
||||
## ProjectSettings keys that decide what the engine EXECUTES at project
|
||||
## startup. `McpPathValidator._reject_sensitive_write` already refuses direct
|
||||
## writes to `res://project.godot`, `res://override.cfg` and `res://.godot/`
|
||||
## for exactly this reason — but `set_project_setting` writes that same
|
||||
## manifest through the settings API, so without this list the guard is
|
||||
## trivially side-steppable:
|
||||
##
|
||||
## settings_set key="autoload/Boot" value="*res://../../evil.gd"
|
||||
##
|
||||
## would land arbitrary code on the next project open, and a `project.godot`
|
||||
## diff is easy to miss in review. Autoloads have a validated route of their
|
||||
## own (`autoload_handler.add_autoload`, which runs the path through
|
||||
## `McpPathValidator`); the rest are refused outright because there is no
|
||||
## legitimate agent workflow for repointing the engine's startup execution.
|
||||
##
|
||||
## Deliberately NARROW. A denylist that over-blocks turns settings_set into a
|
||||
## tool agents can't use, so only keys that actually carry code (or a command
|
||||
## line) are listed — not their whole section. `application/run/` is an exact
|
||||
## entry for `main_scene` precisely because its siblings (`max_fps`,
|
||||
## `low_processor_mode`, …) are inert and must stay writable; likewise
|
||||
## `application/boot_splash/image` is data, not code, and is NOT blocked.
|
||||
##
|
||||
## Prefix entries match the key and anything beneath it, and are used only
|
||||
## where every key under the prefix is code-bearing. Exact entries match only
|
||||
## themselves. Comparison is case-folded — ProjectSettings keys are
|
||||
## case-sensitive, but a case variant that Godot would reject is still a
|
||||
## clearer error coming from here than from a half-applied save.
|
||||
const STARTUP_EXECUTION_KEY_PREFIXES: Array[String] = [
|
||||
"autoload/", ## every key under it is a script path
|
||||
"editor_plugins/", ## enabled-plugin list; each entry is loaded as code
|
||||
]
|
||||
const STARTUP_EXECUTION_KEYS_EXACT: Array[String] = [
|
||||
"application/run/main_scene", ## the scene the game boots into
|
||||
"editor/script/templates_search_path", ## where the editor loads templates from
|
||||
"editor/run/main_run_args", ## command line for the run
|
||||
]
|
||||
|
||||
|
||||
## Returns "" when `key` may be written via set_project_setting, or a
|
||||
## human-readable refusal reason otherwise. Static so it is unit-testable
|
||||
## without instancing the handler.
|
||||
static func startup_execution_key_refusal(key: String) -> String:
|
||||
var lowered := key.strip_edges().to_lower()
|
||||
for prefix in STARTUP_EXECUTION_KEY_PREFIXES:
|
||||
if lowered.begins_with(prefix):
|
||||
if prefix == "autoload/":
|
||||
return (
|
||||
"Refusing to set '%s' — autoloads run code at project startup. " % key
|
||||
+ "Use autoload_manage(op='add'), which validates the script path."
|
||||
)
|
||||
return (
|
||||
"Refusing to set '%s' — keys under '%s' are loaded as code " % [key, prefix]
|
||||
+ "at project startup."
|
||||
)
|
||||
for exact in STARTUP_EXECUTION_KEYS_EXACT:
|
||||
if lowered == exact:
|
||||
return (
|
||||
"Refusing to set '%s' — this key controls what the engine loads " % key
|
||||
+ "or executes at project startup."
|
||||
)
|
||||
return ""
|
||||
|
||||
var _connection: McpConnection
|
||||
var _debugger_plugin
|
||||
var _editor_log_buffer
|
||||
|
||||
|
||||
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
|
||||
_connection = connection
|
||||
_debugger_plugin = debugger_plugin
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
|
||||
|
||||
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")
|
||||
|
||||
## Refuse the startup-execution surface before touching ProjectSettings —
|
||||
## see STARTUP_EXECUTION_KEY_PREFIXES for why this guard exists here and
|
||||
## not only in McpPathValidator.
|
||||
var refusal := startup_execution_key_refusal(key)
|
||||
if not refusal.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, refusal)
|
||||
|
||||
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 _run_project_current_liveness_response(
|
||||
_run_project_base_data(
|
||||
mode,
|
||||
str(params.get("scene", "")),
|
||||
autosave,
|
||||
true,
|
||||
"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')")
|
||||
else:
|
||||
## play_custom_scene() was the last path-taking op in the plugin
|
||||
## with no containment check; every sibling that accepts a scene
|
||||
## path validates it (scene_handler.open_scene,
|
||||
## node_handler.create_node's scene_path).
|
||||
validation_error = McpPathValidator.loadable_error(custom_scene, "scene")
|
||||
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(_editor_log_cursor(), _game_helper_autoload_expected())
|
||||
|
||||
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
|
||||
|
||||
var base_data := _run_project_base_data(
|
||||
mode,
|
||||
str(params.get("scene", "")),
|
||||
autosave,
|
||||
false,
|
||||
"Play/stop is a runtime action"
|
||||
)
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
|
||||
_finish_run_project_deferred(request_id, base_data, _connection, _debugger_plugin)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
return _run_project_current_liveness_response(base_data)
|
||||
|
||||
|
||||
func _editor_log_cursor() -> int:
|
||||
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
|
||||
|
||||
|
||||
func _game_helper_autoload_expected() -> bool:
|
||||
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
|
||||
|
||||
|
||||
static func _run_project_base_data(
|
||||
mode: String,
|
||||
scene: String,
|
||||
autosave: bool,
|
||||
was_already_running: bool,
|
||||
reason: String
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"mode": mode,
|
||||
"scene": scene,
|
||||
"autosave": autosave,
|
||||
"was_already_running": was_already_running,
|
||||
"undoable": false,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
|
||||
if _debugger_plugin == null:
|
||||
return {"data": base_data}
|
||||
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
## One-shot read — force a Debugger-tab scan so boot errors that landed
|
||||
## after the last gated scan are in this response (#641).
|
||||
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
|
||||
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
|
||||
|
||||
|
||||
## `static` is load-bearing (#712, same rationale as the script/filesystem
|
||||
## handlers and editor_handler._do_reload_plugin): this coroutine awaits
|
||||
## across frames, and a plugin reload frees this RefCounted handler
|
||||
## mid-await — resuming an instance coroutine on a freed object errors.
|
||||
## `connection` and `debugger_plugin` are parameterized explicitly and
|
||||
## re-validated after every await; the response is dropped silently when
|
||||
## either died (the server's command timeout surfaces the failure).
|
||||
static func _finish_run_project_deferred(
|
||||
request_id: String, base_data: Dictionary, connection, debugger_plugin
|
||||
) -> void:
|
||||
var tree: SceneTree = connection.get_tree()
|
||||
while true:
|
||||
await tree.process_frame
|
||||
if not is_instance_valid(connection) or not is_instance_valid(debugger_plugin):
|
||||
return
|
||||
var pre_status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
if (
|
||||
not EditorInterface.is_playing_scene()
|
||||
and int(pre_status.get("elapsed_msec", 0)) > 100
|
||||
and str(pre_status.get("status", "stopped")) == "launching"
|
||||
):
|
||||
debugger_plugin.end_game_run()
|
||||
var status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
var errors_info: Dictionary = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||
var decision := _run_project_liveness_decision(status, errors_info)
|
||||
if not bool(decision.get("resolve", false)):
|
||||
continue
|
||||
## #641: the loop above polls with gated (cheap) scans; boot parse
|
||||
## errors can land in the Errors tab in the same frames the run goes
|
||||
## live. Re-gather once with a forced scan before replying so the
|
||||
## response reports them instead of leaving them to a later
|
||||
## logs_read. Rebuilding the decision with strictly-more errors can
|
||||
## only keep it resolved (errors never un-resolve a decision).
|
||||
errors_info = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
|
||||
decision = _run_project_liveness_decision(status, errors_info)
|
||||
connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
|
||||
return
|
||||
|
||||
|
||||
static func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
|
||||
var data := base_data.duplicate(true)
|
||||
var game_status: Dictionary = decision.get("game_status", {})
|
||||
data["game_status"] = game_status
|
||||
data["helper_live"] = bool(game_status.get("helper_live", false))
|
||||
data["session_active"] = bool(game_status.get("session_active", false))
|
||||
if bool(data.get("was_already_running", false)):
|
||||
data["reason"] = _run_project_already_running_message(decision)
|
||||
else:
|
||||
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
|
||||
data["recent_errors"] = decision.get("recent_errors", [])
|
||||
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
|
||||
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
|
||||
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
|
||||
data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _run_project_already_running_message(decision: Dictionary) -> String:
|
||||
var state := str(decision.get("liveness_status", "unknown"))
|
||||
match state:
|
||||
"live":
|
||||
var live_errors: Array = decision.get("recent_errors", [])
|
||||
if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
|
||||
return (
|
||||
"Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
|
||||
% [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
|
||||
)
|
||||
return "Project was already running; the Godot AI game helper is live."
|
||||
"not_live":
|
||||
var errors: Array = decision.get("recent_errors", [])
|
||||
var scope := str(decision.get("recent_errors_scope", "none"))
|
||||
if not errors.is_empty() and scope == "run":
|
||||
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||
if not errors.is_empty():
|
||||
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||
"break":
|
||||
var break_errors: Array = decision.get("recent_errors", [])
|
||||
if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
|
||||
return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
|
||||
if not break_errors.is_empty():
|
||||
return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
|
||||
return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
|
||||
"no_helper":
|
||||
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
|
||||
"launching":
|
||||
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
|
||||
"stopped":
|
||||
return "Project was already marked playing by the editor, but no active game liveness run exists."
|
||||
_:
|
||||
return "Project was already running; current liveness status is %s." % state
|
||||
|
||||
|
||||
## Static (with the rest of the deferred-finisher chain) so the #712
|
||||
## load-bearing-static coroutines above can call it after their owner
|
||||
## handler was freed. Uses no instance state.
|
||||
static func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
|
||||
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
|
||||
var state := str(status.get("status", "stopped"))
|
||||
var recent_errors: Array = errors_info.get("errors", [])
|
||||
var errors_scope := str(errors_info.get("scope", "none"))
|
||||
var truncated := bool(errors_info.get("truncated", false))
|
||||
## Clear errors that predate this run's window — they don't belong in
|
||||
## a successful launch response and only add noise. They remain
|
||||
## reachable via logs_read(source='editor') and the retained buffer so
|
||||
## failed-run debugging is unaffected.
|
||||
## #635 tradeoff: a genuine in-run error whose Errors-tab row carries
|
||||
## an empty or byte-identical time text can be misclassified as
|
||||
## retained_recent and will be dropped here. Still reachable via
|
||||
## logs_read.
|
||||
if state == "live" and errors_scope == "retained_recent":
|
||||
recent_errors = []
|
||||
errors_scope = "none"
|
||||
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
|
||||
var elapsed_msec := int(status.get("elapsed_msec", 0))
|
||||
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
|
||||
var decision := {
|
||||
"resolve": false,
|
||||
"game_status": enriched_status,
|
||||
"liveness_status": state,
|
||||
"recent_errors": recent_errors,
|
||||
"recent_errors_scope": errors_scope,
|
||||
"recent_errors_may_predate_run": errors_scope == "retained_recent",
|
||||
"recent_errors_truncated": truncated,
|
||||
"message": "",
|
||||
}
|
||||
if state == "live":
|
||||
decision["resolve"] = true
|
||||
if correlated_error:
|
||||
## #641: "live" only means the helper autoload registered — scripts
|
||||
## can still have failed to parse or load during boot (a broken
|
||||
## node script does not stop the game from running). Surface those
|
||||
## errors in the success message so agents don't read a clean
|
||||
## launch into a run that silently lost scripts.
|
||||
decision["message"] = (
|
||||
"Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
|
||||
% [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
|
||||
)
|
||||
if truncated:
|
||||
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
||||
else:
|
||||
decision["message"] = "Game launched and the Godot AI game helper is live."
|
||||
elif state == "break":
|
||||
## #645: the game process is parked in a remote-debugger break. A
|
||||
## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
|
||||
## no Errors-tab row, no Logger entry, and no game-log line — the
|
||||
## synthesized break record is the only evidence, and it lands a
|
||||
## moment after the break signal (stack frames arrive async). Wait for
|
||||
## it (correlated_error) before resolving; the ready window is the
|
||||
## fallback if synthesis never lands.
|
||||
var break_info: Dictionary = status.get("break", {})
|
||||
var break_reason := str(break_info.get("reason", ""))
|
||||
if bool(break_info.get("pre_live", true)):
|
||||
decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
|
||||
var summary := break_reason
|
||||
if correlated_error:
|
||||
summary = _format_editor_error_summary(recent_errors[0])
|
||||
if summary.is_empty():
|
||||
summary = "script parse/load error (reason not captured)"
|
||||
decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
|
||||
else:
|
||||
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
|
||||
elif correlated_error:
|
||||
decision["resolve"] = true
|
||||
decision["liveness_status"] = "not_live"
|
||||
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
if truncated:
|
||||
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
||||
elif state == "not_live":
|
||||
decision["resolve"] = true
|
||||
if not recent_errors.is_empty():
|
||||
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
else:
|
||||
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||
elif state == "no_helper":
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
|
||||
elif state == "stopped":
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
|
||||
elif state == "launching" and elapsed_msec >= ready_wait_msec:
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
|
||||
return decision
|
||||
|
||||
|
||||
static func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
|
||||
|
||||
|
||||
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, _connection)
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 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.
|
||||
# `static` is load-bearing (#712): see _finish_run_project_deferred.
|
||||
static func _finish_stop_project_deferred(request_id: String, connection) -> void:
|
||||
var tree: SceneTree = 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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"EditorFileSystem not available", false)
|
||||
|
||||
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,591 @@
|
||||
@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_not_ready(
|
||||
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
|
||||
"EditorFileSystem not available", false)
|
||||
|
||||
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 made := _instantiate_resource(type_str)
|
||||
if made is Dictionary:
|
||||
return made
|
||||
var res: Resource = made
|
||||
|
||||
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
|
||||
|
||||
|
||||
## Build the "Unknown resource type" error with a steer toward op="scan". A type
|
||||
## that reaches here is neither an engine built-in (ClassDB) nor a registered
|
||||
## project class (the global script-class registry). In an agent-driven workflow
|
||||
## the most common cause is a `class_name` script just made via script_create
|
||||
## that isn't registered yet — the global class table only rebuilds on a
|
||||
## filesystem scan (normally an editor-focus event). Point the caller at the one
|
||||
## cheap call that fixes that, so it doesn't fall back to a full plugin reload.
|
||||
## See #614 for the headless scan op.
|
||||
static func _unknown_resource_type_error(type_str: String) -> Dictionary:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
(
|
||||
"Unknown resource type: %s — not an engine built-in or a registered project class. "
|
||||
+ "If you just created it with script_create, the global class table is stale until a "
|
||||
+ "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling."
|
||||
) % type_str
|
||||
)
|
||||
|
||||
|
||||
## Resolve a resource type name to a fresh instance. Handles engine built-ins
|
||||
## (ClassDB) and project `class_name` Resources (the global script-class
|
||||
## registry). Returns a Resource on success, or an error dict on failure.
|
||||
static func _instantiate_resource(type_str: String) -> Variant:
|
||||
if ClassDB.class_exists(type_str):
|
||||
var class_err: Variant = _validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var built_in := ClassDB.instantiate(type_str)
|
||||
if built_in == null or not (built_in is Resource):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||
return built_in
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") == type_str:
|
||||
var script_path: String = entry.get("path", "")
|
||||
var scr: Variant = load(script_path)
|
||||
# Reject non-Resource script classes BEFORE constructing them:
|
||||
# scr.new() runs _init(), and an @tool class_name extending a
|
||||
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
|
||||
# an orphan instance this path never frees. get_instance_base_type()
|
||||
# resolves to the native base, so multi-level custom Resource
|
||||
# hierarchies (B extends A extends Resource) still pass.
|
||||
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||
if base_or_err is Dictionary:
|
||||
return base_or_err
|
||||
var base_type: StringName = base_or_err
|
||||
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||
if not scr.can_instantiate():
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
|
||||
# Reject scripts whose _init() requires arguments BEFORE scr.new():
|
||||
# scr.new() passes no args, so a required-arg _init raises and aborts
|
||||
# this handler mid-call, null-cascading into a generic "malformed
|
||||
# result" error instead of a clean rejection. get_script_method_list()
|
||||
# reports the effective (incl. inherited) _init; required args =
|
||||
# args - default_args. Statically detectable only — a _init that runs
|
||||
# but throws still falls through to scr.new() and the dispatcher catch.
|
||||
for method in scr.get_script_method_list():
|
||||
if method.get("name", "") == "_init":
|
||||
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
|
||||
if required_args > 0:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
|
||||
break
|
||||
var made: Variant = scr.new()
|
||||
if made == null or not (made is Resource):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||
return made
|
||||
return _unknown_resource_type_error(type_str)
|
||||
|
||||
|
||||
## Maximum nesting depth for the {"__class__": ...} sub-resource shortcut.
|
||||
## Caller-supplied dicts recurse through _apply_resource_properties; without a
|
||||
## cap a deeply nested payload overflows the GDScript call stack and crashes
|
||||
## the editor (#536). 32 is far beyond any legitimate sub-resource chain.
|
||||
const MAX_NESTED_RESOURCE_DEPTH := 32
|
||||
|
||||
|
||||
## 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.
|
||||
## `depth` is internal recursion bookkeeping for the nested {"__class__": ...}
|
||||
## shortcut — external callers use the default of 0.
|
||||
static func _apply_resource_properties(res: Resource, properties: Dictionary, depth: int = 0) -> Variant:
|
||||
if depth > MAX_NESTED_RESOURCE_DEPTH:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"Nested resource properties exceed the maximum depth of %d — flatten the {\"__class__\": ...} nesting or create the deep sub-resources in separate calls" % MAX_NESTED_RESOURCE_DEPTH
|
||||
)
|
||||
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()
|
||||
# Name the script's class_name (e.g. MyTestResource) rather than the
|
||||
# native base (Resource) so the hint names the type the agent created,
|
||||
# and point at the real MCP verb — resource_manage(op="get_info") now
|
||||
# answers for project class_name Resources too.
|
||||
var type_label := res.get_class()
|
||||
var res_script: Variant = res.get_script()
|
||||
if res_script is Script and not String(res_script.get_global_name()).is_empty():
|
||||
type_label = String(res_script.get_global_name())
|
||||
var err := ErrorCodes.make(
|
||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
|
||||
)
|
||||
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__", "")
|
||||
# Resolve via the shared helper so the nested shortcut accepts both
|
||||
# engine built-ins (ClassDB) and project `class_name` Resources,
|
||||
# exactly like the top-level resource_create path.
|
||||
var sub_made := _instantiate_resource(sub_type)
|
||||
if sub_made is Dictionary:
|
||||
# Preserve the property-slot context the inline path used to add.
|
||||
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
|
||||
return sub_made
|
||||
var sub_res: Resource = sub_made
|
||||
var remaining: Dictionary = (v as Dictionary).duplicate()
|
||||
remaining.erase("__class__")
|
||||
if not remaining.is_empty():
|
||||
var nested_err := _apply_resource_properties(sub_res, remaining, depth + 1)
|
||||
if nested_err != null:
|
||||
return nested_err
|
||||
v = sub_res
|
||||
else:
|
||||
var slot_value: Variant = res.get(key)
|
||||
if target_type == TYPE_ARRAY and slot_value is Array and (slot_value as Array).is_typed():
|
||||
## Typed Array[T] slot (#612): mirror set_property's dispatch —
|
||||
## the generic passthrough would hand an untyped Array to the
|
||||
## typed setter, which drops it silently while we report success.
|
||||
var typed_out: Variant = NodeHandler._coerce_typed_array(
|
||||
v, slot_value, "Property '%s'" % key
|
||||
)
|
||||
if typed_out is Dictionary:
|
||||
return typed_out
|
||||
v = typed_out
|
||||
elif (
|
||||
target_type == TYPE_DICTIONARY
|
||||
and slot_value is Dictionary
|
||||
and (slot_value as Dictionary).is_typed()
|
||||
):
|
||||
## Typed Dictionary[K, V] slot (#612 stage 3): success is a
|
||||
## typed duplicate of the slot; the error envelope is untyped.
|
||||
var typed_dict_out: Dictionary = NodeHandler._coerce_typed_dictionary(
|
||||
v, slot_value, "Property '%s'" % key
|
||||
)
|
||||
if not typed_dict_out.is_typed():
|
||||
return typed_dict_out
|
||||
v = typed_dict_out
|
||||
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,
|
||||
McpPropertyErrors.build_message(node, property)
|
||||
)
|
||||
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):
|
||||
# Project class_name Resources aren't in ClassDB; resolve them through the
|
||||
# global script-class registry so get_info answers for the same custom
|
||||
# types resource_create can make. Read-only — never instantiates.
|
||||
var custom_info: Variant = _custom_resource_info(type_str)
|
||||
if custom_info != null:
|
||||
return custom_info
|
||||
return _unknown_resource_type_error(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}
|
||||
|
||||
|
||||
## Resolve a loaded global-class script to its native base type, or an error if
|
||||
## the script failed to load (not a Script) or to compile (empty base type).
|
||||
## Shared by the create and get_info custom-Resource paths so both report a
|
||||
## compile failure rather than a misleading "is not a Resource type (extends )".
|
||||
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
|
||||
if not (scr is Script):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
|
||||
var base_type: StringName = scr.get_instance_base_type()
|
||||
if String(base_type).is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
|
||||
return base_type
|
||||
|
||||
|
||||
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
|
||||
## dict, an error dict (for a class_name whose native base is not a Resource), or
|
||||
## null if `type_str` is not a registered global class. Read-only: resolves
|
||||
## properties from the script + its native base WITHOUT instantiating (no _init()).
|
||||
static func _custom_resource_info(type_str: String) -> Variant:
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") != type_str:
|
||||
continue
|
||||
var script_path: String = entry.get("path", "")
|
||||
var scr: Variant = load(script_path)
|
||||
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||
if base_or_err is Dictionary:
|
||||
return base_or_err
|
||||
var base_type: StringName = base_or_err
|
||||
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||
var can_instantiate: bool = scr.can_instantiate()
|
||||
# Inherited (native) properties come from the engine base via ClassDB...
|
||||
var class_info := ClassIntrospection.build(String(base_type), {
|
||||
"sections": ["properties"],
|
||||
"include_inherited": true,
|
||||
"limit": 0,
|
||||
})
|
||||
var props: Array = []
|
||||
for native_prop in class_info.properties:
|
||||
props.append(native_prop)
|
||||
# ...and the script's own (and inherited script) exported properties come
|
||||
# from the Script itself, so we never construct the resource. A real
|
||||
# default isn't available without instantiating, so script props carry an
|
||||
# explicit null — keeping one uniform key set across the array (native
|
||||
# props carry their real default).
|
||||
for raw_prop in scr.get_script_property_list():
|
||||
var prop: Dictionary = raw_prop
|
||||
var usage := int(prop.get("usage", 0))
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
props.append({
|
||||
"name": str(prop.get("name", "")),
|
||||
"type": type_string(int(prop.get("type", TYPE_NIL))),
|
||||
"class_name": str(prop.get("class_name", "")),
|
||||
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
|
||||
"hint_string": str(prop.get("hint_string", "")),
|
||||
"usage": usage,
|
||||
"default": null,
|
||||
})
|
||||
props.sort_custom(func(a, b): return a.name < b.name)
|
||||
# parent_class is the immediate script parent when there is one (so a
|
||||
# multi-level chain B -> A -> Resource reports A), else the native base.
|
||||
var parent_name := String(base_type)
|
||||
var base_script: Variant = scr.get_base_script()
|
||||
if base_script is Script and not String(base_script.get_global_name()).is_empty():
|
||||
parent_name = String(base_script.get_global_name())
|
||||
return {"data": {
|
||||
"type": type_str,
|
||||
"parent_class": parent_name,
|
||||
"can_instantiate": can_instantiate,
|
||||
# is_abstract reflects real abstractness (the @abstract annotation),
|
||||
# NOT editor-instantiability — a non-@tool concrete Resource has
|
||||
# can_instantiate()==false in-editor but is not abstract.
|
||||
"is_abstract": scr.is_abstract(),
|
||||
"properties": props,
|
||||
"property_count": props.size(),
|
||||
}}
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwwd0n3c56ir
|
||||
@@ -0,0 +1,420 @@
|
||||
@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 offset: int = maxi(0, int(params.get("offset", 0)))
|
||||
# limit <= 0 means "no limit" (the hierarchy resource reads the whole tree);
|
||||
# the scene_get_hierarchy tool passes an explicit positive limit. Paginating
|
||||
# here — rather than walking + serializing the full tree and slicing on the
|
||||
# Python side — means only the requested window builds node dicts and clean
|
||||
# scene paths, and only the window crosses the WebSocket.
|
||||
var limit: int = int(params.get("limit", 0))
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
if scene_root == null:
|
||||
return {"data": {
|
||||
"nodes": [],
|
||||
"total_count": 0,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": false,
|
||||
"message": "No scene open",
|
||||
}}
|
||||
|
||||
var nodes: Array[Dictionary] = []
|
||||
# index_ref[0] is the running DFS index shared across the recursion (Arrays
|
||||
# pass by reference in GDScript). The walk still visits every node to get an
|
||||
# accurate total_count, but only materializes those inside the window.
|
||||
var index_ref: Array[int] = [0]
|
||||
# _walk_tree self-seeds the root's path for full reads; pass "" explicitly.
|
||||
_walk_tree(scene_root, nodes, 0, max_depth, scene_root, offset, limit, index_ref, "")
|
||||
var total: int = index_ref[0]
|
||||
return {"data": {
|
||||
"nodes": nodes,
|
||||
"total_count": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": limit > 0 and offset + limit < total,
|
||||
}}
|
||||
|
||||
|
||||
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
|
||||
|
||||
if _connection:
|
||||
_connection.pause_processing = true
|
||||
var err := _pack_and_save_with_uid(root, path)
|
||||
if err == OK:
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## Pack `root` and save it to `path`, embedding a fresh uid or preserving the
|
||||
## one `path` already had — the exact save sequence `create_scene` runs,
|
||||
## minus the `pause_processing` guard (the caller owns that, since it also
|
||||
## needs to bracket `open_scene_from_path`) and minus opening the scene
|
||||
## (switching the editor's active scene isn't safe inside the shared test
|
||||
## runner, so tests call this directly instead of going through
|
||||
## `create_scene` end-to-end). Frees `root`. Returns `OK`, or the first
|
||||
## `Error` encountered.
|
||||
func _pack_and_save_with_uid(root: Node, path: String) -> Error:
|
||||
var packed := PackedScene.new()
|
||||
packed.pack(root)
|
||||
root.free()
|
||||
|
||||
# Captured BEFORE the save below overwrites the file — see
|
||||
# McpResourceIO.ensure_uid's doc comment.
|
||||
var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID
|
||||
|
||||
var err := ResourceSaver.save(packed, path)
|
||||
if err == OK:
|
||||
err = McpResourceIO.ensure_uid(path, prior_uid)
|
||||
return err
|
||||
|
||||
|
||||
## How long open_scene waits for the editor to actually switch to the
|
||||
## requested scene before replying switched=false. Tab switches normally land
|
||||
## within a few frames; keep this under the dispatcher's 4500 ms deferred
|
||||
## default so the coroutine always answers before DEFERRED_TIMEOUT fires.
|
||||
const _OPEN_SETTLE_MAX_MSEC := 3000
|
||||
|
||||
|
||||
## Open an existing scene by file path.
|
||||
func open_scene(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var force_reload: bool = params.get("force_reload", false)
|
||||
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)
|
||||
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var current_path := scene_root.scene_file_path if scene_root else ""
|
||||
## Instance id of the root at call time. A completed open OR reload always
|
||||
## replaces the edited-scene root with a NEW instance, so this is the
|
||||
## reliable completion signal — unlike scene_file_path, which is unchanged
|
||||
## across a force_reload of the already-open scene (#633 review).
|
||||
var prev_root_id := scene_root.get_instance_id() if scene_root else 0
|
||||
var payload := {
|
||||
"path": path,
|
||||
"force_reload": force_reload,
|
||||
"reloaded_from_disk": false,
|
||||
"previous_scene_path": current_path,
|
||||
"undoable": false,
|
||||
"reason": "Scene navigation cannot be undone via editor undo",
|
||||
}
|
||||
|
||||
if current_path == path and not force_reload:
|
||||
## Already the edited scene — nothing switches, reply immediately.
|
||||
payload["switched"] = true
|
||||
payload["settle"] = "already_current"
|
||||
return {"data": payload}
|
||||
|
||||
if force_reload and current_path == path:
|
||||
EditorInterface.reload_scene_from_path(path)
|
||||
payload["reloaded_from_disk"] = true
|
||||
else:
|
||||
EditorInterface.open_scene_from_path(path)
|
||||
|
||||
## The tab switch completes asynchronously; replying now lets an immediate
|
||||
## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued
|
||||
## right after open_scene saved the old scene). Defer the reply until the
|
||||
## edited scene actually is `path` AND its root is a fresh instance, so
|
||||
## success means "the editor is now editing the (re)loaded scene".
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
if _connection != null and not request_id.is_empty():
|
||||
_finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
## Synchronous fallback (batch_execute and unit-test contexts can't await):
|
||||
## preserve the old reply-immediately behavior, flagged as not waited on.
|
||||
payload["switched"] = false
|
||||
payload["settle"] = "not_waited"
|
||||
return {"data": payload}
|
||||
|
||||
|
||||
## `static` is load-bearing (same reason as FilesystemHandler's deferred scan
|
||||
## finish): the coroutine must outlive this RefCounted handler, which can be
|
||||
## freed mid-await by an editor_reload_plugin. Parameterise everything;
|
||||
## reference no instance state.
|
||||
static func _finish_open_scene_deferred(
|
||||
connection: McpConnection,
|
||||
request_id: String,
|
||||
path: String,
|
||||
prev_root_id: int,
|
||||
payload: Dictionary,
|
||||
) -> void:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
var tree := connection.get_tree()
|
||||
if tree == null:
|
||||
return
|
||||
# Hand back a frame so _dispatch() registers this request as deferred
|
||||
# before the coroutine can push a reply.
|
||||
await tree.process_frame
|
||||
var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC
|
||||
while Time.get_ticks_msec() < deadline_ms:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
# Require BOTH the target path AND a fresh root instance: a
|
||||
# force_reload keeps scene_file_path == path across the reload, so the
|
||||
# instance swap is what proves the (re)load actually completed rather
|
||||
# than the coroutine settling on the stale pre-reload root.
|
||||
if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id:
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
payload["switched"] = true
|
||||
payload["settle"] = "settled"
|
||||
connection.send_deferred_response(request_id, {"data": payload})
|
||||
return
|
||||
await tree.process_frame
|
||||
if not is_instance_valid(connection):
|
||||
return
|
||||
payload["switched"] = false
|
||||
payload["settle"] = "timeout"
|
||||
connection.send_deferred_response(request_id, {"data": payload})
|
||||
|
||||
|
||||
## 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, offset: int, limit: int, index_ref: Array[int], node_path: String) -> void:
|
||||
if depth > max_depth:
|
||||
return
|
||||
var idx: int = index_ref[0]
|
||||
index_ref[0] = idx + 1
|
||||
# Materialize only nodes inside the [offset, offset+limit) window. Outside
|
||||
# it we still recurse (to count total_count) but skip the per-node dict.
|
||||
#
|
||||
# Path build strategy depends on the read shape (identical output either way):
|
||||
# * A whole-tree read (offset == 0 and limit <= 0 — the resource-style read
|
||||
# backing godot://scene/hierarchy) threads the parent's clean path down the
|
||||
# DFS: each node's path is one O(1) concat reusing the descent, instead of
|
||||
# McpScenePath.from_node's two native walks back up (is_ancestor_of +
|
||||
# get_path_to). Benchmarked ~1.8x faster on a ~1.5k-node tree, up to ~5x on
|
||||
# deep chains.
|
||||
# * Any windowed read (limit > 0, or an offset > 0 skip) keeps from_node for
|
||||
# just the emitted nodes: threading would concatenate a path for every node
|
||||
# visited for total_count, which benchmarks ~20% slower for a small window.
|
||||
#
|
||||
# `node_path` is self-seeded at the scene root below, so a caller cannot leave
|
||||
# a full read unseeded (it has no default — pass "" for windowed reads).
|
||||
var incremental := limit <= 0 and offset == 0
|
||||
if incremental and node == scene_root:
|
||||
node_path = "/" + String(scene_root.name)
|
||||
var in_window := idx >= offset and (limit <= 0 or idx < offset + limit)
|
||||
if in_window:
|
||||
out.append({
|
||||
"name": node.name,
|
||||
"type": node.get_class(),
|
||||
"path": node_path if incremental else McpScenePath.from_node(node, scene_root),
|
||||
"children_count": node.get_child_count(),
|
||||
})
|
||||
for child in node.get_children():
|
||||
var child_path := (node_path + "/" + String(child.name)) if incremental else ""
|
||||
_walk_tree(child, out, depth + 1, max_depth, scene_root, offset, limit, index_ref, child_path)
|
||||
@@ -0,0 +1 @@
|
||||
uid://7ms40gm6t2r4
|
||||
@@ -0,0 +1,501 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
|
||||
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
|
||||
|
||||
## Handles script creation, reading, attaching, detaching, and symbol inspection.
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
var _connection: McpConnection
|
||||
|
||||
# The bounded import-settle window and the deferred completion coroutine
|
||||
# live on McpResourceIO since #714 — write_file's fresh-`.gd` path shares
|
||||
# them, so create_script and write_file can't drift apart again (#261).
|
||||
|
||||
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")
|
||||
|
||||
var existed_before := FileAccess.file_exists(path)
|
||||
|
||||
# Shared write path (#714): parent mkdir + write/flush + explicit error
|
||||
# check live on McpResourceIO so write_file can't drift from this again.
|
||||
var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content)
|
||||
if write_failure != null:
|
||||
return write_failure
|
||||
|
||||
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",
|
||||
}
|
||||
_attach_gdscript_diagnostics(data, path, content)
|
||||
|
||||
# A freshly-declared `class_name` is NOT in the global class table until a
|
||||
# filesystem scan runs — update_file() below registers the file with the
|
||||
# resource pipeline but not the class registry (see the scan() comment).
|
||||
# Surface that precisely (only when the class isn't already registered) so a
|
||||
# headless caller knows to follow up with filesystem_manage(op="scan")
|
||||
# instead of hitting a confusing "Unknown type" / "Unknown resource type" on
|
||||
# the very next call. We don't scan here — a scan() per create is the exact
|
||||
# SIGABRT race documented below; the explicit op is single-flight.
|
||||
# Skip the hint when the script failed to parse: a scan won't register a
|
||||
# class from a broken script, so pointing at op="scan" would steer the caller
|
||||
# away from the real fix (the parse error already attached above).
|
||||
var declared_class := _extract_class_name(content)
|
||||
if (
|
||||
not declared_class.is_empty()
|
||||
and not _script_has_error_diagnostics(data)
|
||||
and not _class_name_registered(declared_class)
|
||||
):
|
||||
data["class_name"] = declared_class
|
||||
data["class_registration"] = "scan_required"
|
||||
data["class_registration_hint"] = (
|
||||
"New class_name '%s' isn't in the global class table yet. " % declared_class
|
||||
+ "Call filesystem_manage(op=\"scan\") if it won't resolve on the next "
|
||||
+ "call (e.g. resource_manage op=\"create\", or used as a type in another "
|
||||
+ "script). The editor also registers it on its next filesystem scan or "
|
||||
+ "when its window regains focus."
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# `.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():
|
||||
McpResourceIO.finish_text_write_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}
|
||||
|
||||
|
||||
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
|
||||
## (no full parse) for create_script's "scan_required" hint. Stops at the first
|
||||
## space/tab or comma so all three valid forms yield just the name:
|
||||
## `class_name Foo`, `class_name Foo extends Bar`, and the icon form
|
||||
## `class_name Foo, "res://icon.svg"`.
|
||||
static func _extract_class_name(content: String) -> String:
|
||||
for raw_line in content.split("\n"):
|
||||
var line := raw_line.strip_edges()
|
||||
if line.begins_with("class_name "):
|
||||
var rest := line.substr(11).strip_edges()
|
||||
var cut := rest.length()
|
||||
for i in rest.length():
|
||||
var ch := rest[i]
|
||||
if ch == " " or ch == "\t" or ch == ",":
|
||||
cut = i
|
||||
break
|
||||
return rest.substr(0, cut)
|
||||
return ""
|
||||
|
||||
|
||||
## True if create_script's diagnostics captured a parse error for this script.
|
||||
## Used to suppress the "scan_required" hint when the class can't register
|
||||
## anyway — see create_script.
|
||||
static func _script_has_error_diagnostics(data: Dictionary) -> bool:
|
||||
for diag in data.get("diagnostics", []):
|
||||
if diag is Dictionary and diag.get("level", "") == "error":
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an
|
||||
## already-registered project global class. A brand-new class_name returns false
|
||||
## until a filesystem scan registers it.
|
||||
static func _class_name_registered(cn: String) -> bool:
|
||||
if ClassDB.class_exists(cn):
|
||||
return true
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") == cn:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## Instance (not static) despite using no instance state: tests stub
|
||||
## `_capture_gdscript_load_diagnostics` via subclass override, and static
|
||||
## calls bind lexically — see test_script.gd. filesystem_handler shares
|
||||
## this by instantiating a bare ScriptHandler (#714).
|
||||
func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void:
|
||||
var validation := _validate_gdscript_source(content)
|
||||
var diagnostics: Array = []
|
||||
var diagnostics_detail := "none"
|
||||
var diagnostics_status := "checked"
|
||||
|
||||
if not validation.get("ok", true):
|
||||
var capture := _capture_gdscript_load_diagnostics(path)
|
||||
diagnostics = capture.get("diagnostics", [])
|
||||
diagnostics_detail = capture.get("diagnostics_detail", "none")
|
||||
diagnostics_status = capture.get("diagnostics_status", "checked")
|
||||
if not validation.get("ok", true) and diagnostics.is_empty():
|
||||
diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content))
|
||||
diagnostics_detail = "fallback"
|
||||
data["diagnostics"] = diagnostics
|
||||
data["diagnostics_detail"] = diagnostics_detail
|
||||
data["diagnostics_scope"] = "this_file"
|
||||
data["diagnostics_status"] = diagnostics_status
|
||||
|
||||
|
||||
static func _validate_gdscript_source(content: String) -> Dictionary:
|
||||
var script := GDScript.new()
|
||||
script.source_code = content
|
||||
## Keep validation off the live cached resource: assigning resource_path to
|
||||
## this ephemeral Script can collide with loaded instances. reload() still
|
||||
## performs normal GDScript analysis, including static initializer work, so
|
||||
## this check is intentionally scoped to `.gd` writes where the editor would
|
||||
## compile the file on scan anyway.
|
||||
var err := script.reload()
|
||||
return {
|
||||
"ok": err == OK,
|
||||
"error_code": err,
|
||||
}
|
||||
|
||||
|
||||
func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
|
||||
var buffer := McpEditorLogBuffer.new()
|
||||
var logger := ValidationLogger.new(buffer)
|
||||
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
|
||||
OS.add_logger(logger)
|
||||
# ResourceLoader.load() reports parse failure instead of throwing, and
|
||||
# a failed GDScript parse does not execute user code; remove immediately
|
||||
# after the synchronous load to keep the private capture window tiny.
|
||||
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
OS.remove_logger(logger)
|
||||
return {}
|
||||
)
|
||||
return capture
|
||||
|
||||
|
||||
static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary:
|
||||
var line := _fallback_gdscript_error_line(content)
|
||||
return {
|
||||
"source": "editor",
|
||||
"level": "error",
|
||||
"text": "GDScript reload failed with error code %d." % error_code,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"function": "GDScript::reload",
|
||||
"details": {
|
||||
"code": "gdscript_reload_failed",
|
||||
"error_code": error_code,
|
||||
"fallback_line": true,
|
||||
"source": {
|
||||
"path": path,
|
||||
"line": line,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func _fallback_gdscript_error_line(content: String) -> int:
|
||||
var lines := content.split("\n")
|
||||
for i in range(lines.size() - 1, -1, -1):
|
||||
if not str(lines[i]).strip_edges().is_empty():
|
||||
return i + 1
|
||||
return 1
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Shared write path (#714). No import-settle deferral here: the file
|
||||
# already exists, so ResourceLoader knows it and there is no scan to wait
|
||||
# for — same rationale as create_script's overwrite arm.
|
||||
var write_failure: Variant = McpResourceIO.write_text_to_disk(path, new_content)
|
||||
if write_failure != null:
|
||||
return write_failure
|
||||
|
||||
var 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",
|
||||
}
|
||||
_attach_gdscript_diagnostics(data, path, new_content)
|
||||
|
||||
# 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": data}
|
||||
|
||||
|
||||
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 — same cut logic as _extract_class_name so the
|
||||
# `extends Bar` / icon-form tails don't leak into the symbol name.
|
||||
if line.begins_with("class_name "):
|
||||
var cn_rest := line.substr(11).strip_edges()
|
||||
var cn_cut := cn_rest.length()
|
||||
for ci in cn_rest.length():
|
||||
var cn_ch := cn_rest[ci]
|
||||
if cn_ch == " " or cn_ch == "\t" or cn_ch == ",":
|
||||
cn_cut = ci
|
||||
break
|
||||
class_name_str = cn_rest.substr(0, cn_cut)
|
||||
|
||||
# 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,274 @@
|
||||
@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_value: Variant = params.get("path", "")
|
||||
var path_type_err = McpParamValidators.require_string("path", path_value)
|
||||
if path_type_err != null:
|
||||
return path_type_err
|
||||
## String(...) conversion: require_string accepts StringName too, and
|
||||
## a bare typed assignment from StringName would defeat the guard.
|
||||
var path: String = String(path_value)
|
||||
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, Object.CONNECT_PERSIST)
|
||||
_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])
|
||||
|
||||
# Capture the connection's current flags so undo restores it exactly as it
|
||||
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
|
||||
# silently promote a runtime-only connection into one that serializes on the
|
||||
# next save. (The connection still exists at this point — checked above.)
|
||||
var reconnect_flags := 0
|
||||
for conn in source.get_signal_connection_list(signal_name):
|
||||
if conn.get("callable", Callable()) == callable:
|
||||
reconnect_flags = int(conn.get("flags", 0))
|
||||
break
|
||||
|
||||
_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, reconnect_flags)
|
||||
_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,309 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Discovers and runs McpTestSuite scripts from res://tests/.
|
||||
## Exposes run_tests and get_test_results as MCP commands.
|
||||
##
|
||||
## Live MCP runs service the WebSocket transport between tests
|
||||
## (McpConnection.service_transport_during_exclusive_run) so a long suite
|
||||
## can no longer starve the server's keepalive, and abort at a per-call
|
||||
## ceiling derived from the server-provided time budget. Direct callers,
|
||||
## unit-test fixtures, and batch contexts (no request id / no connection)
|
||||
## keep the legacy fully-synchronous behavior with no ceiling.
|
||||
## See docs/test-run-transport-starvation-plan.md.
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
## Clamp bounds for the server-provided ``timeout_budget_sec`` param. The
|
||||
## floor is purely defensive (a malformed or buggy server value must not
|
||||
## abort every run instantly); the param is not user-facing.
|
||||
const BUDGET_MIN_SEC := 30.0
|
||||
const BUDGET_MAX_SEC := 3600.0
|
||||
## Conservative default when the server sent no (or an invalid) budget: an
|
||||
## old server's own test_run timeout is 120s, and the plugin must abort
|
||||
## and reply before that future expires.
|
||||
const BUDGET_DEFAULT_SEC := 110.0
|
||||
## Abort this long before the server would time the call out, so the
|
||||
## partial-results reply beats the server-side timeout.
|
||||
const CEILING_MARGIN_SEC := 10.0
|
||||
|
||||
var _runner: McpTestRunner
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
var _log_buffer: McpLogBuffer
|
||||
## Live plugin dispatcher, exposed to suites via ctx so tests can prove the
|
||||
## lazy handler registrations (#736) materialize with their real ctor args.
|
||||
## Optional third arg keeps old two-arg fixtures working; untyped because
|
||||
## the dispatcher constructs this handler (avoids a load-time type cycle).
|
||||
var _dispatcher
|
||||
## Live connection for exclusive-run transport servicing. Null in unit-test
|
||||
## fixtures and batch contexts, which keep the legacy synchronous path.
|
||||
var _connection: McpConnection
|
||||
|
||||
|
||||
func _init(
|
||||
undo_redo: EditorUndoRedoManager,
|
||||
log_buffer: McpLogBuffer,
|
||||
dispatcher = null,
|
||||
connection: McpConnection = null,
|
||||
) -> void:
|
||||
_runner = McpTestRunner.new()
|
||||
_undo_redo = undo_redo
|
||||
_log_buffer = log_buffer
|
||||
_dispatcher = dispatcher
|
||||
_connection = connection
|
||||
|
||||
|
||||
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 request_id: String = params.get("_request_id", "")
|
||||
var live := _connection != null and not request_id.is_empty()
|
||||
var service_cb := Callable()
|
||||
var deadline_ticks_ms := 0
|
||||
var budget_sec := 0.0
|
||||
var started_ms := Time.get_ticks_msec()
|
||||
var run_state := {}
|
||||
if live:
|
||||
budget_sec = _validated_budget_sec(params)
|
||||
service_cb = Callable(_connection, "service_transport_during_exclusive_run")
|
||||
deadline_ticks_ms = started_ms + int((budget_sec - CEILING_MARGIN_SEC) * 1000.0)
|
||||
|
||||
## Clear the previous run's results BEFORE discovery so an abort at any
|
||||
## later point can never expose a stale prior run via get_test_results.
|
||||
_runner.clear()
|
||||
|
||||
var discovery := _discover_suites(service_cb, deadline_ticks_ms, run_state)
|
||||
var discovery_outcome: String = discovery.get("outcome", "")
|
||||
if not discovery_outcome.is_empty():
|
||||
## Aborted during discovery: no suite has begun, so there is no
|
||||
## suite teardown to run. Same outcome mapping as the run itself.
|
||||
var empty_results: Dictionary = _runner.get_results(verbose)
|
||||
if not discovery.errors.is_empty():
|
||||
empty_results["load_errors"] = discovery.errors
|
||||
return _map_outcome(
|
||||
discovery_outcome, "discovery", empty_results, 0, started_ms, budget_sec
|
||||
)
|
||||
|
||||
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),
|
||||
]
|
||||
var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors}
|
||||
## Keep the edited_scene annotation on the no-suites error payload too,
|
||||
## so the response contract is consistent across every return path.
|
||||
_annotate_edited_scene(no_suites)
|
||||
return {"data": no_suites}
|
||||
|
||||
var ctx := {
|
||||
"undo_redo": _undo_redo,
|
||||
"log_buffer": _log_buffer,
|
||||
"dispatcher": _dispatcher,
|
||||
}
|
||||
|
||||
var run: Dictionary = _runner.run_suites_serviced(
|
||||
suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter,
|
||||
service_cb, deadline_ticks_ms, run_state
|
||||
)
|
||||
var results: Dictionary = run["results"]
|
||||
if not discovery.errors.is_empty():
|
||||
results["load_errors"] = discovery.errors
|
||||
return _map_outcome(
|
||||
run["outcome"], "run", results, run["tests_not_run"], started_ms, budget_sec
|
||||
)
|
||||
|
||||
|
||||
## Map a runner/discovery outcome onto the response envelope. Ownership is
|
||||
## deliberately here, not in the runner: the runner reports WHAT happened,
|
||||
## the handler decides how it goes over the wire (plan D2).
|
||||
func _map_outcome(
|
||||
outcome: String,
|
||||
phase: String,
|
||||
results: Dictionary,
|
||||
tests_not_run: int,
|
||||
started_ms: int,
|
||||
budget_sec: float,
|
||||
) -> Dictionary:
|
||||
var elapsed_ms := Time.get_ticks_msec() - started_ms
|
||||
match outcome:
|
||||
"completed":
|
||||
_annotate_edited_scene(results)
|
||||
return {"data": results}
|
||||
"transport_lost":
|
||||
## The peer is gone (or flood-closed); the send will fail against
|
||||
## the dead socket regardless, but a sync handler must return an
|
||||
## envelope. Partials stay retrievable via get_test_results after
|
||||
## the plugin reconnects.
|
||||
results["aborted"] = "transport_lost"
|
||||
results["tests_not_run"] = tests_not_run
|
||||
_annotate_edited_scene(results)
|
||||
return {"data": results}
|
||||
"paused":
|
||||
var depth := _connection.pause_depth() if _connection != null else 0
|
||||
if _log_buffer != null:
|
||||
_log_buffer.log(
|
||||
"[error] test run aborted in %s: transport paused at checkpoint (depth %d)"
|
||||
% [phase, depth]
|
||||
)
|
||||
var paused_err := ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
(
|
||||
"Test run aborted: the MCP transport was paused at a between-test "
|
||||
+ "checkpoint (pause depth %d) — a paused transport cannot service "
|
||||
+ "the WebSocket heartbeat, so continuing would starve the session. "
|
||||
+ "Partial results: test_manage(op=\"results_get\")."
|
||||
) % depth
|
||||
)
|
||||
paused_err["error"]["data"] = _abort_data(
|
||||
phase, results, tests_not_run, elapsed_ms, budget_sec, {"pause_depth": depth}
|
||||
)
|
||||
return paused_err
|
||||
"timeout":
|
||||
var timeout_err := ErrorCodes.make(
|
||||
ErrorCodes.TEST_RUN_TIMEOUT,
|
||||
(
|
||||
"Test run hit its abort ceiling after %.1fs (budget %.0fs, ceiling = "
|
||||
+ "budget - %.0fs): %d passed, %d failed, %d of the selected tests "
|
||||
+ "never ran. Narrow the run with suite=/test_name= filters, or fetch "
|
||||
+ "the partial results with test_manage(op=\"results_get\")."
|
||||
) % [
|
||||
elapsed_ms / 1000.0, budget_sec, CEILING_MARGIN_SEC,
|
||||
int(results.get("passed", 0)), int(results.get("failed", 0)),
|
||||
tests_not_run,
|
||||
]
|
||||
)
|
||||
timeout_err["error"]["data"] = _abort_data(
|
||||
phase, results, tests_not_run, elapsed_ms, budget_sec, {}
|
||||
)
|
||||
return timeout_err
|
||||
## Unknown outcome is a runner bug — surface it loudly.
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR, "Unknown test run outcome '%s'" % outcome
|
||||
)
|
||||
|
||||
|
||||
func _abort_data(
|
||||
phase: String,
|
||||
results: Dictionary,
|
||||
tests_not_run: int,
|
||||
elapsed_ms: int,
|
||||
budget_sec: float,
|
||||
extra: Dictionary,
|
||||
) -> Dictionary:
|
||||
var data := {
|
||||
"phase": phase,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"budget_sec": budget_sec,
|
||||
"passed": int(results.get("passed", 0)),
|
||||
"failed": int(results.get("failed", 0)),
|
||||
"skipped": int(results.get("skipped", 0)),
|
||||
"total": int(results.get("total", 0)),
|
||||
"tests_not_run": tests_not_run,
|
||||
}
|
||||
data.merge(extra)
|
||||
return data
|
||||
|
||||
|
||||
## Strict validation of the server-provided per-call budget: numeric,
|
||||
## finite, positive, then clamped to [BUDGET_MIN_SEC, BUDGET_MAX_SEC].
|
||||
## Everything else (missing, wrong type, NaN/inf, non-positive) falls back
|
||||
## to BUDGET_DEFAULT_SEC. typeof() so bool never sneaks through as int.
|
||||
func _validated_budget_sec(params: Dictionary) -> float:
|
||||
var raw: Variant = params.get("timeout_budget_sec", null)
|
||||
var t := typeof(raw)
|
||||
if t == TYPE_FLOAT or t == TYPE_INT:
|
||||
var v := float(raw)
|
||||
if is_finite(v) and v > 0.0:
|
||||
return clampf(v, BUDGET_MIN_SEC, BUDGET_MAX_SEC)
|
||||
return BUDGET_DEFAULT_SEC
|
||||
|
||||
|
||||
## Many suites assume the project's main scene is the edited scene (they read
|
||||
## /Main/... nodes directly). Running with another scene open produces a flood
|
||||
## of phantom failures that look like real regressions. Surface the edited
|
||||
## scene and a warning when it differs from run/main_scene so the failures are
|
||||
## attributable at a glance instead of costing a debugging round (#635).
|
||||
func _annotate_edited_scene(results: Dictionary) -> void:
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var edited := scene_root.scene_file_path if scene_root else ""
|
||||
results["edited_scene"] = edited
|
||||
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||||
if main_scene.is_empty() or edited == main_scene:
|
||||
return
|
||||
if int(results.get("failed", 0)) <= 0:
|
||||
return
|
||||
results["scene_warning"] = (
|
||||
"Edited scene is '%s' but the project main scene is '%s'. Many suites "
|
||||
% [edited if not edited.is_empty() else "<none>", main_scene]
|
||||
+ "assume the main scene is open and will report phantom failures "
|
||||
+ "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
|
||||
)
|
||||
|
||||
|
||||
func get_test_results(params: Dictionary) -> Dictionary:
|
||||
var verbose: bool = params.get("verbose", false)
|
||||
return {"data": _runner.get_results(verbose)}
|
||||
|
||||
|
||||
## Returns {"suites": Array, "errors": Array[String], "outcome": String}.
|
||||
## Resilient: a broken script doesn't kill discovery of the rest. A
|
||||
## non-empty outcome ("timeout" / "transport_lost" / "paused") means a
|
||||
## between-load checkpoint aborted discovery — script loading is itself an
|
||||
## atomic phase, and a directory of heavy scripts must neither starve the
|
||||
## heartbeat nor escape the run budget.
|
||||
func _discover_suites(
|
||||
service_cb: Callable = Callable(),
|
||||
deadline_ticks_ms: int = 0,
|
||||
run_state: Dictionary = {},
|
||||
) -> Dictionary:
|
||||
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"],
|
||||
"outcome": "",
|
||||
}
|
||||
|
||||
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 stop := _discovery_checkpoint(service_cb, deadline_ticks_ms, run_state)
|
||||
if not stop.is_empty():
|
||||
return {"suites": suites, "errors": errors, "outcome": stop}
|
||||
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, "outcome": ""}
|
||||
|
||||
|
||||
## Discovery-phase twin of McpTestRunner._checkpoint. Both delegate to the
|
||||
## shared McpConnection.exclusive_run_checkpoint so the outcome mapping
|
||||
## cannot drift between the discovery and between-test paths.
|
||||
func _discovery_checkpoint(
|
||||
service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary
|
||||
) -> String:
|
||||
return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state)
|
||||
@@ -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,
|
||||
McpPropertyErrors.build_message(node, property)
|
||||
)
|
||||
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,476 @@
|
||||
@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
|
||||
var _connection: McpConnection
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
|
||||
_undo_redo = undo_redo
|
||||
_connection = connection
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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 := McpResourceIO.guarded_save(theme, path, _connection)
|
||||
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)
|
||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||
|
||||
|
||||
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)
|
||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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)
|
||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||
|
||||
|
||||
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)
|
||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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.
|
||||
## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
|
||||
## support and strict key/component checking, same shapes as every other
|
||||
## color-accepting handler.
|
||||
static func _parse_color(value: Variant) -> Variant:
|
||||
return McpJsonValues.parse_color(value)
|
||||
|
||||
|
||||
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,163 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells
|
||||
## directly in the editor scene with full undo/redo support.
|
||||
##
|
||||
## All ops target TileMapLayer nodes in the currently edited scene by
|
||||
## scene-relative path (e.g. "/LavaLake20x20/Ground").
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const MAX_RECT_FILL_CELLS := 4096
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
|
||||
|
||||
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = undo_redo
|
||||
|
||||
|
||||
## Set a single tile cell.
|
||||
## params: {path, source_id, atlas_col, atlas_row, map_x, map_y}
|
||||
## Returns: {map_x, map_y, source_id, atlas_col, atlas_row}
|
||||
func set_cell(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0))
|
||||
var src := int(params.get("source_id", 0))
|
||||
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
|
||||
var prev := _capture_cell_state(node, pos)
|
||||
_undo_redo.create_action("MCP: TileMap set_cell")
|
||||
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
|
||||
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src,
|
||||
"atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}}
|
||||
|
||||
|
||||
## Fill a rectangular region with one tile type in a single undo action.
|
||||
## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h}
|
||||
## Returns: {cells_filled, rect: {x, y, w, h}}
|
||||
func set_cells_rect(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var src := int(params.get("source_id", 0))
|
||||
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
|
||||
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0))
|
||||
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1))
|
||||
if rw <= 0 or rh <= 0:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh]
|
||||
)
|
||||
var cell_count := rw * rh
|
||||
if cell_count > MAX_RECT_FILL_CELLS:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS]
|
||||
)
|
||||
var cells: Array[Vector2i] = []
|
||||
var snapshot: Array[Dictionary] = []
|
||||
for x in range(rx, rx + rw):
|
||||
for y in range(ry, ry + rh):
|
||||
var pos := Vector2i(x, y)
|
||||
cells.append(pos)
|
||||
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
|
||||
_undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh])
|
||||
for pos in cells:
|
||||
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
|
||||
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"cells_filled": cells.size(),
|
||||
"rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}}
|
||||
|
||||
|
||||
## Remove all tiles from a TileMapLayer.
|
||||
## params: {path}
|
||||
## Returns: {cleared: true}
|
||||
func clear_layer(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var snapshot := _capture_used_cells_snapshot(node)
|
||||
_undo_redo.create_action("MCP: TileMap clear")
|
||||
_undo_redo.add_do_method(node, "clear")
|
||||
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
|
||||
_undo_redo.commit_action()
|
||||
return {"data": {"cleared": true, "undoable": true}}
|
||||
|
||||
|
||||
## Return all used cell coordinates.
|
||||
## params: {path}
|
||||
## Returns: {cells: [{x, y}, ...], count: int}
|
||||
func get_used_cells(params: Dictionary) -> Dictionary:
|
||||
var layer := _resolve_layer(params)
|
||||
if layer.has("error"): return layer
|
||||
var node: TileMapLayer = layer.node
|
||||
var cells := node.get_used_cells()
|
||||
var result: Array = []
|
||||
for c in cells:
|
||||
result.append({"x": c.x, "y": c.y})
|
||||
return {"data": {"cells": result, "count": result.size()}}
|
||||
|
||||
|
||||
## Resolve a TileMapLayer node from params["path"] in the currently edited
|
||||
## scene. Returns {"node": TileMapLayer} on success, or an error dict.
|
||||
func _resolve_layer(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var scene_file: String = params.get("scene_file", "")
|
||||
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var node: Node = resolved.node
|
||||
if not node is TileMapLayer:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||
"Node is not a TileMapLayer: %s" % path)
|
||||
return {"node": node}
|
||||
|
||||
|
||||
func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary:
|
||||
var source_id := node.get_cell_source_id(pos)
|
||||
if source_id == -1:
|
||||
return {"has_tile": false}
|
||||
var atlas: Vector2i = node.get_cell_atlas_coords(pos)
|
||||
var alternative := node.get_cell_alternative_tile(pos)
|
||||
return {
|
||||
"has_tile": true,
|
||||
"source_id": source_id,
|
||||
"atlas_col": atlas.x,
|
||||
"atlas_row": atlas.y,
|
||||
"alternative": alternative,
|
||||
}
|
||||
|
||||
|
||||
func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]:
|
||||
var snapshot: Array[Dictionary] = []
|
||||
for pos in node.get_used_cells():
|
||||
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
|
||||
return snapshot
|
||||
|
||||
|
||||
func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
|
||||
node.clear()
|
||||
for entry in snapshot:
|
||||
_restore_cell_state(node, entry.pos, entry.state)
|
||||
|
||||
|
||||
func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
|
||||
for entry in snapshot:
|
||||
_restore_cell_state(node, entry.pos, entry.state)
|
||||
|
||||
|
||||
func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void:
|
||||
if not state.get("has_tile", false):
|
||||
node.erase_cell(pos)
|
||||
return
|
||||
node.set_cell(
|
||||
pos,
|
||||
int(state.get("source_id", -1)),
|
||||
Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))),
|
||||
int(state.get("alternative", 0))
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cm8s7a0ey2q6k
|
||||
@@ -0,0 +1,178 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## TileSet management — atlas inspection helpers.
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## Query all occupied atlas tile positions for a single source.
|
||||
##
|
||||
## params:
|
||||
## tileset_path — res:// path to the TileSet resource (required, non-empty)
|
||||
## source_id — raw TileSet source id (required)
|
||||
##
|
||||
## Returns:
|
||||
## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}}
|
||||
## on success (including empty sources, where tiles=[] and count=0)
|
||||
## ErrorCodes.make(code, message) on any validation or load failure
|
||||
##
|
||||
## Error codes:
|
||||
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
|
||||
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
|
||||
## WRONG_TYPE — loaded resource is not a TileSet, or source is
|
||||
## not a TileSetAtlasSource
|
||||
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
|
||||
##
|
||||
## This method is read-only: it never calls ResourceSaver or modifies any resource.
|
||||
func get_atlas_tiles(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_atlas_source(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var src: TileSetAtlasSource = resolved.src
|
||||
|
||||
var tiles: Array = []
|
||||
for i in range(src.get_tiles_count()):
|
||||
var v: Vector2i = src.get_tile_id(i)
|
||||
tiles.append({"col": v.x, "row": v.y})
|
||||
|
||||
return {"data": {"tiles": tiles, "count": tiles.size()}}
|
||||
|
||||
|
||||
## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG.
|
||||
##
|
||||
## params:
|
||||
## tileset_path — res:// path to the TileSet resource (required, non-empty)
|
||||
## source_id — raw TileSet source id (required)
|
||||
## max_size — optional int; if > 0, the image is scaled so its longest
|
||||
## edge is at most max_size pixels (default 0 = full res)
|
||||
##
|
||||
## Returns:
|
||||
## {"data": {"image_base64": String, "width": int, "height": int,
|
||||
## "original_width": int, "original_height": int, "format": "png"}}
|
||||
## on success
|
||||
## ErrorCodes.make(code, message) on any validation or load failure
|
||||
##
|
||||
## Error codes:
|
||||
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
|
||||
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
|
||||
## WRONG_TYPE — loaded resource is not a TileSet, or source is
|
||||
## not a TileSetAtlasSource, or texture is null
|
||||
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
|
||||
##
|
||||
## This method is read-only: it never calls ResourceSaver or modifies anything.
|
||||
func get_atlas_image(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_atlas_source(params)
|
||||
if resolved.has("error"):
|
||||
return resolved
|
||||
var source_id: int = resolved.source_id
|
||||
var src: TileSetAtlasSource = resolved.src
|
||||
|
||||
var tex: Texture2D = src.texture
|
||||
if tex == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Source %d has no texture assigned" % source_id
|
||||
)
|
||||
|
||||
var img: Image = tex.get_image()
|
||||
if img == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Could not retrieve image data from texture of source %d" % source_id
|
||||
)
|
||||
if img.is_compressed():
|
||||
var decompress_err := img.decompress()
|
||||
if decompress_err != OK:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)]
|
||||
)
|
||||
|
||||
var original_width: int = img.get_width()
|
||||
var original_height: int = img.get_height()
|
||||
|
||||
var max_size: int = params.get("max_size", 0)
|
||||
if max_size > 0:
|
||||
var longest_edge: int = max(original_width, original_height)
|
||||
if longest_edge > max_size:
|
||||
var scale: float = float(max_size) / float(longest_edge)
|
||||
var new_w: int = max(1, int(original_width * scale))
|
||||
var new_h: int = max(1, int(original_height * scale))
|
||||
img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
|
||||
|
||||
var png_bytes: PackedByteArray = img.save_png_to_buffer()
|
||||
if png_bytes.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"PNG encoding produced empty output for source %d" % source_id
|
||||
)
|
||||
var b64: String = Marshalls.raw_to_base64(png_bytes)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"image_base64": b64,
|
||||
"width": img.get_width(),
|
||||
"height": img.get_height(),
|
||||
"original_width": original_width,
|
||||
"original_height": original_height,
|
||||
"format": "png",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _resolve_atlas_source(params: Dictionary) -> Dictionary:
|
||||
var tileset_path: String = params.get("tileset_path", "")
|
||||
if tileset_path.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.MISSING_REQUIRED_PARAM,
|
||||
"'tileset_path' parameter is required and must not be empty"
|
||||
)
|
||||
|
||||
if not params.has("source_id"):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.MISSING_REQUIRED_PARAM,
|
||||
"'source_id' parameter is required"
|
||||
)
|
||||
|
||||
var tileset_path_err = McpPathValidator.loadable_error(tileset_path, "tileset_path")
|
||||
if tileset_path_err != null:
|
||||
return tileset_path_err
|
||||
|
||||
if not ResourceLoader.exists(tileset_path):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.RESOURCE_NOT_FOUND,
|
||||
"TileSet resource not found: %s" % tileset_path
|
||||
)
|
||||
|
||||
var ts = load(tileset_path)
|
||||
if not ts is TileSet:
|
||||
var loaded_type := "null" if ts == null else ts.get_class()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type]
|
||||
)
|
||||
|
||||
var source_id: int = int(params.get("source_id", -999))
|
||||
if source_id < 0 or not ts.has_source(source_id):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
"source_id %d does not exist in TileSet" % source_id
|
||||
)
|
||||
|
||||
var src = ts.get_source(source_id)
|
||||
if not src is TileSetAtlasSource:
|
||||
var source_type: String = "null" if src == null else src.get_class()
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type]
|
||||
)
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"src": src,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://de3v4m1pnk7tr
|
||||
@@ -0,0 +1,525 @@
|
||||
@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:
|
||||
## Canonical parser (#714): adds [r,g,b(,a)] array support and
|
||||
## strict key/component checking, same shapes everywhere.
|
||||
var parsed_color = McpJsonValues.parse_color(value)
|
||||
if parsed_color != null:
|
||||
return {"ok": true, "value": parsed_color}
|
||||
return {"ok": false}
|
||||
TYPE_VECTOR2:
|
||||
## Same canonical parser as TYPE_COLOR (CodeRabbit review):
|
||||
## keeping the inline copy here would re-introduce exactly the
|
||||
## permissive-vs-strict drift this PR removes elsewhere.
|
||||
var parsed_v2 = McpJsonValues.parse_vector2(value)
|
||||
if parsed_v2 != null:
|
||||
return {"ok": true, "value": parsed_v2}
|
||||
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