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

400 lines
14 KiB
GDScript

@tool
extends RefCounted
## tileset.edit_* per-tile data commands: apply a verb-scoped set of per-tile
## TileData edits (physics / terrain / navigation / occlusion+animation / custom
## data), rejecting any key owned by a different edit verb.
##
## One parametric operation — cmd_edit(parameters, verb) — with a verb-indexed
## strategy table. The per-tile-edit policy (which keys are legal per verb, the
## _EDIT_* tables + _foreign_key_error gate) and its mechanism (how each key
## mutates TileData, the _apply_* flows + geometry builders) are inseparable, so
## they live together here. Persistence, the layer-node hint, and the full-tile
## polygon come from the shared tileset_io.gd leaf (_IO); consumed by
## tileset_commands.gd via a `preload` alias.
const _IO := preload("res://addons/godot_mcp_toolkit/commands/tileset/tileset_io.gd")
# -- Per-verb key enforcement -------------------------------------------------
#
# Each tileset.edit_* tool owns exactly one tile-data concern. The five tools all
# share cmd_edit, so without enforcement a key meant for one verb (e.g.
# terrain_set) applied silently under another (e.g. edit_physics). These tables
# make the verb authoritative: a verb accepts only its own per-tile keys plus the
# universal coordinate selectors, and any foreign key is rejected with a hint that
# names the tool that DOES own it (reverse lookup via _EDIT_KEY_OWNER).
## Coordinate selectors every verb needs to address a tile.
const _EDIT_COORD_KEYS := ["atlas_x", "atlas_y"]
## Per-tile keys each verb is allowed to read/apply. The key sets are mutually
## exclusive — one key belongs to exactly one verb — which is what lets a single
## reverse lookup name the owning tool for a rejection hint.
const _EDIT_KEY_SETS := {
"physics": ["physics_polygon", "physics_layer", "one_way_collision"],
"terrain": ["terrain_set", "terrain", "terrain_peering"],
"navigation": ["navigation_polygon", "navigation_layer"],
"visuals": ["occlusion_polygon", "occlusion_layer", "animation", "probability"],
"custom_data": ["custom_data"],
}
## Reverse map: per-tile key -> the tool that owns it, for the rejection hint.
const _EDIT_KEY_OWNER := {
"physics_polygon": "tileset.edit_physics",
"physics_layer": "tileset.edit_physics",
"one_way_collision": "tileset.edit_physics",
"terrain_set": "tileset.edit_terrain",
"terrain": "tileset.edit_terrain",
"terrain_peering": "tileset.edit_terrain",
"navigation_polygon": "tileset.edit_navigation",
"navigation_layer": "tileset.edit_navigation",
"occlusion_polygon": "tileset.edit_visuals",
"occlusion_layer": "tileset.edit_visuals",
"animation": "tileset.edit_visuals",
"probability": "tileset.edit_visuals",
"custom_data": "tileset.edit_custom_data",
}
## Reject the first per-tile key that does not belong to `verb`. Returns "" when
## every key is allowed, otherwise an INVALID_PARAMS message that names the tool
## owning the offending key. Pure (no engine state) so it is unit-testable.
static func _foreign_key_error(verb: String, tile: Dictionary) -> String:
var allowed: Array = _EDIT_KEY_SETS.get(verb, [])
for key in tile:
var key_str := str(key)
if key_str in _EDIT_COORD_KEYS or key_str in allowed:
continue
if _EDIT_KEY_OWNER.has(key_str):
return "key '%s' belongs to %s, not tileset.edit_%s" % [
key_str, _EDIT_KEY_OWNER[key_str], verb]
return "unknown key '%s' for tileset.edit_%s" % [key_str, verb]
return ""
static func cmd_edit(parameters: Dictionary, verb: String) -> Dictionary:
var err = MCPToolkitError.require(parameters, ["file_path"])
if err != null:
return err
var file_path := str(parameters.get("file_path", ""))
var source_id := int(parameters.get("source_id", 0))
var tiles_raw = parameters.get("tiles", null)
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 tile_errors: Array = []
var tiles_modified := 0
# -- per-tile edits --
if tiles_raw != null and typeof(tiles_raw) == TYPE_ARRAY:
if not ts.has_source(source_id):
return MCPToolkitError.fail("NOT_FOUND",
"No source with id %d in TileSet" % source_id)
var source = ts.get_source(source_id)
if not (source is TileSetAtlasSource):
return MCPToolkitError.fail("INVALID_CLASS",
"Source %d is not a TileSetAtlasSource" % source_id)
var atlas: TileSetAtlasSource = source
var tile_size: Vector2i = ts.tile_size
for i in range(tiles_raw.size()):
var tile = tiles_raw[i]
if typeof(tile) != TYPE_DICTIONARY:
tile_errors.append("tiles[%d]: not a dictionary" % i)
continue
if not tile.has("atlas_x") or not tile.has("atlas_y"):
tile_errors.append("tiles[%d]: missing atlas_x/atlas_y" % i)
continue
# Enforce the verb: a key for another tool is a caller mistake, so
# reject the whole call (don't silently apply the wrong concern).
var foreign := _foreign_key_error(verb, tile)
if foreign != "":
return MCPToolkitError.fail("INVALID_PARAMS",
"tiles[%d]: %s" % [i, foreign])
var coord := Vector2i(int(tile["atlas_x"]), int(tile["atlas_y"]))
if not atlas.has_tile(coord):
tile_errors.append("tiles[%d]: tile (%d,%d) not found in source %d" % [
i, coord.x, coord.y, source_id])
continue
var td: TileData = atlas.get_tile_data(coord, 0)
# Count a tile only when at least one real edit key was applied — a
# tile carrying just the coord selectors applies nothing, so it must
# not be counted (matches the pre-split per-tile `modified` flag).
var has_real_edit := false
for k in tile:
if not str(k) in _EDIT_COORD_KEYS:
has_real_edit = true
break
var feature_errors: Array = _apply_verb(verb, atlas, coord, td, tile, tile_size)
if feature_errors.is_empty():
if has_real_edit:
tiles_modified += 1
else:
for fe in feature_errors:
tile_errors.append("tiles[%d]: %s" % [i, str(fe)])
# -- Save --
var save_result := await _IO.save_tileset(ts, file_path)
if save_result.has("error"):
return save_result
var edit_result := {
"path": file_path,
"tiles_modified": tiles_modified,
"errors": tile_errors,
"hint": _IO.layer_node_hint(
"Edit more tile properties with tileset.edit_*, or paint tiles onto a ",
" with tilemap.set_cells."),
}
return MCPToolkitSuccess.ok(edit_result)
# -- Intent-named per-verb flows ----------------------------------------------
#
# One flow per tool, each applying ONLY its own concern's keys to a single tile.
# Each returns the soft per-tile errors it accumulated ([] = fully applied); the
# caller folds those into the per-tile errors[] array exactly as before.
## Route one tile to the flow that matches `verb`. Keys are already verb-checked.
static func _apply_verb(
verb: String, atlas: TileSetAtlasSource, coord: Vector2i,
td: TileData, tile: Dictionary, tile_size: Vector2i
) -> Array:
match verb:
"physics":
return _apply_physics(td, tile, tile_size)
"terrain":
return _apply_terrain(td, tile)
"navigation":
return _apply_navigation(td, tile, tile_size)
"visuals":
return _apply_visuals(atlas, coord, td, tile, tile_size)
"custom_data":
return _apply_custom_data(td, tile)
return ["unknown tileset edit verb: %s" % verb]
## Collision polygon + one-way flag (tileset.edit_physics).
static func _apply_physics(td: TileData, tile: Dictionary, tile_size: Vector2i) -> Array:
var errors: Array = []
if tile.has("physics_polygon"):
var r := _apply_physics_polygon(td, tile, tile_size)
if not r.is_empty():
errors.append(r)
return errors
## Terrain set, terrain index, and peering bits (tileset.edit_terrain).
static func _apply_terrain(td: TileData, tile: Dictionary) -> Array:
var errors: Array = []
if tile.has("terrain_set"):
td.terrain_set = int(tile["terrain_set"])
if tile.has("terrain"):
td.terrain = int(tile["terrain"])
if tile.has("terrain_peering") and typeof(tile["terrain_peering"]) == TYPE_DICTIONARY:
var r := _apply_terrain_peering(td, tile["terrain_peering"])
if not r.is_empty():
errors.append(r)
return errors
## Navigation polygon (tileset.edit_navigation).
static func _apply_navigation(td: TileData, tile: Dictionary, tile_size: Vector2i) -> Array:
var errors: Array = []
if tile.has("navigation_polygon"):
var r := _apply_navigation_polygon(td, tile, tile_size)
if not r.is_empty():
errors.append(r)
return errors
## Occlusion, animation, and probability (tileset.edit_visuals). This tool
## deliberately bundles all three appearance concerns for a tile.
static func _apply_visuals(
atlas: TileSetAtlasSource, coord: Vector2i,
td: TileData, tile: Dictionary, tile_size: Vector2i
) -> Array:
var errors: Array = []
if tile.has("occlusion_polygon"):
var r := _apply_occlusion_polygon(td, tile, tile_size)
if not r.is_empty():
errors.append(r)
if tile.has("animation") and typeof(tile["animation"]) == TYPE_DICTIONARY:
var r := _apply_animation(atlas, coord, tile["animation"])
if not r.is_empty():
errors.append(r)
if tile.has("probability"):
td.probability = float(tile["probability"])
return errors
## Custom data layer values (tileset.edit_custom_data).
static func _apply_custom_data(td: TileData, tile: Dictionary) -> Array:
if tile.has("custom_data") and typeof(tile["custom_data"]) == TYPE_DICTIONARY:
var cd: Dictionary = tile["custom_data"]
for layer_name in cd:
td.set_custom_data(str(layer_name), cd[layer_name])
return []
static func _ensure_collision_polygon(td: TileData, physics_layer: int) -> void:
if td.get_collision_polygons_count(physics_layer) == 0:
td.add_collision_polygon(physics_layer)
static func _apply_physics_polygon(
td: TileData, tile: Dictionary, tile_size: Vector2i
) -> String:
var val = tile["physics_polygon"]
var physics_layer := int(tile.get("physics_layer", 0))
if typeof(val) == TYPE_STRING:
match val:
"full":
_ensure_collision_polygon(td, physics_layer)
td.set_collision_polygon_points(physics_layer, 0,
_IO.build_full_tile_polygon(tile_size))
"none":
while td.get_collision_polygons_count(physics_layer) > 0:
td.remove_collision_polygon(physics_layer, 0)
"one_way":
_ensure_collision_polygon(td, physics_layer)
td.set_collision_polygon_points(physics_layer, 0,
_IO.build_full_tile_polygon(tile_size))
td.set_collision_polygon_one_way(physics_layer, 0, true)
_:
return "unknown physics_polygon shorthand: %s" % val
elif typeof(val) == TYPE_ARRAY:
_ensure_collision_polygon(td, physics_layer)
var points := PackedVector2Array()
for pt in val:
if typeof(pt) == TYPE_DICTIONARY:
points.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0))))
td.set_collision_polygon_points(physics_layer, 0, points)
else:
return "physics_polygon must be string or Array[{x,y}]"
if tile.has("one_way_collision"):
_ensure_collision_polygon(td, physics_layer)
td.set_collision_polygon_one_way(physics_layer, 0, bool(tile["one_way_collision"]))
return ""
const _PEERING_MAP := {
"right": TileSet.CELL_NEIGHBOR_RIGHT_SIDE,
"bottom_right": TileSet.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER,
"bottom": TileSet.CELL_NEIGHBOR_BOTTOM_SIDE,
"bottom_left": TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER,
"left": TileSet.CELL_NEIGHBOR_LEFT_SIDE,
"top_left": TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER,
"top": TileSet.CELL_NEIGHBOR_TOP_SIDE,
"top_right": TileSet.CELL_NEIGHBOR_TOP_RIGHT_CORNER,
}
static func _apply_terrain_peering(td: TileData, peering: Dictionary) -> String:
for key in peering:
if key == "center":
td.terrain = int(peering[key])
continue
if not _PEERING_MAP.has(key):
return "unknown peering bit: %s" % key
td.set_terrain_peering_bit(_PEERING_MAP[key], int(peering[key]))
return ""
static func _build_nav_polygon(verts: PackedVector2Array) -> NavigationPolygon:
var np := NavigationPolygon.new()
np.set_vertices(verts)
var indices := PackedInt32Array()
for i in range(verts.size()):
indices.append(i)
np.add_polygon(indices)
return np
static func _apply_navigation_polygon(
td: TileData, tile: Dictionary, tile_size: Vector2i
) -> String:
var val = tile["navigation_polygon"]
var nav_layer := int(tile.get("navigation_layer", 0))
if typeof(val) == TYPE_STRING:
match val:
"full":
var verts := _IO.build_full_tile_polygon(tile_size)
td.set_navigation_polygon(nav_layer, _build_nav_polygon(verts))
"none":
td.set_navigation_polygon(nav_layer, null)
_:
return "unknown navigation_polygon shorthand: %s" % val
elif typeof(val) == TYPE_ARRAY:
var verts := PackedVector2Array()
for pt in val:
if typeof(pt) == TYPE_DICTIONARY:
verts.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0))))
td.set_navigation_polygon(nav_layer, _build_nav_polygon(verts))
else:
return "navigation_polygon must be string or Array[{x,y}]"
return ""
static func _apply_occlusion_polygon(
td: TileData, tile: Dictionary, tile_size: Vector2i
) -> String:
var val = tile["occlusion_polygon"]
var occ_layer := int(tile.get("occlusion_layer", 0))
if typeof(val) == TYPE_STRING:
match val:
"full":
var op := OccluderPolygon2D.new()
op.polygon = _IO.build_full_tile_polygon(tile_size)
td.set_occluder(occ_layer, op)
"none":
td.set_occluder(occ_layer, null)
_:
return "unknown occlusion_polygon shorthand: %s" % val
elif typeof(val) == TYPE_ARRAY:
var op := OccluderPolygon2D.new()
var verts := PackedVector2Array()
for pt in val:
if typeof(pt) == TYPE_DICTIONARY:
verts.append(Vector2(float(pt.get("x", 0)), float(pt.get("y", 0))))
op.polygon = verts
td.set_occluder(occ_layer, op)
else:
return "occlusion_polygon must be string or Array[{x,y}]"
return ""
static func _apply_animation(
atlas: TileSetAtlasSource, coord: Vector2i, anim: Dictionary
) -> String:
var frame_count := int(anim.get("frame_count", anim.get("frames", []).size()))
if frame_count < 2:
return "animation needs frame_count >= 2"
var columns := int(anim.get("columns", frame_count))
var duration := float(anim.get("frame_duration", 1.0))
# Remove tiles that the animation area would cover (except the base tile)
var rows_needed := ceili(float(frame_count) / float(columns))
for fy in range(rows_needed):
for fx in range(columns):
if fx == 0 and fy == 0:
continue
var covered := coord + Vector2i(fx, fy)
if atlas.has_tile(covered):
atlas.remove_tile(covered)
atlas.set_tile_animation_columns(coord, columns)
atlas.set_tile_animation_frames_count(coord, frame_count)
for f in range(frame_count):
atlas.set_tile_animation_frame_duration(coord, f, duration)
if anim.has("separation"):
var sep = anim["separation"]
if typeof(sep) == TYPE_DICTIONARY:
atlas.set_tile_animation_separation(coord,
Vector2i(int(sep.get("x", 0)), int(sep.get("y", 0))))
return ""