Godot 4.7 project for authoring, previewing, validating, and building Exchanger PCK themes. Includes the example and space-pixel themes, binding contracts, editor tooling, validation/build scripts, and project documentation.
602 lines
22 KiB
GDScript3
602 lines
22 KiB
GDScript3
extends SceneTree
|
|
|
|
const ACTIVE_STATE_PATH := "res://build/active-theme.json"
|
|
const CANONICAL_PACKAGE_ROOT := "res://exchanger_theme_dlc"
|
|
const PACKAGED_MANIFEST_PATH := "res://exchanger_theme_dlc/manifest.json"
|
|
const PET_MANIFEST_FIELD := "InteractivePetScene"
|
|
const PET_COMPONENT_RELATIVE_PATH := "components/interactive_pet.tscn"
|
|
const LEGACY_PET_COMPONENT_RELATIVE_PATH := "components/alien_pet.tscn"
|
|
const REQUIRED_METHODS := [
|
|
&"get_pet_animation_names",
|
|
&"get_pet_animation_frame_count",
|
|
&"has_pet_animation",
|
|
&"play_pet_animation",
|
|
&"pause_pet_animation",
|
|
&"stop_pet_animation",
|
|
&"get_current_pet_animation",
|
|
&"get_current_pet_frame",
|
|
]
|
|
const COMPOSITE_METHODS := [
|
|
&"has_pet_composite_action",
|
|
&"start_pet_composite_action",
|
|
&"cancel_pet_composite_action",
|
|
]
|
|
const COMPOSITE_SIGNALS := [
|
|
&"pet_composite_action_started",
|
|
&"pet_composite_action_finished",
|
|
]
|
|
const INTERACTIVE_PET_ACTIONS := [
|
|
&"idle",
|
|
&"sit",
|
|
&"interact",
|
|
&"walk_left",
|
|
&"walk_right",
|
|
&"walk_start_left",
|
|
&"walk_start_right",
|
|
&"walk_finish_left",
|
|
&"walk_finish_right",
|
|
&"climb_start",
|
|
&"climb_up",
|
|
&"climb_down",
|
|
&"climb_finish",
|
|
&"drag",
|
|
&"drop",
|
|
&"landing",
|
|
&"celebrate",
|
|
]
|
|
const PET_NAVIGATION_ROOT_META := &"exchanger_pet_navigation_root"
|
|
const PET_SURFACE_META := &"exchanger_pet_surface"
|
|
const PET_CLIMB_EDGE_META := &"exchanger_pet_climb_edge"
|
|
|
|
|
|
func _init() -> void:
|
|
call_deferred("_verify")
|
|
|
|
|
|
func _verify() -> void:
|
|
var failures := PackedStringArray()
|
|
var pack_path := _argument_value("--pack", "")
|
|
var explicit_scene_path := _argument_value("--scene", "")
|
|
var optional := OS.get_cmdline_user_args().has("--optional")
|
|
var pet_scene_path := ""
|
|
var package_root := ""
|
|
|
|
if not pack_path.is_empty():
|
|
if not ProjectSettings.load_resource_pack(pack_path, false):
|
|
_fail("Cannot mount PCK %s." % pack_path)
|
|
return
|
|
package_root = CANONICAL_PACKAGE_ROOT
|
|
pet_scene_path = _resolve_packaged_scene_path(explicit_scene_path)
|
|
else:
|
|
package_root = _resolve_builder_package_root()
|
|
pet_scene_path = _resolve_builder_scene_path(explicit_scene_path)
|
|
|
|
if pet_scene_path.is_empty():
|
|
if optional:
|
|
print("[Interactive Pet] SKIP: theme does not declare an interactive pet.")
|
|
quit(0)
|
|
return
|
|
_fail("Theme does not declare an interactive pet scene.")
|
|
return
|
|
|
|
var packed_scene := load(pet_scene_path) as PackedScene
|
|
if packed_scene == null:
|
|
_fail("Cannot load %s." % pet_scene_path)
|
|
return
|
|
|
|
var instance := packed_scene.instantiate()
|
|
if not instance is Node2D:
|
|
instance.queue_free()
|
|
_fail("Interactive pet root must inherit Node2D: %s." % pet_scene_path)
|
|
return
|
|
var pet := instance as Node2D
|
|
root.add_child(pet)
|
|
await process_frame
|
|
|
|
for method_name in REQUIRED_METHODS:
|
|
if not pet.has_method(method_name):
|
|
failures.append("Missing public method %s()." % method_name)
|
|
_expect_signal(pet, &"pet_animation_started", failures)
|
|
_expect_signal(pet, &"pet_animation_completed", failures)
|
|
if not failures.is_empty():
|
|
_finish(failures, pet_scene_path, 0, 0, 0, 0)
|
|
return
|
|
|
|
var animation_names: Array[StringName] = []
|
|
var raw_names = pet.call(&"get_pet_animation_names")
|
|
if not raw_names is Array:
|
|
failures.append("get_pet_animation_names() must return an Array.")
|
|
else:
|
|
for raw_name in raw_names:
|
|
var animation_name := StringName(str(raw_name))
|
|
if animation_name.is_empty():
|
|
failures.append("Animation names must not be empty.")
|
|
elif animation_names.has(animation_name):
|
|
failures.append("Duplicate animation name %s." % animation_name)
|
|
else:
|
|
animation_names.append(animation_name)
|
|
if animation_names.is_empty():
|
|
failures.append("Interactive pet must expose at least one animation.")
|
|
|
|
var initial_position := pet.position
|
|
var total_frames := 0
|
|
for animation_name in animation_names:
|
|
if not bool(pet.call(&"has_pet_animation", animation_name)):
|
|
failures.append("has_pet_animation() rejected %s." % animation_name)
|
|
continue
|
|
var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name))
|
|
if frame_count <= 0:
|
|
failures.append("Animation %s must contain at least one frame." % animation_name)
|
|
else:
|
|
total_frames += frame_count
|
|
if not bool(pet.call(&"play_pet_animation", animation_name)):
|
|
failures.append("play_pet_animation() rejected %s." % animation_name)
|
|
|
|
await create_timer(0.25).timeout
|
|
if pet.position != initial_position:
|
|
failures.append("Animation playback changed the pet root position.")
|
|
await _validate_optional_visual_offset(pet, animation_names, failures)
|
|
if pet.has_method(&"get_pet_preview_size"):
|
|
var preview_size = pet.call(&"get_pet_preview_size")
|
|
if not preview_size is Vector2 or preview_size.x <= 0.0 or preview_size.y <= 0.0:
|
|
failures.append("get_pet_preview_size() must return a positive Vector2.")
|
|
_validate_optional_pet_profile(pet, animation_names, failures)
|
|
_validate_landing_animation(pet, animation_names, failures)
|
|
_validate_optional_composite_action(pet, failures)
|
|
var navigation_counts := _validate_pet_navigation(package_root, failures)
|
|
|
|
_finish(
|
|
failures,
|
|
pet_scene_path,
|
|
animation_names.size(),
|
|
total_frames,
|
|
navigation_counts.x,
|
|
navigation_counts.y
|
|
)
|
|
|
|
|
|
func _validate_optional_composite_action(pet: Node, failures: PackedStringArray) -> void:
|
|
var exposed_count := 0
|
|
for method_name in COMPOSITE_METHODS:
|
|
if pet.has_method(method_name):
|
|
exposed_count += 1
|
|
for signal_name in COMPOSITE_SIGNALS:
|
|
if pet.has_signal(signal_name):
|
|
exposed_count += 1
|
|
if exposed_count == 0:
|
|
return
|
|
|
|
for method_name in COMPOSITE_METHODS:
|
|
if not pet.has_method(method_name):
|
|
failures.append("Partial composite pet API: missing %s()." % method_name)
|
|
for signal_name in COMPOSITE_SIGNALS:
|
|
if not pet.has_signal(signal_name):
|
|
failures.append("Partial composite pet API: missing signal %s." % signal_name)
|
|
if exposed_count != COMPOSITE_METHODS.size() + COMPOSITE_SIGNALS.size():
|
|
return
|
|
var supported = pet.call(&"has_pet_composite_action", &"interact")
|
|
if not supported is bool:
|
|
failures.append("has_pet_composite_action() must return bool.")
|
|
|
|
|
|
func _resolve_builder_scene_path(explicit_scene_path: String) -> String:
|
|
if not explicit_scene_path.is_empty():
|
|
return explicit_scene_path
|
|
var package_path := _resolve_builder_package_root()
|
|
if package_path.is_empty():
|
|
return ""
|
|
var manifest := _load_json(package_path.path_join("manifest.json"))
|
|
return _resolve_scene_from_manifest(package_path, manifest)
|
|
|
|
|
|
func _resolve_builder_package_root() -> String:
|
|
var theme_id := _argument_value("--theme", "")
|
|
var package_path := ""
|
|
if not theme_id.is_empty():
|
|
package_path = "res://themes/%s/package" % theme_id
|
|
else:
|
|
var state := _load_json(ACTIVE_STATE_PATH)
|
|
package_path = ProjectSettings.localize_path(str(state.get("Package", "")))
|
|
package_path = package_path.replace("\\", "/").trim_suffix("/")
|
|
if not package_path.begins_with("res://"):
|
|
return ""
|
|
return package_path
|
|
|
|
|
|
func _resolve_packaged_scene_path(explicit_scene_path: String) -> String:
|
|
if not explicit_scene_path.is_empty():
|
|
return explicit_scene_path
|
|
var manifest := _load_json(PACKAGED_MANIFEST_PATH)
|
|
return _resolve_scene_from_manifest(CANONICAL_PACKAGE_ROOT, manifest)
|
|
|
|
|
|
func _resolve_scene_from_manifest(package_root: String, manifest: Dictionary) -> String:
|
|
var declared_path := str(manifest.get(PET_MANIFEST_FIELD, ""))
|
|
var candidates := PackedStringArray()
|
|
if not declared_path.is_empty():
|
|
var relative_path := _package_relative_path(declared_path)
|
|
if not relative_path.is_empty():
|
|
candidates.append(relative_path)
|
|
candidates.append(PET_COMPONENT_RELATIVE_PATH)
|
|
candidates.append(LEGACY_PET_COMPONENT_RELATIVE_PATH)
|
|
for relative_path in candidates:
|
|
var candidate := package_root.path_join(relative_path)
|
|
if ResourceLoader.exists(candidate):
|
|
return candidate
|
|
return ""
|
|
|
|
|
|
func _package_relative_path(resource_path: String) -> String:
|
|
var normalized := resource_path.replace("\\", "/")
|
|
if normalized.contains(".."):
|
|
return ""
|
|
if normalized.begins_with(CANONICAL_PACKAGE_ROOT + "/"):
|
|
return normalized.trim_prefix(CANONICAL_PACKAGE_ROOT + "/")
|
|
if normalized.begins_with("res://"):
|
|
return ""
|
|
return normalized.trim_prefix("/")
|
|
|
|
|
|
func _expect_signal(pet: Node, signal_name: StringName, failures: PackedStringArray) -> void:
|
|
if not pet.has_signal(signal_name):
|
|
failures.append("Missing public signal %s." % signal_name)
|
|
|
|
|
|
func _validate_optional_visual_offset(
|
|
pet: Node2D,
|
|
animation_names: Array[StringName],
|
|
failures: PackedStringArray
|
|
) -> void:
|
|
var has_current_offset := pet.has_method(&"get_pet_visual_offset")
|
|
var has_animation_offset := pet.has_method(&"get_pet_animation_visual_offset")
|
|
if has_current_offset != has_animation_offset:
|
|
failures.append("Pet visual offset introspection methods must be provided together.")
|
|
return
|
|
if not has_current_offset:
|
|
return
|
|
if not animation_names.has(&"sitting"):
|
|
failures.append("A pet exposing get_pet_visual_offset() must provide sitting.")
|
|
return
|
|
var non_sitting_animation := &""
|
|
for animation_name in animation_names:
|
|
if animation_name != &"sitting":
|
|
non_sitting_animation = animation_name
|
|
break
|
|
if non_sitting_animation.is_empty():
|
|
failures.append("A pet exposing get_pet_visual_offset() must provide a non-sitting animation.")
|
|
return
|
|
if pet.call(&"get_pet_animation_visual_offset", &"sitting") != Vector2(0.0, 24.0):
|
|
failures.append("Declared sitting visual offset must be Vector2(0, 24).")
|
|
if pet.call(&"get_pet_animation_visual_offset", non_sitting_animation) != Vector2.ZERO:
|
|
failures.append("Declared non-sitting visual offset must be Vector2.ZERO.")
|
|
|
|
var initial_root_position := pet.position
|
|
pet.call(&"play_pet_animation", &"sitting")
|
|
await process_frame
|
|
if pet.call(&"get_pet_visual_offset") != Vector2(0.0, 24.0):
|
|
failures.append("Sitting visual offset must be Vector2(0, 24).")
|
|
|
|
pet.call(&"play_pet_animation", non_sitting_animation)
|
|
await process_frame
|
|
if pet.call(&"get_pet_visual_offset") != Vector2.ZERO:
|
|
failures.append("Non-sitting visual offset must be Vector2.ZERO.")
|
|
|
|
pet.call(&"play_pet_animation", &"sitting")
|
|
await process_frame
|
|
if pet.call(&"get_pet_visual_offset") != Vector2(0.0, 24.0):
|
|
failures.append("Returning to sitting must restore Vector2(0, 24) visual offset.")
|
|
if pet.position != initial_root_position:
|
|
failures.append("Visual offset switching changed the pet root position.")
|
|
|
|
|
|
func _validate_optional_pet_profile(
|
|
pet: Node,
|
|
animation_names: Array[StringName],
|
|
failures: PackedStringArray
|
|
) -> void:
|
|
if pet.has_method(&"get_pet_action_animation"):
|
|
for action_name in INTERACTIVE_PET_ACTIONS:
|
|
var animation_name := StringName(str(pet.call(&"get_pet_action_animation", action_name)))
|
|
if not animation_name.is_empty() and not animation_names.has(animation_name):
|
|
failures.append("Pet action %s maps to unknown animation %s." % [action_name, animation_name])
|
|
|
|
_validate_positive_float_method(pet, &"get_pet_runtime_scale", failures)
|
|
_validate_positive_float_method(pet, &"get_pet_walk_speed", failures)
|
|
_validate_positive_float_method(pet, &"get_pet_climb_speed", failures)
|
|
|
|
var reference_size := Vector2.ZERO
|
|
if pet.has_method(&"get_pet_hit_size"):
|
|
var hit_size = pet.call(&"get_pet_hit_size")
|
|
if (
|
|
not hit_size is Vector2
|
|
or not _is_finite_vector2(hit_size)
|
|
or hit_size.x <= 0.0
|
|
or hit_size.y <= 0.0
|
|
):
|
|
failures.append("get_pet_hit_size() must return a finite positive Vector2.")
|
|
elif hit_size is Vector2:
|
|
reference_size = hit_size
|
|
elif pet.has_method(&"get_pet_preview_size"):
|
|
var preview_size = pet.call(&"get_pet_preview_size")
|
|
if preview_size is Vector2:
|
|
reference_size = preview_size
|
|
|
|
if pet.has_method(&"get_pet_ground_offset"):
|
|
var ground_offset = pet.call(&"get_pet_ground_offset")
|
|
if not ground_offset is Vector2 or not _is_finite_vector2(ground_offset):
|
|
failures.append("get_pet_ground_offset() must return a finite Vector2.")
|
|
elif (
|
|
reference_size.x > 0.0
|
|
and reference_size.y > 0.0
|
|
and (
|
|
ground_offset.x < 0.0
|
|
or ground_offset.y < 0.0
|
|
or ground_offset.x > reference_size.x
|
|
or ground_offset.y > reference_size.y
|
|
)
|
|
):
|
|
failures.append("get_pet_ground_offset() must stay inside pet hit/preview size.")
|
|
|
|
|
|
func _validate_landing_animation(
|
|
pet: Node,
|
|
animation_names: Array[StringName],
|
|
failures: PackedStringArray
|
|
) -> void:
|
|
if not animation_names.has(&"landing"):
|
|
return
|
|
if not bool(pet.call(&"has_pet_animation", &"landing")):
|
|
failures.append("Direct landing animation is not playable.")
|
|
return
|
|
if pet.has_method(&"is_pet_animation_looping") and bool(
|
|
pet.call(&"is_pet_animation_looping", &"landing")
|
|
):
|
|
failures.append("Direct landing animation must be one-shot, not looping.")
|
|
|
|
|
|
func _validate_positive_float_method(
|
|
pet: Node,
|
|
method_name: StringName,
|
|
failures: PackedStringArray
|
|
) -> void:
|
|
if not pet.has_method(method_name):
|
|
return
|
|
var value := float(pet.call(method_name))
|
|
if not is_finite(value) or value <= 0.0:
|
|
failures.append("%s() must return a finite positive value." % method_name)
|
|
|
|
|
|
func _validate_pet_navigation(package_root: String, failures: PackedStringArray) -> Vector2i:
|
|
if package_root.is_empty():
|
|
failures.append("Cannot resolve theme package root for pet navigation validation.")
|
|
return Vector2i.ZERO
|
|
var manifest := _load_json(package_root.path_join("manifest.json"))
|
|
var screen_map: Dictionary = manifest.get("ScreenScenes", {})
|
|
var totals := Vector2i.ZERO
|
|
for screen_id_value in screen_map:
|
|
var screen_id := str(screen_id_value)
|
|
if screen_id == "screen.settings":
|
|
continue
|
|
var declared_path := str(screen_map.get(screen_id, ""))
|
|
var relative_path := _package_relative_path(declared_path)
|
|
var scene_path := package_root.path_join(relative_path)
|
|
var packed_scene := load(scene_path) as PackedScene
|
|
if packed_scene == null:
|
|
failures.append("Cannot load %s pet navigation scene: %s." % [screen_id, scene_path])
|
|
continue
|
|
var screen_root := packed_scene.instantiate()
|
|
if screen_root == null:
|
|
failures.append("Cannot instantiate %s pet navigation scene: %s." % [screen_id, scene_path])
|
|
continue
|
|
|
|
var roots: Array[Node] = []
|
|
for node in _all_nodes(screen_root):
|
|
if bool(node.get_meta(PET_NAVIGATION_ROOT_META, false)):
|
|
roots.append(node)
|
|
if roots.size() != 1:
|
|
failures.append("%s must contain exactly one pet navigation root." % screen_id)
|
|
screen_root.free()
|
|
continue
|
|
|
|
totals += _validate_pet_navigation_root(roots[0], failures, screen_id)
|
|
screen_root.free()
|
|
return totals
|
|
|
|
|
|
func _validate_pet_navigation_root(
|
|
navigation_root: Node,
|
|
failures: PackedStringArray,
|
|
screen_id: String
|
|
) -> Vector2i:
|
|
if not navigation_root is Node2D:
|
|
failures.append("%s pet navigation root must inherit Node2D." % screen_id)
|
|
if int(navigation_root.get_meta(&"exchanger_pet_navigation_version", 0)) != 1:
|
|
failures.append("%s pet navigation root version must be 1." % screen_id)
|
|
var bounds_value = navigation_root.get_meta(&"exchanger_pet_roaming_bounds", null)
|
|
var bounds := bounds_value as Rect2 if typeof(bounds_value) == TYPE_RECT2 else Rect2()
|
|
if (
|
|
typeof(bounds_value) != TYPE_RECT2
|
|
or not _is_finite_rect2(bounds)
|
|
or bounds.size.x <= 0.0
|
|
or bounds.size.y <= 0.0
|
|
):
|
|
failures.append("%s pet roaming bounds must be a finite positive Rect2." % screen_id)
|
|
|
|
var surfaces: Dictionary = {}
|
|
var climb_edges: Array[Dictionary] = []
|
|
var climb_ids: Dictionary = {}
|
|
for node in _all_nodes(navigation_root):
|
|
if bool(node.get_meta(PET_SURFACE_META, false)):
|
|
_validate_pet_surface(node, surfaces, bounds, failures)
|
|
if bool(node.get_meta(PET_CLIMB_EDGE_META, false)):
|
|
var edge_id := StringName(str(node.get_meta(&"exchanger_pet_climb_edge_id", "")))
|
|
if edge_id.is_empty():
|
|
failures.append("Pet climb edge id must not be empty.")
|
|
elif climb_ids.has(edge_id):
|
|
failures.append("Duplicate pet climb edge id %s." % edge_id)
|
|
else:
|
|
climb_ids[edge_id] = true
|
|
climb_edges.append({
|
|
"id": edge_id,
|
|
"from": StringName(str(node.get_meta(&"exchanger_pet_climb_from_surface_id", ""))),
|
|
"to": StringName(str(node.get_meta(&"exchanger_pet_climb_to_surface_id", ""))),
|
|
"x": float(node.get_meta(&"exchanger_pet_climb_x", NAN)),
|
|
"from_y": float(node.get_meta(&"exchanger_pet_climb_from_y", NAN)),
|
|
"to_y": float(node.get_meta(&"exchanger_pet_climb_to_y", NAN)),
|
|
"side": StringName(str(node.get_meta(&"exchanger_pet_climb_side", ""))),
|
|
})
|
|
|
|
if surfaces.is_empty():
|
|
failures.append("%s pet navigation root must contain at least one surface." % screen_id)
|
|
var default_surface := StringName(str(navigation_root.get_meta(&"exchanger_pet_default_surface_id", "")))
|
|
if default_surface.is_empty() or not surfaces.has(default_surface):
|
|
failures.append("%s pet navigation default surface must reference an existing surface." % screen_id)
|
|
for edge in climb_edges:
|
|
_validate_pet_climb_edge(edge, surfaces, bounds, failures)
|
|
return Vector2i(surfaces.size(), climb_edges.size())
|
|
|
|
|
|
func _validate_pet_surface(
|
|
node: Node,
|
|
surfaces: Dictionary,
|
|
bounds: Rect2,
|
|
failures: PackedStringArray
|
|
) -> void:
|
|
var surface_id := StringName(str(node.get_meta(&"exchanger_pet_surface_id", "")))
|
|
if surface_id.is_empty():
|
|
failures.append("Pet surface id must not be empty.")
|
|
return
|
|
if surfaces.has(surface_id):
|
|
failures.append("Duplicate pet surface id %s." % surface_id)
|
|
return
|
|
var from_x := float(node.get_meta(&"exchanger_pet_surface_from_x", NAN))
|
|
var to_x := float(node.get_meta(&"exchanger_pet_surface_to_x", NAN))
|
|
var surface_y := float(node.get_meta(&"exchanger_pet_surface_y", NAN))
|
|
if not is_finite(from_x) or not is_finite(to_x) or not is_finite(surface_y) or from_x >= to_x:
|
|
failures.append("Pet surface %s must have finite from_x < to_x and y." % surface_id)
|
|
if _is_finite_rect2(bounds) and (
|
|
not _rect_contains_inclusive(bounds, Vector2(from_x, surface_y))
|
|
or not _rect_contains_inclusive(bounds, Vector2(to_x, surface_y))
|
|
):
|
|
failures.append("Pet surface %s must stay inside roaming bounds." % surface_id)
|
|
var sit_points = node.get_meta(&"exchanger_pet_surface_sit_points", PackedFloat32Array())
|
|
var sit_type := typeof(sit_points)
|
|
if sit_type != TYPE_ARRAY and sit_type != TYPE_PACKED_FLOAT32_ARRAY:
|
|
failures.append("Pet surface %s sit points must be an Array or PackedFloat32Array." % surface_id)
|
|
else:
|
|
for raw_x in sit_points:
|
|
var sit_x := float(raw_x)
|
|
if not is_finite(sit_x) or sit_x < from_x or sit_x > to_x:
|
|
failures.append("Pet surface %s sit point must be finite and inside the surface." % surface_id)
|
|
surfaces[surface_id] = {"from_x": from_x, "to_x": to_x, "y": surface_y}
|
|
|
|
|
|
func _validate_pet_climb_edge(
|
|
edge: Dictionary,
|
|
surfaces: Dictionary,
|
|
bounds: Rect2,
|
|
failures: PackedStringArray
|
|
) -> void:
|
|
var edge_id: StringName = edge.id
|
|
var from_id: StringName = edge.from
|
|
var to_id: StringName = edge.to
|
|
if from_id.is_empty() or not surfaces.has(from_id):
|
|
failures.append("Pet climb edge %s has unknown from surface." % edge_id)
|
|
if to_id.is_empty() or not surfaces.has(to_id):
|
|
failures.append("Pet climb edge %s has unknown to surface." % edge_id)
|
|
var x := float(edge.x)
|
|
var from_y := float(edge.from_y)
|
|
var to_y := float(edge.to_y)
|
|
if not is_finite(x) or not is_finite(from_y) or not is_finite(to_y) or is_equal_approx(from_y, to_y):
|
|
failures.append("Pet climb edge %s must have finite coordinates and non-zero height." % edge_id)
|
|
if edge.side != &"left" and edge.side != &"right":
|
|
failures.append("Pet climb edge %s side must be left or right." % edge_id)
|
|
if _is_finite_rect2(bounds) and (
|
|
not _rect_contains_inclusive(bounds, Vector2(x, from_y))
|
|
or not _rect_contains_inclusive(bounds, Vector2(x, to_y))
|
|
):
|
|
failures.append("Pet climb edge %s must stay inside roaming bounds." % edge_id)
|
|
if surfaces.has(from_id):
|
|
var from_surface: Dictionary = surfaces[from_id]
|
|
if (
|
|
x < float(from_surface.from_x)
|
|
or x > float(from_surface.to_x)
|
|
or not is_equal_approx(from_y, float(from_surface.y))
|
|
):
|
|
failures.append("Pet climb edge %s must start on its from surface." % edge_id)
|
|
if surfaces.has(to_id):
|
|
var to_surface: Dictionary = surfaces[to_id]
|
|
if (
|
|
x < float(to_surface.from_x)
|
|
or x > float(to_surface.to_x)
|
|
or not is_equal_approx(to_y, float(to_surface.y))
|
|
):
|
|
failures.append("Pet climb edge %s must end on its to surface." % edge_id)
|
|
|
|
|
|
func _all_nodes(start: Node) -> Array[Node]:
|
|
var result: Array[Node] = [start]
|
|
var cursor := 0
|
|
while cursor < result.size():
|
|
for child in result[cursor].get_children():
|
|
result.append(child)
|
|
cursor += 1
|
|
return result
|
|
|
|
|
|
func _is_finite_vector2(value: Vector2) -> bool:
|
|
return is_finite(value.x) and is_finite(value.y)
|
|
|
|
|
|
func _is_finite_rect2(value: Rect2) -> bool:
|
|
return _is_finite_vector2(value.position) and _is_finite_vector2(value.size)
|
|
|
|
|
|
func _rect_contains_inclusive(rect: Rect2, point: Vector2) -> bool:
|
|
return (
|
|
point.x >= rect.position.x
|
|
and point.y >= rect.position.y
|
|
and point.x <= rect.end.x
|
|
and point.y <= rect.end.y
|
|
)
|
|
|
|
|
|
func _finish(
|
|
failures: PackedStringArray,
|
|
pet_scene_path: String,
|
|
animation_count: int,
|
|
total_frames: int,
|
|
surface_count: int,
|
|
climb_edge_count: int
|
|
) -> void:
|
|
if failures.is_empty():
|
|
print(
|
|
"[Interactive Pet] OK: %d animations, %d frames, stationary root, %d surfaces, %d climb edges across non-settings screens (%s)."
|
|
% [animation_count, total_frames, surface_count, climb_edge_count, pet_scene_path]
|
|
)
|
|
quit(0)
|
|
return
|
|
for failure in failures:
|
|
push_error("[Interactive Pet] %s" % failure)
|
|
quit(1)
|
|
|
|
|
|
func _fail(message: String) -> void:
|
|
push_error("[Interactive Pet] %s" % message)
|
|
quit(1)
|
|
|
|
|
|
func _load_json(path: String) -> Dictionary:
|
|
if not FileAccess.file_exists(path):
|
|
return {}
|
|
var value = JSON.parse_string(FileAccess.get_file_as_string(path))
|
|
return value if value is Dictionary else {}
|
|
|
|
|
|
func _argument_value(name: String, fallback: String) -> String:
|
|
var args := OS.get_cmdline_user_args()
|
|
var index := args.find(name)
|
|
if index >= 0 and index + 1 < args.size():
|
|
return args[index + 1]
|
|
return fallback
|