403 lines
15 KiB
GDScript
403 lines
15 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## Shared Variant coercion helper and its serialisation inverse.
|
|
##
|
|
## _coerce_value: JSON dict → Godot typed value (for property writes, method args).
|
|
## _serialize_value: Godot typed value → JSON-safe dict (for property reads, responses).
|
|
## _check_resource_paths: pre-coercion gate that validates Resource refs resolve.
|
|
##
|
|
## Both directions share a symmetric tag vocabulary — keep aligned or round-trip breaks.
|
|
|
|
# Direct preload (not via _hub) to avoid circular dependency — _hub preloads us.
|
|
const _FileGuard := preload("res://addons/godot_mcp_toolkit/security/file_guard.gd")
|
|
|
|
|
|
static func coerce_value(value: Variant) -> Variant:
|
|
if typeof(value) == TYPE_ARRAY:
|
|
var result: Array = []
|
|
for element in value:
|
|
result.append(coerce_value(element))
|
|
return result
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
return value
|
|
match str(value.get("type", "")):
|
|
"Resource", "ResourceRef":
|
|
var resource_path := str(value.get("path", ""))
|
|
if resource_path.is_empty():
|
|
# Fall through: {"type":"Resource","resource_type":"X"} → inline NewResource
|
|
var res_class := str(value.get("resource_type", value.get("class", "")))
|
|
if not res_class.is_empty():
|
|
return coerce_value({"type": "NewResource", "class": res_class,
|
|
"properties": value.get("properties", {})})
|
|
return null
|
|
var guard := _FileGuard.resolve_safe(resource_path)
|
|
if guard["error"] != null:
|
|
return null
|
|
return ResourceLoader.load(resource_path, "", ResourceLoader.CACHE_MODE_REPLACE)
|
|
"NewResource":
|
|
var res_class := str(value.get("class", ""))
|
|
if res_class.is_empty() or not ClassDB.class_exists(res_class):
|
|
return null
|
|
if not ClassDB.is_parent_class(res_class, "Resource"):
|
|
return null
|
|
if not ClassDB.can_instantiate(res_class):
|
|
return null
|
|
var resource = ClassDB.instantiate(res_class)
|
|
if resource == null:
|
|
return null
|
|
var props: Dictionary = _safe_dict(value.get("properties", {}))
|
|
for key in props.keys():
|
|
resource.set(str(key), coerce_value(props[key]))
|
|
return resource
|
|
"Vector2":
|
|
return Vector2(
|
|
float(value.get("x", 0.0)),
|
|
float(value.get("y", 0.0)),
|
|
)
|
|
"Vector3":
|
|
return Vector3(
|
|
float(value.get("x", 0.0)),
|
|
float(value.get("y", 0.0)),
|
|
float(value.get("z", 0.0)),
|
|
)
|
|
"Vector4":
|
|
return Vector4(
|
|
float(value.get("x", 0.0)),
|
|
float(value.get("y", 0.0)),
|
|
float(value.get("z", 0.0)),
|
|
float(value.get("w", 0.0)),
|
|
)
|
|
"Vector2i":
|
|
return Vector2i(int(value.get("x", 0)), int(value.get("y", 0)))
|
|
"Vector3i":
|
|
return Vector3i(
|
|
int(value.get("x", 0)),
|
|
int(value.get("y", 0)),
|
|
int(value.get("z", 0)),
|
|
)
|
|
"Color":
|
|
return Color(
|
|
float(value.get("r", 0.0)),
|
|
float(value.get("g", 0.0)),
|
|
float(value.get("b", 0.0)),
|
|
float(value.get("a", 1.0)),
|
|
)
|
|
"Rect2":
|
|
return Rect2(
|
|
float(value.get("x", 0.0)),
|
|
float(value.get("y", 0.0)),
|
|
float(value.get("w", 0.0)),
|
|
float(value.get("h", 0.0)),
|
|
)
|
|
"Rect2i":
|
|
return Rect2i(
|
|
int(value.get("x", 0)),
|
|
int(value.get("y", 0)),
|
|
int(value.get("w", 0)),
|
|
int(value.get("h", 0)),
|
|
)
|
|
"Transform2D":
|
|
var x_axis: Dictionary = _safe_dict(value.get("x_axis", {}))
|
|
var y_axis: Dictionary = _safe_dict(value.get("y_axis", {}))
|
|
var origin_2d: Dictionary = _safe_dict(value.get("origin", {}))
|
|
return Transform2D(
|
|
Vector2(float(x_axis.get("x", 1.0)), float(x_axis.get("y", 0.0))),
|
|
Vector2(float(y_axis.get("x", 0.0)), float(y_axis.get("y", 1.0))),
|
|
Vector2(float(origin_2d.get("x", 0.0)), float(origin_2d.get("y", 0.0))),
|
|
)
|
|
"Transform3D":
|
|
var basis_dict: Dictionary = _safe_dict(value.get("basis", {}))
|
|
var basis_x: Dictionary = _safe_dict(basis_dict.get("x", {}))
|
|
var basis_y: Dictionary = _safe_dict(basis_dict.get("y", {}))
|
|
var basis_z: Dictionary = _safe_dict(basis_dict.get("z", {}))
|
|
var origin_3d: Dictionary = _safe_dict(value.get("origin", {}))
|
|
var basis := Basis(
|
|
Vector3(float(basis_x.get("x", 1.0)), float(basis_x.get("y", 0.0)), float(basis_x.get("z", 0.0))),
|
|
Vector3(float(basis_y.get("x", 0.0)), float(basis_y.get("y", 1.0)), float(basis_y.get("z", 0.0))),
|
|
Vector3(float(basis_z.get("x", 0.0)), float(basis_z.get("y", 0.0)), float(basis_z.get("z", 1.0))),
|
|
)
|
|
return Transform3D(
|
|
basis,
|
|
Vector3(
|
|
float(origin_3d.get("x", 0.0)),
|
|
float(origin_3d.get("y", 0.0)),
|
|
float(origin_3d.get("z", 0.0)),
|
|
),
|
|
)
|
|
"NodePath":
|
|
return NodePath(str(value.get("path", "")))
|
|
"PackedVector2Array":
|
|
var arr := PackedVector2Array()
|
|
var elements = value.get("values", [])
|
|
if typeof(elements) == TYPE_ARRAY:
|
|
for el in elements:
|
|
var v = coerce_value(el)
|
|
if v is Vector2:
|
|
arr.append(v)
|
|
elif typeof(v) == TYPE_DICTIONARY and not (v as Dictionary).has("_coerce_error"):
|
|
arr.append(Vector2(float(v.get("x", 0.0)), float(v.get("y", 0.0))))
|
|
else:
|
|
return {"_coerce_error":
|
|
"PackedVector2Array elements must be Vector2. Use: {type:'PackedVector2Array', values: [{type:'Vector2', x:0, y:0}, ...]}"}
|
|
return arr
|
|
"PackedVector3Array":
|
|
var arr := PackedVector3Array()
|
|
var elements = value.get("values", [])
|
|
if typeof(elements) == TYPE_ARRAY:
|
|
for el in elements:
|
|
var v = coerce_value(el)
|
|
if v is Vector3:
|
|
arr.append(v)
|
|
elif typeof(v) == TYPE_DICTIONARY and not (v as Dictionary).has("_coerce_error"):
|
|
arr.append(Vector3(float(v.get("x", 0.0)), float(v.get("y", 0.0)), float(v.get("z", 0.0))))
|
|
else:
|
|
return {"_coerce_error":
|
|
"PackedVector3Array elements must be Vector3. Use: {type:'PackedVector3Array', values: [{type:'Vector3', x:0, y:0, z:0}, ...]}"}
|
|
return arr
|
|
"PackedColorArray":
|
|
var arr := PackedColorArray()
|
|
var elements = value.get("values", [])
|
|
if typeof(elements) == TYPE_ARRAY:
|
|
for el in elements:
|
|
var v = coerce_value(el)
|
|
if v is Color:
|
|
arr.append(v)
|
|
elif typeof(v) == TYPE_DICTIONARY and not (v as Dictionary).has("_coerce_error"):
|
|
arr.append(Color(float(v.get("r", 0.0)), float(v.get("g", 0.0)),
|
|
float(v.get("b", 0.0)), float(v.get("a", 1.0))))
|
|
else:
|
|
return {"_coerce_error":
|
|
"PackedColorArray elements must be Color. Use: {type:'PackedColorArray', values: [{type:'Color', r:1, g:0, b:0, a:1}, ...]}"}
|
|
return arr
|
|
"LayerMask":
|
|
var lm_category := str(value.get("category", "2d_physics"))
|
|
return layers_to_mask(value.get("layers", []), lm_category)
|
|
_:
|
|
# Reject unknown type tags instead of silently passing through.
|
|
var type_tag := str(value.get("type", ""))
|
|
if not type_tag.is_empty():
|
|
return {"_coerce_error":
|
|
"Unknown type tag '%s'. Supported: Vector2, Vector3, Vector4, Vector2i, Vector3i, Color, Rect2, Rect2i, Transform2D, Transform3D, NodePath, Resource, NewResource, PackedVector2Array, PackedVector3Array, PackedColorArray, LayerMask." % type_tag}
|
|
# No type key — generic dict with recursive coercion.
|
|
var result := {}
|
|
for k in value.keys():
|
|
result[k] = coerce_value(value[k])
|
|
return result
|
|
|
|
|
|
## Convert a layer-number value to a bitmask integer.
|
|
## Accepts: int (returned as-is), or Array of layer numbers (int) or
|
|
## named layers (String) → bitmask. Named layers are resolved via
|
|
## ProjectSettings (layer_names/{category}/layer_N).
|
|
static func layers_to_mask(value: Variant, category: String = "2d_physics") -> int:
|
|
if typeof(value) == TYPE_ARRAY:
|
|
var mask := 0
|
|
for layer in value:
|
|
if typeof(layer) == TYPE_STRING:
|
|
var n := _resolve_layer_name(str(layer), category)
|
|
if n >= 1:
|
|
mask |= (1 << (n - 1))
|
|
else:
|
|
var n := int(layer)
|
|
if n >= 1 and n <= 32:
|
|
mask |= (1 << (n - 1))
|
|
return mask
|
|
return int(value)
|
|
|
|
|
|
## Resolve a named layer to its 1-based number by scanning ProjectSettings.
|
|
## Returns -1 if the name is not found.
|
|
static func _resolve_layer_name(layer_name: String, category: String) -> int:
|
|
for n in range(1, 33):
|
|
var key := "layer_names/%s/layer_%d" % [category, n]
|
|
if ProjectSettings.has_setting(key):
|
|
var stored: String = str(ProjectSettings.get_setting(key))
|
|
if stored.to_lower() == layer_name.to_lower():
|
|
return n
|
|
return -1
|
|
|
|
|
|
## Project a {r,g,b,a} JSON dict onto a Color with opaque-white channel
|
|
## defaults (tint / modulate semantics). A non-dict value yields [param default]
|
|
## (itself opaque white unless overridden); within a dict, each absent channel
|
|
## falls to 1.0 so a partial {r,g,b} stays fully opaque. Distinct from the
|
|
## "Color" type tag above, whose channels default to opaque BLACK (paint
|
|
## semantics) — the two defaults must not be conflated.
|
|
static func color_from_dict(d: Variant, default: Color = Color(1, 1, 1, 1)) -> Color:
|
|
if typeof(d) != TYPE_DICTIONARY:
|
|
return default
|
|
return Color(
|
|
float(d.get("r", 1.0)), float(d.get("g", 1.0)),
|
|
float(d.get("b", 1.0)), float(d.get("a", 1.0)))
|
|
|
|
|
|
static func serialize_value(value: Variant) -> Variant:
|
|
match typeof(value):
|
|
TYPE_NIL:
|
|
return null
|
|
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
|
|
return value
|
|
TYPE_VECTOR2:
|
|
return {"type": "Vector2", "x": value.x, "y": value.y}
|
|
TYPE_VECTOR3:
|
|
return {"type": "Vector3", "x": value.x, "y": value.y, "z": value.z}
|
|
TYPE_VECTOR4:
|
|
return {"type": "Vector4", "x": value.x, "y": value.y, "z": value.z, "w": value.w}
|
|
TYPE_VECTOR2I:
|
|
return {"type": "Vector2i", "x": value.x, "y": value.y}
|
|
TYPE_VECTOR3I:
|
|
return {"type": "Vector3i", "x": value.x, "y": value.y, "z": value.z}
|
|
TYPE_COLOR:
|
|
return {"type": "Color", "r": value.r, "g": value.g, "b": value.b, "a": value.a}
|
|
TYPE_RECT2:
|
|
return {
|
|
"type": "Rect2",
|
|
"x": value.position.x, "y": value.position.y,
|
|
"w": value.size.x, "h": value.size.y,
|
|
}
|
|
TYPE_RECT2I:
|
|
return {
|
|
"type": "Rect2i",
|
|
"x": value.position.x, "y": value.position.y,
|
|
"w": value.size.x, "h": value.size.y,
|
|
}
|
|
TYPE_TRANSFORM2D:
|
|
return {
|
|
"type": "Transform2D",
|
|
"x_axis": {"x": value.x.x, "y": value.x.y},
|
|
"y_axis": {"x": value.y.x, "y": value.y.y},
|
|
"origin": {"x": value.origin.x, "y": value.origin.y},
|
|
}
|
|
TYPE_TRANSFORM3D:
|
|
return {
|
|
"type": "Transform3D",
|
|
"basis": {
|
|
"x": {"x": value.basis.x.x, "y": value.basis.x.y, "z": value.basis.x.z},
|
|
"y": {"x": value.basis.y.x, "y": value.basis.y.y, "z": value.basis.y.z},
|
|
"z": {"x": value.basis.z.x, "y": value.basis.z.y, "z": value.basis.z.z},
|
|
},
|
|
"origin": {"x": value.origin.x, "y": value.origin.y, "z": value.origin.z},
|
|
}
|
|
TYPE_NODE_PATH:
|
|
return {"type": "NodePath", "path": str(value)}
|
|
TYPE_PACKED_VECTOR2_ARRAY:
|
|
# Emit the tagged form coerce_value parses back: each element is
|
|
# itself a tagged {type:"Vector2",x,y} dict (recurse so the round-trip
|
|
# is exact). Iterating a typed PackedVector2Array yields Vector2 elements.
|
|
var packed_v2: Array = []
|
|
for element in value:
|
|
packed_v2.append(serialize_value(element))
|
|
return {"type": "PackedVector2Array", "values": packed_v2}
|
|
TYPE_PACKED_VECTOR3_ARRAY:
|
|
var packed_v3: Array = []
|
|
for element in value:
|
|
packed_v3.append(serialize_value(element))
|
|
return {"type": "PackedVector3Array", "values": packed_v3}
|
|
TYPE_PACKED_COLOR_ARRAY:
|
|
var packed_col: Array = []
|
|
for element in value:
|
|
packed_col.append(serialize_value(element))
|
|
return {"type": "PackedColorArray", "values": packed_col}
|
|
TYPE_STRING_NAME:
|
|
return str(value)
|
|
TYPE_ARRAY:
|
|
var serialized_array: Array = []
|
|
for element in value:
|
|
serialized_array.append(serialize_value(element))
|
|
return serialized_array
|
|
TYPE_DICTIONARY:
|
|
var serialized_dictionary: Dictionary = {}
|
|
for key in value.keys():
|
|
serialized_dictionary[str(key)] = serialize_value(value[key])
|
|
return serialized_dictionary
|
|
TYPE_OBJECT:
|
|
if value == null:
|
|
return null
|
|
if value is Node:
|
|
return str((value as Node).get_path())
|
|
if value is Resource:
|
|
var resource := value as Resource
|
|
return {
|
|
"type": "Resource",
|
|
"path": resource.resource_path,
|
|
"class": resource.get_class(),
|
|
}
|
|
return "<unserialisable>"
|
|
_:
|
|
return var_to_str(value)
|
|
|
|
|
|
## Pre-coercion gate: recursively scan for {type:"Resource",path:...} entries
|
|
## and return the first path that fails FileGuard or ResourceLoader.load.
|
|
## Empty string means all Resource refs resolve.
|
|
static func check_resource_paths(value: Variant) -> String:
|
|
if typeof(value) == TYPE_DICTIONARY:
|
|
var vtype := str(value.get("type", ""))
|
|
if vtype == "Resource" or vtype == "ResourceRef":
|
|
var resource_path := str(value.get("path", ""))
|
|
if resource_path.is_empty():
|
|
# Fall through: Resource with no path + class/resource_type → NewResource
|
|
var res_class := str(value.get("resource_type", value.get("class", "")))
|
|
if not res_class.is_empty():
|
|
return check_resource_paths({"type": "NewResource", "class": res_class,
|
|
"properties": value.get("properties", {})})
|
|
return "<empty path>"
|
|
var guard := _FileGuard.resolve_safe(resource_path)
|
|
if guard["error"] != null:
|
|
return resource_path
|
|
if ResourceLoader.load(resource_path) == null:
|
|
return resource_path
|
|
elif vtype == "NewResource":
|
|
var res_class := str(value.get("class", ""))
|
|
if res_class.is_empty() or not ClassDB.class_exists(res_class):
|
|
return "<invalid class: %s>" % res_class
|
|
if not ClassDB.is_parent_class(res_class, "Resource"):
|
|
return "<not a Resource: %s>" % res_class
|
|
# Recurse into properties to check nested Resource refs
|
|
var props: Dictionary = value.get("properties", {}) if typeof(value.get("properties", null)) == TYPE_DICTIONARY else {}
|
|
for key in props.keys():
|
|
var nested := check_resource_paths(props[key])
|
|
if nested != "":
|
|
return nested
|
|
else:
|
|
# Recurse into untyped dict values for nested sub-resources
|
|
for key in value.keys():
|
|
var nested := check_resource_paths(value[key])
|
|
if nested != "":
|
|
return nested
|
|
return ""
|
|
if typeof(value) == TYPE_ARRAY:
|
|
for element in value:
|
|
var missing := check_resource_paths(element)
|
|
if missing != "":
|
|
return missing
|
|
return ""
|
|
|
|
|
|
## Coerce a JSON value using the existing property value as a type hint.
|
|
## When the JSON dict lacks a "type" key, injects the correct type tag
|
|
## inferred from the current Variant type so coerce_value() handles it.
|
|
## Falls back to plain coerce_value() when no inference is possible.
|
|
static func coerce_value_hint(raw: Variant, existing: Variant) -> Variant:
|
|
if typeof(raw) != TYPE_DICTIONARY or (raw as Dictionary).has("type"):
|
|
return coerce_value(raw)
|
|
var tag := ""
|
|
match typeof(existing):
|
|
TYPE_VECTOR2: tag = "Vector2"
|
|
TYPE_VECTOR3: tag = "Vector3"
|
|
TYPE_VECTOR4: tag = "Vector4"
|
|
TYPE_VECTOR2I: tag = "Vector2i"
|
|
TYPE_VECTOR3I: tag = "Vector3i"
|
|
TYPE_COLOR: tag = "Color"
|
|
TYPE_RECT2: tag = "Rect2"
|
|
TYPE_RECT2I: tag = "Rect2i"
|
|
if tag.is_empty():
|
|
return coerce_value(raw)
|
|
var tagged := (raw as Dictionary).duplicate()
|
|
tagged["type"] = tag
|
|
return coerce_value(tagged)
|
|
|
|
|
|
static func _safe_dict(value: Variant) -> Dictionary:
|
|
return value if typeof(value) == TYPE_DICTIONARY else {}
|