2026-10-09 12:49:07 +02:00

275 lines
10 KiB
GDScript

@tool
extends RefCounted
## tileset.* structure commands: define a TileSet's atlas-source set and layer
## skeleton — create-from-texture, add/remove source, and setup the layer counts.
##
## Stateless — every handler takes its parameters Dictionary and returns the
## response Dictionary. Persistence, the layer-node hint, and the full-tile polygon
## come from the shared tileset_io.gd leaf (_IO); this module owns only the
## structural-shape logic. An extracted submodule of the tileset-command group,
## reached via a `preload` alias.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const _IO := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd")
const Coerce = Modules.Coerce
const FileGuard = Modules.FileGuard
const Helpers = Modules.CommandHelpers
# -- Commands -----------------------------------------------------------------
static func cmd_create(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path", "texture_path"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
var texture_path := str(parameters.get("texture_path", ""))
var tile_size_raw = parameters.get("tile_size", {"x": 16, "y": 16})
var tile_w := int(tile_size_raw.get("x", 16)) if typeof(tile_size_raw) == TYPE_DICTIONARY else 16
var tile_h := int(tile_size_raw.get("y", 16)) if typeof(tile_size_raw) == TYPE_DICTIONARY else 16
var guard := FileGuard.resolve_safe(file_path)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if not file_path.get_extension().to_lower() in ["tres", "res"]:
return MCPToolkitError.fail("INVALID_PATH",
"tileset_create writes .tres/.res files (got %s)" % file_path)
var texture: Texture2D = load(texture_path) as Texture2D
if texture == null:
return MCPToolkitError.fail("NOT_FOUND",
"texture not found or not a Texture2D: %s" % texture_path)
var ts := TileSet.new()
ts.tile_size = Vector2i(tile_w, tile_h)
var physics: bool = parameters.get("physics", true)
if physics:
ts.add_physics_layer()
var collision_layer := Coerce.layers_to_mask(parameters.get("collision_layer", 1))
var collision_mask := Coerce.layers_to_mask(parameters.get("collision_mask", 1))
ts.set_physics_layer_collision_layer(0, collision_layer)
ts.set_physics_layer_collision_mask(0, collision_mask)
var source := TileSetAtlasSource.new()
source.texture = texture
source.texture_region_size = Vector2i(tile_w, tile_h)
var source_id := ts.add_source(source)
var tex_size := texture.get_size()
var cols := int(tex_size.x) / tile_w
var rows := int(tex_size.y) / tile_h
var tiles_created := 0
var full_tile_polygon := _IO.build_full_tile_polygon(ts.tile_size)
for row in range(rows):
for col in range(cols):
var atlas_coord := Vector2i(col, row)
source.create_tile(atlas_coord)
if physics:
var td: TileData = source.get_tile_data(atlas_coord, 0)
td.add_collision_polygon(0)
td.set_collision_polygon_points(0, 0, full_tile_polygon)
tiles_created += 1
var dir_result := Helpers.ensure_parent_dir(file_path, "tileset.create")
if dir_result.has("error"):
return dir_result
var save_err := ResourceSaver.save(ts, file_path)
if save_err != OK:
return MCPToolkitError.fail("SAVE_FAILED",
"ResourceSaver.save returned %d (path=%s)" % [save_err, file_path])
var loaded := ResourceLoader.load(file_path, "TileSet", ResourceLoader.CACHE_MODE_REPLACE)
if loaded == null or not (loaded is TileSet):
return MCPToolkitError.fail("SAVE_FAILED",
"tileset saved but reload failed — file may be corrupt: %s" % file_path)
await Helpers.ensure_file_indexed(file_path)
var create_result := {
"path": file_path,
"tile_size": {"x": tile_w, "y": tile_h},
"source_id": source_id,
"atlas_grid": {"columns": cols, "rows": rows},
"tiles_created": tiles_created,
"physics": physics,
}
create_result["hint"] = _IO.layer_node_hint("Assign this TileSet to a ", " node.")
return MCPToolkitSuccess.ok(create_result)
static func cmd_add_source(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path", "texture_path"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
var ts_or_err = _IO.load_tileset(file_path)
if ts_or_err is Dictionary:
return ts_or_err
var ts: TileSet = ts_or_err
var result = _apply_add_source(ts, parameters)
if result.has("error"):
return result
var save_result := await _IO.save_tileset(ts, file_path)
if save_result.has("error"):
return save_result
var source_id: int = result["source_id"]
return MCPToolkitSuccess.ok({
"path": file_path,
"new_source_id": source_id,
"hint": _IO.layer_node_hint(
"Configure tiles on source %d with tileset.edit_* tools, or paint onto a " % source_id,
" with tilemap.set_cells."),
})
static func cmd_remove_source(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path", "source_id"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
var source_id := int(parameters.get("source_id", 0))
var ts_or_err = _IO.load_tileset(file_path)
if ts_or_err is Dictionary:
return ts_or_err
var ts: TileSet = ts_or_err
if not ts.has_source(source_id):
return MCPToolkitError.fail("NOT_FOUND",
"No source with id %d in TileSet" % source_id)
ts.remove_source(source_id)
var save_result := await _IO.save_tileset(ts, file_path)
if save_result.has("error"):
return save_result
return MCPToolkitSuccess.ok({
"path": file_path,
"removed_source_id": source_id,
"hint": _IO.layer_node_hint(
"", " cells referencing source %d may become invalid. Check with tilemap.read_cells." % source_id),
})
static func cmd_setup_layers(parameters: Dictionary) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
var ts_or_err = _IO.load_tileset(file_path)
if ts_or_err is Dictionary:
return ts_or_err
var ts: TileSet = ts_or_err
var result = _apply_layers(ts, parameters)
if result.has("error"):
return result
var save_result := await _IO.save_tileset(ts, file_path)
if save_result.has("error"):
return save_result
return MCPToolkitSuccess.ok({
"path": file_path,
"hint": _IO.layer_node_hint(
"Layers configured. Assign per-tile data with tileset.edit_physics, tileset.edit_terrain, etc., then paint onto a ",
" with tilemap.set_cells."),
})
# -- structure sub-helpers ----------------------------------------------------
static func _apply_add_source(ts: TileSet, cfg: Dictionary) -> Dictionary:
var tex_path := str(cfg.get("texture_path", ""))
if tex_path.is_empty():
return MCPToolkitError.fail("INVALID_PARAMS", "add_source.texture_path required")
var texture: Texture2D = load(tex_path) as Texture2D
if texture == null:
return MCPToolkitError.fail("NOT_FOUND",
"add_source texture not found: %s" % tex_path)
var source := TileSetAtlasSource.new()
source.texture = texture
var tile_w := int(cfg.get("tile_size", {}).get("x", ts.tile_size.x))
var tile_h := int(cfg.get("tile_size", {}).get("y", ts.tile_size.y))
source.texture_region_size = Vector2i(tile_w, tile_h)
var sid := ts.add_source(source)
# Auto-create tiles
var tex_size := texture.get_size()
var cols := int(tex_size.x) / tile_w
var rows := int(tex_size.y) / tile_h
for row in range(rows):
for col in range(cols):
source.create_tile(Vector2i(col, row))
return {"source_id": sid}
static func _apply_layers(ts: TileSet, layers: Dictionary) -> Dictionary:
# Terrain sets
if layers.has("terrain_sets") and typeof(layers["terrain_sets"]) == TYPE_ARRAY:
for ts_def in layers["terrain_sets"]:
if typeof(ts_def) != TYPE_DICTIONARY:
continue
var idx := ts.get_terrain_sets_count()
ts.add_terrain_set()
var mode_str := str(ts_def.get("mode", "match_corners_and_sides"))
var mode: int = TileSet.TERRAIN_MODE_MATCH_CORNERS_AND_SIDES
if mode_str == "match_corners":
mode = TileSet.TERRAIN_MODE_MATCH_CORNERS
elif mode_str == "match_sides":
mode = TileSet.TERRAIN_MODE_MATCH_SIDES
ts.set_terrain_set_mode(idx, mode)
if ts_def.has("terrains") and typeof(ts_def["terrains"]) == TYPE_ARRAY:
for t_name in ts_def["terrains"]:
ts.add_terrain(idx)
var t_idx := ts.get_terrains_count(idx) - 1
ts.set_terrain_name(idx, t_idx, str(t_name))
# Custom data layers
if layers.has("custom_data") and typeof(layers["custom_data"]) == TYPE_ARRAY:
for cd_def in layers["custom_data"]:
if typeof(cd_def) != TYPE_DICTIONARY:
continue
var layer_name := str(cd_def.get("name", ""))
if layer_name.is_empty():
continue
# Check if layer already exists
var exists := false
for li in range(ts.get_custom_data_layers_count()):
if ts.get_custom_data_layer_name(li) == layer_name:
exists = true
break
if exists:
continue
ts.add_custom_data_layer()
var li := ts.get_custom_data_layers_count() - 1
ts.set_custom_data_layer_name(li, layer_name)
var type_str := str(cd_def.get("type", "int")).to_lower()
ts.set_custom_data_layer_type(li, _variant_type_from_string(type_str))
# Navigation layers (safe coercion via str() for non-int input)
if layers.has("navigation_layers"):
var want := int(str(layers["navigation_layers"]))
while ts.get_navigation_layers_count() < want:
ts.add_navigation_layer()
# Occlusion layers
if layers.has("occlusion_layers"):
var want := int(str(layers["occlusion_layers"]))
while ts.get_occlusion_layers_count() < want:
ts.add_occlusion_layer()
# Physics layers
if layers.has("physics_layers"):
var want := int(str(layers["physics_layers"]))
while ts.get_physics_layers_count() < want:
ts.add_physics_layer()
return {}
static func _variant_type_from_string(s: String) -> int:
match s:
"int": return TYPE_INT
"float": return TYPE_FLOAT
"bool": return TYPE_BOOL
"string": return TYPE_STRING
"vector2": return TYPE_VECTOR2
"vector2i": return TYPE_VECTOR2I
"vector3": return TYPE_VECTOR3
"color": return TYPE_COLOR
_: return TYPE_INT