Randoloot/addons/godot_mcp_toolkit/commands/spriteframes_commands.gd
2026-10-09 12:49:07 +02:00

372 lines
14 KiB
GDScript

@tool
extends RefCounted
## spriteframes.* command handlers — SpriteFrames resource creation and editing.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const FileGuard = Modules.FileGuard
const Helpers = Modules.CommandHelpers
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
registry.add("spriteframes.create", func(p: Dictionary) -> Dictionary:
return _cmd_create(p)
, MCPToolkitCommandOptions.new().mark_scene_independent())
registry.add("spriteframes.edit", func(p: Dictionary) -> Dictionary:
return _cmd_edit(p)
, MCPToolkitCommandOptions.new().mark_scene_independent())
registry.add("spriteframes.from_spritesheet", func(p: Dictionary) -> Dictionary:
return _cmd_from_spritesheet(p)
, MCPToolkitCommandOptions.new().mark_scene_independent())
# -- Commands -----------------------------------------------------------------
static func _cmd_create(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path", "animations"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
file_path = Helpers.normalize_editor_path(file_path)
var animations_raw = parameters.get("animations", null)
if typeof(animations_raw) != TYPE_ARRAY or animations_raw.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animations must be a non-empty Array")
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if file_path.get_extension().to_lower() != "tres":
return MCPToolkitError.fail("INVALID_PATH",
"spriteframes.create writes .tres files (got %s)" % file_path)
# Idempotency: if file already exists, return early.
if FileAccess.file_exists(file_path):
return MCPToolkitSuccess.ok({"status": "returned", "path": file_path})
var sf := SpriteFrames.new()
var anim_info := []
for anim_raw in animations_raw:
if typeof(anim_raw) != TYPE_DICTIONARY:
return MCPToolkitError.fail("INVALID_PARAMS", "each animation must be a Dictionary")
var anim_name := str(anim_raw.get("name", ""))
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation name is required")
var fps: float = float(anim_raw.get("fps", 5.0))
var loop: bool = bool(anim_raw.get("loop", true))
var frames_raw = anim_raw.get("frames", [])
sf.add_animation(anim_name)
sf.set_animation_speed(anim_name, fps)
sf.set_animation_loop(anim_name, loop)
if typeof(frames_raw) == TYPE_ARRAY:
for frame_raw in frames_raw:
var result = _load_frame(frame_raw)
if result.has("error"):
return result
sf.add_frame(anim_name, result["texture"], result["duration"])
anim_info.append({
"name": anim_name,
"frame_count": sf.get_frame_count(anim_name),
"fps": fps,
"loop": loop,
})
# Remove the auto-created "default" animation when custom ones provided.
if sf.has_animation("default") and anim_info.size() > 0:
sf.remove_animation("default")
var dir_path := file_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var save_err := ResourceSaver.save(sf, file_path)
if save_err != OK:
return MCPToolkitError.fail("SAVE_FAILED",
"ResourceSaver.save() returned error %d for %s" % [save_err, file_path])
return MCPToolkitSuccess.ok({"path": file_path, "status": "created", "animations": anim_info})
static func _cmd_edit(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path", "action"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
file_path = Helpers.normalize_editor_path(file_path)
var action := str(parameters.get("action", ""))
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if not FileAccess.file_exists(file_path):
return MCPToolkitError.fail("NOT_FOUND", "SpriteFrames resource not found: %s" % file_path)
var loaded = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_IGNORE)
if loaded == null or not (loaded is SpriteFrames):
return MCPToolkitError.fail("INVALID_CLASS",
"Resource at %s is not a SpriteFrames" % file_path)
var sf: SpriteFrames = loaded as SpriteFrames
var anim_name := str(parameters.get("animation_name", ""))
match action:
"list":
return _build_list(sf)
"add_animation":
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required")
if sf.has_animation(anim_name):
return MCPToolkitSuccess.ok({"action": action, "animation_name": anim_name, "status": "returned"})
sf.add_animation(anim_name)
var fps_raw = parameters.get("fps", null)
if fps_raw != null:
sf.set_animation_speed(anim_name, float(fps_raw))
var loop_raw = parameters.get("loop", null)
if loop_raw != null:
sf.set_animation_loop(anim_name, bool(loop_raw))
"remove_animation":
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required")
if not sf.has_animation(anim_name):
return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name)
sf.remove_animation(anim_name)
"add_frame":
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required")
if not sf.has_animation(anim_name):
return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name)
var frames_raw = parameters.get("frames", null)
if frames_raw == null or typeof(frames_raw) != TYPE_ARRAY or frames_raw.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "frames array is required for add_frame")
for frame_raw in frames_raw:
var result = _load_frame(frame_raw)
if result.has("error"):
return result
sf.add_frame(anim_name, result["texture"], result["duration"])
"remove_frame":
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required")
if not sf.has_animation(anim_name):
return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name)
var frame_index_raw = parameters.get("frame_index", null)
if frame_index_raw == null:
return MCPToolkitError.fail("INVALID_PARAMS", "frame_index is required for remove_frame")
var idx := int(frame_index_raw)
if idx < 0 or idx >= sf.get_frame_count(anim_name):
return MCPToolkitError.fail("INVALID_PARAMS",
"frame_index %d out of range (0..%d)" % [idx, sf.get_frame_count(anim_name) - 1])
sf.remove_frame(anim_name, idx)
"set_fps":
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required")
if not sf.has_animation(anim_name):
return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name)
var fps_val = parameters.get("fps", null)
if fps_val == null:
return MCPToolkitError.fail("INVALID_PARAMS", "fps is required for set_fps")
sf.set_animation_speed(anim_name, float(fps_val))
"set_loop":
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required")
if not sf.has_animation(anim_name):
return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name)
var loop_val = parameters.get("loop", null)
if loop_val == null:
return MCPToolkitError.fail("INVALID_PARAMS", "loop is required for set_loop")
sf.set_animation_loop(anim_name, bool(loop_val))
"reorder_frames":
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation_name is required")
if not sf.has_animation(anim_name):
return MCPToolkitError.fail("NOT_FOUND", "Animation '%s' not found" % anim_name)
var from_idx_raw = parameters.get("frame_index", null)
var to_idx_raw = parameters.get("new_index", null)
if from_idx_raw == null or to_idx_raw == null:
return MCPToolkitError.fail("INVALID_PARAMS", "frame_index and new_index are required for reorder_frames")
var from_idx := int(from_idx_raw)
var to_idx := int(to_idx_raw)
var count := sf.get_frame_count(anim_name)
if from_idx < 0 or from_idx >= count or to_idx < 0 or to_idx >= count:
return MCPToolkitError.fail("INVALID_PARAMS",
"frame_index/new_index out of range (0..%d)" % [count - 1])
var tex = sf.get_frame_texture(anim_name, from_idx)
var dur := sf.get_frame_duration(anim_name, from_idx)
sf.remove_frame(anim_name, from_idx)
sf.add_frame(anim_name, tex, dur, to_idx)
_:
return MCPToolkitError.fail("INVALID_PARAMS",
"Unknown action '%s'. Expected: add_animation, remove_animation, add_frame, remove_frame, set_fps, set_loop, reorder_frames, list" % action)
var save_err := ResourceSaver.save(sf, file_path)
if save_err != OK:
return MCPToolkitError.fail("SAVE_FAILED",
"ResourceSaver.save() returned error %d for %s" % [save_err, file_path])
return MCPToolkitSuccess.ok({"action": action, "animation_name": anim_name})
static func _cmd_from_spritesheet(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path", "texture_path", "frame_size", "animations"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
file_path = Helpers.normalize_editor_path(file_path)
var texture_path := str(parameters.get("texture_path", ""))
texture_path = Helpers.normalize_editor_path(texture_path)
var frame_size_raw = parameters.get("frame_size", null)
var animations_raw = parameters.get("animations", null)
if typeof(frame_size_raw) != TYPE_DICTIONARY:
return MCPToolkitError.fail("INVALID_PARAMS", "frame_size must be {x, y}")
var frame_w := int(frame_size_raw.get("x", 0))
var frame_h := int(frame_size_raw.get("y", 0))
if frame_w <= 0 or frame_h <= 0:
return MCPToolkitError.fail("INVALID_PARAMS", "frame_size x and y must be positive integers")
if typeof(animations_raw) != TYPE_ARRAY or animations_raw.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animations must be a non-empty Array")
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if file_path.get_extension().to_lower() != "tres":
return MCPToolkitError.fail("INVALID_PATH",
"spriteframes.from_spritesheet writes .tres files (got %s)" % file_path)
# Idempotency.
if FileAccess.file_exists(file_path):
return MCPToolkitSuccess.ok({"status": "returned", "path": file_path})
if not ResourceLoader.exists(texture_path):
return MCPToolkitError.fail("NOT_FOUND", "Spritesheet texture not found: %s" % texture_path)
var sheet_tex = ResourceLoader.load(texture_path)
if sheet_tex == null or not (sheet_tex is Texture2D):
return MCPToolkitError.fail("NOT_FOUND", "Failed to load spritesheet texture: %s" % texture_path)
var tex_size: Vector2 = sheet_tex.get_size()
var cols := int(tex_size.x) / frame_w
var rows := int(tex_size.y) / frame_h
var sf := SpriteFrames.new()
var anim_info := []
for anim_raw in animations_raw:
if typeof(anim_raw) != TYPE_DICTIONARY:
return MCPToolkitError.fail("INVALID_PARAMS", "each animation must be a Dictionary")
var anim_name := str(anim_raw.get("name", ""))
if anim_name.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "animation name is required")
var row := int(anim_raw.get("row", 0))
var start_col := int(anim_raw.get("start_col", 0))
var frame_count := int(anim_raw.get("frame_count", 0))
if frame_count <= 0:
return MCPToolkitError.fail("INVALID_PARAMS",
"frame_count must be positive for animation '%s'" % anim_name)
if row < 0 or row >= rows:
return MCPToolkitError.fail("INVALID_PARAMS",
"row %d out of bounds (texture has %d rows of %dpx)" % [row, rows, frame_h])
if start_col < 0 or (start_col + frame_count) > cols:
return MCPToolkitError.fail("INVALID_PARAMS",
"columns %d..%d out of bounds (texture has %d cols of %dpx)" % [
start_col, start_col + frame_count - 1, cols, frame_w])
var fps: float = float(anim_raw.get("fps", 5.0))
var loop: bool = bool(anim_raw.get("loop", true))
sf.add_animation(anim_name)
sf.set_animation_speed(anim_name, fps)
sf.set_animation_loop(anim_name, loop)
for col_idx in range(start_col, start_col + frame_count):
var atlas := AtlasTexture.new()
atlas.atlas = sheet_tex
atlas.region = Rect2(col_idx * frame_w, row * frame_h, frame_w, frame_h)
sf.add_frame(anim_name, atlas)
anim_info.append({
"name": anim_name,
"frame_count": frame_count,
"fps": fps,
"loop": loop,
})
if sf.has_animation("default") and anim_info.size() > 0:
sf.remove_animation("default")
var dir_path := file_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var save_err := ResourceSaver.save(sf, file_path)
if save_err != OK:
return MCPToolkitError.fail("SAVE_FAILED",
"ResourceSaver.save() returned error %d for %s" % [save_err, file_path])
return MCPToolkitSuccess.ok({"path": file_path, "status": "created", "animations": anim_info})
# -- Helpers ------------------------------------------------------------------
static func _load_frame(frame_raw) -> Dictionary:
if typeof(frame_raw) != TYPE_DICTIONARY:
return MCPToolkitError.fail("INVALID_PARAMS", "each frame must be a Dictionary")
var texture_path := str(frame_raw.get("texture_path", ""))
if texture_path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "frame texture path is required")
if not ResourceLoader.exists(texture_path):
return MCPToolkitError.fail("NOT_FOUND", "Texture not found: %s" % texture_path)
var tex = ResourceLoader.load(texture_path)
if tex == null or not (tex is Texture2D):
return MCPToolkitError.fail("NOT_FOUND", "Failed to load texture: %s" % texture_path)
var atlas_raw = frame_raw.get("atlas", null)
if atlas_raw != null and typeof(atlas_raw) == TYPE_DICTIONARY:
var atlas := AtlasTexture.new()
atlas.atlas = tex
atlas.region = Rect2(
float(atlas_raw.get("x", 0)),
float(atlas_raw.get("y", 0)),
float(atlas_raw.get("width", 0)),
float(atlas_raw.get("height", 0)))
tex = atlas
var duration: float = float(frame_raw.get("duration", 1.0))
return {"texture": tex, "duration": duration}
static func _build_list(sf: SpriteFrames) -> Dictionary:
var anims := []
for anim_name in sf.get_animation_names():
anims.append({
"name": str(anim_name),
"frame_count": sf.get_frame_count(anim_name),
"fps": sf.get_animation_speed(anim_name),
"loop": sf.get_animation_loop(anim_name),
})
return MCPToolkitSuccess.ok({"animations": anims})