400 lines
14 KiB
GDScript
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 ""
|