From bf4bf73675fe1f6acb03428ac39d7ba9f403f566 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 21:41:33 +0200 Subject: [PATCH] refactor: separate content from presentation assets --- simulation/animals/AnimalDefinition.gd | 37 +---- simulation/animals/AnimalFactory.gd | 25 ++- .../animals/definitions/domestic_goat.tres | 7 +- .../animals/definitions/domestic_sheep.tres | 7 +- simulation/definitions/ContentCatalog.gd | 13 +- simulation/definitions/PresentationCueIds.gd | 4 + tests/animal_definition_contract_test.gd | 31 +++- tests/content_catalog_test.gd | 2 +- tests/unit/test_presentation_catalog.gd | 148 ++++++++++++++++++ tests/unit/test_presentation_catalog.gd.uid | 1 + world/animals/animal_node.gd | 21 ++- world/animals/grazer/cozy_grazer_visual.gd | 25 ++- world/presentation/PresentationCatalog.gd | 84 ++++++++++ world/presentation/PresentationCatalog.gd.uid | 1 + .../PresentationCatalogResource.gd | 4 + .../PresentationCatalogResource.gd.uid | 1 + .../presentation/PresentationCueDefinition.gd | 89 +++++++++++ .../PresentationCueDefinition.gd.uid | 1 + .../PresentationNavRequestBudget.gd | 8 +- .../catalogs/animal_domestic_goat.tres | 16 ++ .../catalogs/animal_domestic_sheep.tres | 16 ++ .../catalogs/core_presentation.tres | 9 ++ 22 files changed, 480 insertions(+), 70 deletions(-) create mode 100644 tests/unit/test_presentation_catalog.gd create mode 100644 tests/unit/test_presentation_catalog.gd.uid create mode 100644 world/presentation/PresentationCatalog.gd create mode 100644 world/presentation/PresentationCatalog.gd.uid create mode 100644 world/presentation/PresentationCatalogResource.gd create mode 100644 world/presentation/PresentationCatalogResource.gd.uid create mode 100644 world/presentation/PresentationCueDefinition.gd create mode 100644 world/presentation/PresentationCueDefinition.gd.uid create mode 100644 world/presentation/catalogs/animal_domestic_goat.tres create mode 100644 world/presentation/catalogs/animal_domestic_sheep.tres create mode 100644 world/presentation/catalogs/core_presentation.tres diff --git a/simulation/animals/AnimalDefinition.gd b/simulation/animals/AnimalDefinition.gd index d51debd..9bc357c 100644 --- a/simulation/animals/AnimalDefinition.gd +++ b/simulation/animals/AnimalDefinition.gd @@ -8,13 +8,8 @@ const BEHAVIOR_GRAZER_ROUTINE := &"grazer_routine" @export var display_name: String @export var behavior_id: StringName = BEHAVIOR_GRAZER_ROUTINE @export var supported_action_ids: Array[StringName] = [] -@export_file("*.tscn") var presentation_scene_path: String +@export var presentation_cue_id: StringName @export_range(0.1, 10.0, 0.1) var movement_speed := 1.6 -@export var visual_scale := Vector3.ONE -@export var body_scale := Vector3.ONE -@export var fluff_scale := Vector3.ONE -@export var show_horns := true -@export var show_beard := true func validate() -> Array[String]: @@ -29,36 +24,10 @@ func validate() -> Array[String]: errors.append( "behavior_id '%s' is unsupported for '%s'" % [behavior_id, animal_definition_id] ) - if ( - presentation_scene_path.is_empty() - or not presentation_scene_path.begins_with("res://") - or presentation_scene_path.get_extension() != "tscn" - or not ResourceLoader.exists(presentation_scene_path, "PackedScene") - ): - errors.append("presentation_scene_path is invalid for '%s'" % animal_definition_id) + if not is_valid_stable_id(presentation_cue_id): + errors.append("presentation_cue_id is invalid for '%s'" % animal_definition_id) if not is_finite(movement_speed) or movement_speed <= 0.0: errors.append("movement_speed must be positive for '%s'" % animal_definition_id) - if ( - not visual_scale.is_finite() - or visual_scale.x <= 0.0 - or visual_scale.y <= 0.0 - or visual_scale.z <= 0.0 - ): - errors.append("visual_scale must be finite and positive for '%s'" % animal_definition_id) - if ( - not body_scale.is_finite() - or body_scale.x <= 0.0 - or body_scale.y <= 0.0 - or body_scale.z <= 0.0 - ): - errors.append("body_scale must be finite and positive for '%s'" % animal_definition_id) - if ( - not fluff_scale.is_finite() - or fluff_scale.x <= 0.0 - or fluff_scale.y <= 0.0 - or fluff_scale.z <= 0.0 - ): - errors.append("fluff_scale must be finite and positive for '%s'" % animal_definition_id) var seen: Dictionary = {} for action_id in supported_action_ids: if not is_valid_stable_id(action_id): diff --git a/simulation/animals/AnimalFactory.gd b/simulation/animals/AnimalFactory.gd index e2271ef..206eaf9 100644 --- a/simulation/animals/AnimalFactory.gd +++ b/simulation/animals/AnimalFactory.gd @@ -14,10 +14,12 @@ const CONFIGURATION_KEYS := [ var last_error := "" var _catalog: AnimalCatalog +var _presentation_catalog: PresentationCatalog -func _init(catalog: AnimalCatalog = null) -> void: +func _init(catalog: AnimalCatalog = null, presentation_catalog: PresentationCatalog = null) -> void: _catalog = catalog if catalog != null else AnimalCatalog.create_core() + _presentation_catalog = presentation_catalog if _catalog == null: last_error = "Animal catalog could not be loaded" elif not _catalog.rebuild().is_empty(): @@ -28,6 +30,10 @@ func get_catalog() -> AnimalCatalog: return _catalog +func get_presentation_catalog() -> PresentationCatalog: + return _presentation_catalog + + func create_presentation( animal_definition_id: StringName, animal_id: StringName, @@ -44,15 +50,26 @@ func create_presentation( if not _configuration_is_valid(configuration): return null var definition := _catalog.get_definition(animal_definition_id) - var presentation_scene := load(definition.presentation_scene_path) as PackedScene + if _presentation_catalog == null: + return _fail("Presentation catalog must be injected before creating presentation") + if not _presentation_catalog.is_valid(): + return _fail("Injected presentation catalog is invalid") + var cue := _presentation_catalog.get_definition(definition.presentation_cue_id) + if cue == null: + return _fail( + ( + "Unknown presentation cue '%s' for '%s'" + % [definition.presentation_cue_id, animal_definition_id] + ) + ) var node := ( - presentation_scene.instantiate() as AnimalNode if presentation_scene != null else null + _presentation_catalog.instantiate_scene(definition.presentation_cue_id) as AnimalNode ) if node == null: return _fail( "Presentation scene for '%s' must instantiate AnimalNode" % animal_definition_id ) - if not node.apply_animal_definition(definition): + if not node.apply_animal_definition(definition, cue.get_parameters()): node.free() return _fail("Presentation rejected definition '%s'" % animal_definition_id) node.animal_id = animal_id diff --git a/simulation/animals/definitions/domestic_goat.tres b/simulation/animals/definitions/domestic_goat.tres index 3f9f4cb..6b1e3bc 100644 --- a/simulation/animals/definitions/domestic_goat.tres +++ b/simulation/animals/definitions/domestic_goat.tres @@ -9,10 +9,5 @@ species_id = &"goat" display_name = "Domestic goat" behavior_id = &"grazer_routine" supported_action_ids = Array[StringName]([&"feed_animal"]) -presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn" +presentation_cue_id = &"animal.domestic_goat" movement_speed = 1.6 -visual_scale = Vector3(1, 1, 1) -body_scale = Vector3(1, 1, 1) -fluff_scale = Vector3(1, 1, 1) -show_horns = true -show_beard = true diff --git a/simulation/animals/definitions/domestic_sheep.tres b/simulation/animals/definitions/domestic_sheep.tres index bcedd5f..fd8ef62 100644 --- a/simulation/animals/definitions/domestic_sheep.tres +++ b/simulation/animals/definitions/domestic_sheep.tres @@ -9,10 +9,5 @@ species_id = &"sheep" display_name = "Domestic sheep" behavior_id = &"grazer_routine" supported_action_ids = Array[StringName]([&"feed_animal"]) -presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn" +presentation_cue_id = &"animal.domestic_sheep" movement_speed = 1.4 -visual_scale = Vector3(1.08, 1.04, 1.08) -body_scale = Vector3(1.12, 1.08, 1.12) -fluff_scale = Vector3(1.16, 1.14, 1.16) -show_horns = false -show_beard = false diff --git a/simulation/definitions/ContentCatalog.gd b/simulation/definitions/ContentCatalog.gd index 7bfc256..4064351 100644 --- a/simulation/definitions/ContentCatalog.gd +++ b/simulation/definitions/ContentCatalog.gd @@ -148,7 +148,7 @@ func rebuild( storages, items_by_id, actions_by_id, capability_tags_by_id, supported_cues, errors ) _validate_enemy_references(enemies, items_by_id, errors) - _validate_animal_references(animals, actions_by_id, errors) + _validate_animal_references(animals, actions_by_id, supported_cues, errors) errors.sort() _errors = errors if not errors.is_empty(): @@ -708,9 +708,18 @@ static func _validate_enemy_references( static func _validate_animal_references( - animals: Array[AnimalDefinition], actions_by_id: Dictionary, errors: Array[String] + animals: Array[AnimalDefinition], + actions_by_id: Dictionary, + supported_cues: Dictionary, + errors: Array[String] ) -> void: for definition in animals: + _validate_presentation_cue( + "Animal '%s'" % definition.animal_definition_id, + definition.presentation_cue_id, + supported_cues, + errors + ) for action_id in definition.supported_action_ids: if not actions_by_id.has(action_id): errors.append( diff --git a/simulation/definitions/PresentationCueIds.gd b/simulation/definitions/PresentationCueIds.gd index 09b62e1..e36c46c 100644 --- a/simulation/definitions/PresentationCueIds.gd +++ b/simulation/definitions/PresentationCueIds.gd @@ -6,6 +6,8 @@ extends RefCounted const ITEM_FOOD := &"item.food" const ITEM_HERBS := &"item.herbs" const ITEM_WOOD := &"item.wood" +const ANIMAL_DOMESTIC_GOAT := &"animal.domestic_goat" +const ANIMAL_DOMESTIC_SHEEP := &"animal.domestic_sheep" const RESOURCE_BERRY_PATCH := &"resource.berry_patch" const RESOURCE_HERB_PATCH := &"resource.herb_patch" const RESOURCE_TREE := &"resource.tree" @@ -16,6 +18,8 @@ const STORAGE_WOODPILE := &"storage.woodpile" static func get_all() -> Array[StringName]: var cue_ids: Array[StringName] = [ + ANIMAL_DOMESTIC_GOAT, + ANIMAL_DOMESTIC_SHEEP, ITEM_FOOD, ITEM_HERBS, ITEM_WOOD, diff --git a/tests/animal_definition_contract_test.gd b/tests/animal_definition_contract_test.gd index 01b90ab..b5e6160 100644 --- a/tests/animal_definition_contract_test.gd +++ b/tests/animal_definition_contract_test.gd @@ -51,9 +51,10 @@ func _run() -> void: ( goat_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE and sheep_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE - and goat_definition.presentation_scene_path == sheep_definition.presentation_scene_path + and goat_definition.presentation_cue_id == PresentationCueIds.ANIMAL_DOMESTIC_GOAT + and sheep_definition.presentation_cue_id == PresentationCueIds.ANIMAL_DOMESTIC_SHEEP ), - "Both animals should reuse one grazer behavior and presentation contract" + "Both animals should reuse one grazer behavior with stable presentation cues" ) _check( ( @@ -63,7 +64,29 @@ func _run() -> void: "Both data definitions should advertise the existing care action" ) - var generic_scene := load(goat_definition.presentation_scene_path) as PackedScene + var presentation_catalog := PresentationCatalog.create_core() + _check( + presentation_catalog != null and presentation_catalog.is_valid(), + "The presentation-side catalog should validate independently" + ) + if presentation_catalog == null or not presentation_catalog.is_valid(): + _finish() + return + var goat_cue := presentation_catalog.get_definition(goat_definition.presentation_cue_id) + var sheep_cue := presentation_catalog.get_definition(sheep_definition.presentation_cue_id) + _check( + ( + goat_cue != null + and sheep_cue != null + and goat_cue.scene_path == sheep_cue.scene_path + and goat_cue.scene_path == "res://world/animals/grazer/cozy_grazer.tscn" + ), + "Presentation cues should map both data-only animals to the shared grazer scene" + ) + if goat_cue == null or sheep_cue == null: + _finish() + return + var generic_scene := load(goat_cue.scene_path) as PackedScene var generic := generic_scene.instantiate() as AnimalNode _check( ( @@ -79,7 +102,7 @@ func _run() -> void: if generic != null: generic.free() - var factory := AnimalFactory.new(catalog) + var factory := AnimalFactory.new(catalog, presentation_catalog) var goat := ( factory . create_presentation( diff --git a/tests/content_catalog_test.gd b/tests/content_catalog_test.gd index bacbae8..e448ea2 100644 --- a/tests/content_catalog_test.gd +++ b/tests/content_catalog_test.gd @@ -257,7 +257,7 @@ func _animal(animal_definition_id: StringName, action_id: StringName) -> AnimalD animal.species_id = &"test_species" animal.display_name = String(animal_definition_id).capitalize() animal.supported_action_ids = [action_id] - animal.presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn" + animal.presentation_cue_id = PresentationCueIds.ANIMAL_DOMESTIC_GOAT return animal diff --git a/tests/unit/test_presentation_catalog.gd b/tests/unit/test_presentation_catalog.gd new file mode 100644 index 0000000..53ba8ee --- /dev/null +++ b/tests/unit/test_presentation_catalog.gd @@ -0,0 +1,148 @@ +extends GutTest + + +func test_headless_animal_content_uses_only_stable_cues_and_never_asset_paths() -> void: + var content := ContentCatalog.create_core() + assert_true(content.is_valid(), "%s" % [content.get_errors()]) + var animals := content.get_animals() + assert_eq(animals.size(), 2) + for animal in animals: + assert_true(AnimalDefinition.is_valid_stable_id(animal.presentation_cue_id)) + assert_false("res://" in String(animal.presentation_cue_id)) + assert_false(_resource_has_asset_path_property(animal)) + assert_true(animal.validate().is_empty()) + + var missing_asset_cue := PresentationCueDefinition.new() + missing_asset_cue.cue_id = &"animal.missing_asset" + missing_asset_cue.scene_path = "res://does/not/exist/animal.tscn" + assert_true(missing_asset_cue.validate_metadata().is_empty()) + assert_false(missing_asset_cue.validate_assets().is_empty()) + + var headless_catalog := ContentCatalog.new() + var pack := SimulationContentPack.new() + pack.pack_id = &"headless_animals" + pack.display_name = "Headless animals" + pack.animals = [_animal(&"headless_goat", missing_asset_cue.cue_id)] + var errors := headless_catalog.rebuild( + [pack] as Array[SimulationContentPack], [], [missing_asset_cue.cue_id] + ) + assert_true(errors.is_empty(), "%s" % [errors]) + assert_true(headless_catalog.is_valid()) + errors = headless_catalog.rebuild( + [pack] as Array[SimulationContentPack], [], [&"animal.some_other_cue"] + ) + assert_true(_has_error(errors, "unknown presentation cue 'animal.missing_asset'")) + assert_false(headless_catalog.is_valid()) + + +func test_presentation_catalog_rejects_missing_assets_and_duplicate_cues_transactionally() -> void: + var missing_id := PresentationCueDefinition.new() + var catalog := PresentationCatalog.new() + var errors := catalog.rebuild([missing_id] as Array[PresentationCueDefinition], false) + assert_true(_has_error(errors, "cue_id is invalid")) + assert_false(catalog.is_valid()) + + var valid := _cue(&"animal.valid", "res://world/animals/grazer/cozy_grazer.tscn") + var duplicate := _cue(&"animal.valid", "res://world/animals/goat/cozy_goat.tscn") + errors = catalog.rebuild([valid, duplicate] as Array[PresentationCueDefinition], false) + assert_true(_has_error(errors, "Duplicate presentation cue 'animal.valid'")) + assert_false(catalog.is_valid()) + assert_true(catalog.get_cue_ids().is_empty()) + assert_null(catalog.get_definition(&"animal.valid")) + + var missing := _cue(&"animal.missing", "res://world/animals/absent.tscn") + errors = catalog.rebuild([missing] as Array[PresentationCueDefinition], true) + assert_true(_has_error(errors, "scene_path does not resolve"), "%s" % [errors]) + assert_false(catalog.is_valid()) + assert_true(catalog.get_cue_ids().is_empty()) + + +func test_goat_and_sheep_are_data_only_content_additions_using_one_scene_contract() -> void: + var animal_catalog := AnimalCatalog.create_core() + var presentation_catalog := PresentationCatalog.create_core() + assert_not_null(animal_catalog) + assert_true(presentation_catalog.is_valid(), "%s" % [presentation_catalog.get_errors()]) + assert_eq( + presentation_catalog.get_cue_ids(), + [PresentationCueIds.ANIMAL_DOMESTIC_GOAT, PresentationCueIds.ANIMAL_DOMESTIC_SHEEP] + ) + var goat := animal_catalog.get_definition(&"domestic_goat") + var sheep := animal_catalog.get_definition(&"domestic_sheep") + var goat_cue := presentation_catalog.get_definition(goat.presentation_cue_id) + var sheep_cue := presentation_catalog.get_definition(sheep.presentation_cue_id) + + assert_eq(goat.behavior_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE) + assert_eq(sheep.behavior_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE) + assert_ne(goat.presentation_cue_id, sheep.presentation_cue_id) + assert_eq(goat_cue.scene_path, sheep_cue.scene_path) + assert_eq(goat_cue.animation_hook_id, sheep_cue.animation_hook_id) + assert_eq(goat_cue.animation_hook_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE) + assert_true(bool(goat_cue.parameters["show_horns"])) + assert_false(bool(sheep_cue.parameters["show_horns"])) + + var factory := AnimalFactory.new(animal_catalog, presentation_catalog) + var goat_node := factory.create_presentation(&"domestic_goat", &"factory_goat", "Goat") + var sheep_node := factory.create_presentation(&"domestic_sheep", &"factory_sheep", "Sheep") + assert_not_null(goat_node) + assert_not_null(sheep_node) + add_child_autofree(goat_node) + add_child_autofree(sheep_node) + await wait_process_frames(1) + assert_eq(goat_node.get_script(), sheep_node.get_script()) + assert_eq(goat_node.get_node("Visual").get_script(), sheep_node.get_node("Visual").get_script()) + assert_true(goat_node.get_node("Visual/HeadRoot/HornLeft").visible) + assert_false(sheep_node.get_node("Visual/HeadRoot/HornLeft").visible) + + +func test_factory_requires_an_injected_mapping_for_every_animal_cue() -> void: + var animal_catalog := AnimalCatalog.create_core() + var headless_factory := AnimalFactory.new(animal_catalog) + assert_null(headless_factory.get_presentation_catalog()) + assert_eq(headless_factory.last_error, "") + assert_null(headless_factory.create_presentation(&"domestic_goat", &"headless_goat", "Goat")) + assert_true("must be injected" in headless_factory.last_error) + + var presentation_catalog := PresentationCatalog.new() + var only_goat := PresentationCatalog.create_core().get_definition( + PresentationCueIds.ANIMAL_DOMESTIC_GOAT + ) + assert_true( + presentation_catalog.rebuild([only_goat] as Array[PresentationCueDefinition]).is_empty() + ) + var factory := AnimalFactory.new(animal_catalog, presentation_catalog) + assert_null(factory.create_presentation(&"domestic_sheep", &"missing_sheep", "Sheep")) + assert_true("Unknown presentation cue 'animal.domestic_sheep'" in factory.last_error) + + +func _animal(animal_id: StringName, cue_id: StringName) -> AnimalDefinition: + var animal := AnimalDefinition.new() + animal.animal_definition_id = animal_id + animal.species_id = &"test_species" + animal.display_name = "Headless animal" + animal.supported_action_ids = [] + animal.presentation_cue_id = cue_id + return animal + + +func _cue(cue_id: StringName, scene_path: String) -> PresentationCueDefinition: + var cue := PresentationCueDefinition.new() + cue.cue_id = cue_id + cue.scene_path = scene_path + return cue + + +func _resource_has_asset_path_property(resource: Resource) -> bool: + for property in resource.get_property_list(): + if (int(property["usage"]) & PROPERTY_USAGE_SCRIPT_VARIABLE) == 0: + continue + var property_name := String(property["name"]) + if property_name.ends_with("_path") or property_name in ["scene", "icon", "material"]: + return true + return false + + +func _has_error(errors: Array[String], fragment: String) -> bool: + for error in errors: + if fragment in error: + return true + return false diff --git a/tests/unit/test_presentation_catalog.gd.uid b/tests/unit/test_presentation_catalog.gd.uid new file mode 100644 index 0000000..67d2662 --- /dev/null +++ b/tests/unit/test_presentation_catalog.gd.uid @@ -0,0 +1 @@ +uid://7leb0ri32j4u diff --git a/world/animals/animal_node.gd b/world/animals/animal_node.gd index bc1b597..c2a329e 100644 --- a/world/animals/animal_node.gd +++ b/world/animals/animal_node.gd @@ -10,6 +10,7 @@ signal navigation_failed(animal_id: StringName, site_id: StringName) static var _all: Array[AnimalNode] = [] static var _core_animal_catalog: AnimalCatalog +static var _core_presentation_catalog: PresentationCatalog @export var animal_id: StringName @export var display_name := "Animal" @@ -39,6 +40,7 @@ var navigation_request_id := 0 var path_pending := false var has_reported_navigation_result := true var _animal_definition: AnimalDefinition +var _presentation_parameters: Dictionary = {} func _ready() -> void: @@ -132,7 +134,9 @@ func supports_action(action_id: StringName) -> bool: return action_id in supported_action_ids -func apply_animal_definition(definition: AnimalDefinition) -> bool: +func apply_animal_definition( + definition: AnimalDefinition, presentation_parameters: Dictionary = {} +) -> bool: if definition == null or not definition.validate().is_empty(): return false if state != null and state.get_animal_definition_id() != definition.animal_definition_id: @@ -142,9 +146,10 @@ func apply_animal_definition(definition: AnimalDefinition) -> bool: species_id = definition.species_id supported_action_ids = definition.supported_action_ids.duplicate() move_speed = definition.movement_speed + _presentation_parameters = presentation_parameters.duplicate(true) var visual := get_node_or_null("Visual") if visual != null and visual.has_method("apply_animal_definition"): - return bool(visual.apply_animal_definition(definition)) + return bool(visual.apply_animal_definition(definition, _presentation_parameters)) return true @@ -164,7 +169,17 @@ func _ensure_animal_definition() -> bool: _core_animal_catalog = AnimalCatalog.create_core() if _core_animal_catalog == null: return false - return apply_animal_definition(_core_animal_catalog.get_definition(animal_definition_id)) + var definition := _core_animal_catalog.get_definition(animal_definition_id) + if _core_presentation_catalog == null: + _core_presentation_catalog = PresentationCatalog.create_core() + if ( + definition == null + or _core_presentation_catalog == null + or not _core_presentation_catalog.is_valid() + ): + return false + var cue := _core_presentation_catalog.get_definition(definition.presentation_cue_id) + return cue != null and apply_animal_definition(definition, cue.get_parameters()) func get_hunger() -> float: diff --git a/world/animals/grazer/cozy_grazer_visual.gd b/world/animals/grazer/cozy_grazer_visual.gd index e89d192..171032f 100644 --- a/world/animals/grazer/cozy_grazer_visual.gd +++ b/world/animals/grazer/cozy_grazer_visual.gd @@ -28,6 +28,7 @@ var body_base_scale: Vector3 var fluff_front_base_scale: Vector3 var fluff_back_base_scale: Vector3 var _animal_definition: AnimalDefinition +var _presentation_parameters: Dictionary = {} func _ready() -> void: @@ -40,10 +41,13 @@ func _ready() -> void: reset_transient_feedback() -func apply_animal_definition(definition: AnimalDefinition) -> bool: +func apply_animal_definition( + definition: AnimalDefinition, presentation_parameters: Dictionary = {} +) -> bool: if definition == null or not definition.validate().is_empty(): return false _animal_definition = definition + _presentation_parameters = presentation_parameters.duplicate(true) if is_node_ready(): _apply_definition_presentation() return true @@ -52,13 +56,18 @@ func apply_animal_definition(definition: AnimalDefinition) -> bool: func _apply_definition_presentation() -> void: if _animal_definition == null: return - scale = _animal_definition.visual_scale - body.scale = body_base_scale * _animal_definition.body_scale - fluff_front.scale = fluff_front_base_scale * _animal_definition.fluff_scale - fluff_back.scale = fluff_back_base_scale * _animal_definition.fluff_scale - horn_left.visible = _animal_definition.show_horns - horn_right.visible = _animal_definition.show_horns - beard.visible = _animal_definition.show_beard + scale = _parameter_vector("visual_scale", Vector3.ONE) + body.scale = body_base_scale * _parameter_vector("body_scale", Vector3.ONE) + fluff_front.scale = fluff_front_base_scale * _parameter_vector("fluff_scale", Vector3.ONE) + fluff_back.scale = fluff_back_base_scale * _parameter_vector("fluff_scale", Vector3.ONE) + horn_left.visible = bool(_presentation_parameters.get("show_horns", true)) + horn_right.visible = bool(_presentation_parameters.get("show_horns", true)) + beard.visible = bool(_presentation_parameters.get("show_beard", true)) + + +func _parameter_vector(key: String, fallback: Vector3) -> Vector3: + var value: Variant = _presentation_parameters.get(key, fallback) + return value as Vector3 if value is Vector3 else fallback func _process(delta: float) -> void: diff --git a/world/presentation/PresentationCatalog.gd b/world/presentation/PresentationCatalog.gd new file mode 100644 index 0000000..7da4fed --- /dev/null +++ b/world/presentation/PresentationCatalog.gd @@ -0,0 +1,84 @@ +class_name PresentationCatalog +extends RefCounted + +const CORE_CATALOG_PATH := "res://world/presentation/catalogs/core_presentation.tres" + +var _valid := false +var _errors: Array[String] = [] +var _definitions: Array[PresentationCueDefinition] = [] +var _definitions_by_id: Dictionary = {} + + +static func create_core(validate_assets := true) -> PresentationCatalog: + var catalog := PresentationCatalog.new() + var resource := load(CORE_CATALOG_PATH) as PresentationCatalogResource + if resource == null: + catalog._errors = ["Core presentation catalog failed to load"] + return catalog + catalog.rebuild(resource.cues, validate_assets) + return catalog + + +func rebuild( + definitions: Array[PresentationCueDefinition], validate_assets := true +) -> Array[String]: + _valid = false + _errors.clear() + _definitions.clear() + _definitions_by_id.clear() + var ordered := definitions.duplicate() + ordered.sort_custom(_definition_before) + for definition in ordered: + if definition == null: + _errors.append("Presentation catalog contains a null cue") + continue + for error in ( + definition.validate_assets() if validate_assets else definition.validate_metadata() + ): + _errors.append("Cue '%s': %s" % [definition.cue_id, error]) + if _definitions_by_id.has(definition.cue_id): + _errors.append("Duplicate presentation cue '%s'" % definition.cue_id) + else: + _definitions_by_id[definition.cue_id] = definition + _definitions.append(definition) + _errors.sort() + if not _errors.is_empty(): + _definitions.clear() + _definitions_by_id.clear() + return get_errors() + _valid = true + return [] + + +func is_valid() -> bool: + return _valid + + +func get_errors() -> Array[String]: + return _errors.duplicate() + + +func get_definition(cue_id: StringName) -> PresentationCueDefinition: + return _definitions_by_id.get(cue_id) as PresentationCueDefinition + + +func get_cue_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for definition in _definitions: + result.append(definition.cue_id) + return result + + +func instantiate_scene(cue_id: StringName) -> Node: + var definition := get_definition(cue_id) + return definition.instantiate_scene() if definition != null else null + + +func _definition_before( + first: PresentationCueDefinition, second: PresentationCueDefinition +) -> bool: + if first == null: + return second != null + if second == null: + return false + return String(first.cue_id) < String(second.cue_id) diff --git a/world/presentation/PresentationCatalog.gd.uid b/world/presentation/PresentationCatalog.gd.uid new file mode 100644 index 0000000..a669f4e --- /dev/null +++ b/world/presentation/PresentationCatalog.gd.uid @@ -0,0 +1 @@ +uid://dd1bsnixqs5hr diff --git a/world/presentation/PresentationCatalogResource.gd b/world/presentation/PresentationCatalogResource.gd new file mode 100644 index 0000000..f9f51e7 --- /dev/null +++ b/world/presentation/PresentationCatalogResource.gd @@ -0,0 +1,4 @@ +class_name PresentationCatalogResource +extends Resource + +@export var cues: Array[PresentationCueDefinition] = [] diff --git a/world/presentation/PresentationCatalogResource.gd.uid b/world/presentation/PresentationCatalogResource.gd.uid new file mode 100644 index 0000000..ea712ff --- /dev/null +++ b/world/presentation/PresentationCatalogResource.gd.uid @@ -0,0 +1 @@ +uid://g61fe7gl8hqo diff --git a/world/presentation/PresentationCueDefinition.gd b/world/presentation/PresentationCueDefinition.gd new file mode 100644 index 0000000..b23add0 --- /dev/null +++ b/world/presentation/PresentationCueDefinition.gd @@ -0,0 +1,89 @@ +class_name PresentationCueDefinition +extends Resource + +const ALLOWED_PARAMETER_KEYS := { + "visual_scale": true, + "body_scale": true, + "fluff_scale": true, + "show_horns": true, + "show_beard": true, +} + +@export var cue_id: StringName +@export_file("*.tscn") var scene_path: String +@export_file var icon_path: String +@export_file var material_path: String +@export var animation_hook_id: StringName +@export var parameters: Dictionary = {} + + +func validate_metadata() -> Array[String]: + var errors: Array[String] = [] + if not AnimalDefinition.is_valid_stable_id(cue_id): + errors.append("cue_id is invalid") + _validate_optional_path(scene_path, "scene_path", ["tscn", "scn"], errors) + _validate_optional_path(icon_path, "icon_path", ["png", "svg", "webp"], errors) + _validate_optional_path(material_path, "material_path", ["tres", "res", "material"], errors) + if ( + not animation_hook_id.is_empty() + and not AnimalDefinition.is_valid_stable_id(animation_hook_id) + ): + errors.append("animation_hook_id is invalid for '%s'" % cue_id) + _validate_parameters(errors) + return errors + + +func validate_assets() -> Array[String]: + var errors := validate_metadata() + if not errors.is_empty(): + return errors + for path_field in [ + ["scene_path", scene_path, "PackedScene"], + ["icon_path", icon_path, "Texture2D"], + ["material_path", material_path, "Material"], + ]: + var path: String = path_field[1] + if not path.is_empty() and not ResourceLoader.exists(path, path_field[2]): + errors.append("%s does not resolve for '%s'" % [path_field[0], cue_id]) + return errors + + +func get_parameters() -> Dictionary: + return parameters.duplicate(true) + + +func instantiate_scene() -> Node: + if scene_path.is_empty(): + return null + var scene := load(scene_path) as PackedScene + return scene.instantiate() if scene != null else null + + +func _validate_parameters(errors: Array[String]) -> void: + for raw_key in parameters: + if raw_key is not String and raw_key is not StringName: + errors.append("parameter key must be a string for '%s'" % cue_id) + continue + var key := String(raw_key) + if not ALLOWED_PARAMETER_KEYS.has(key): + errors.append("unsupported parameter '%s' for '%s'" % [key, cue_id]) + continue + var value: Variant = parameters[raw_key] + if key.ends_with("_scale"): + if value is not Vector3 or not _is_positive_vector(value): + errors.append("parameter '%s' must be a positive Vector3 for '%s'" % [key, cue_id]) + elif value is not bool: + errors.append("parameter '%s' must be bool for '%s'" % [key, cue_id]) + + +func _validate_optional_path( + path: String, field: String, extensions: Array[String], errors: Array[String] +) -> void: + if path.is_empty(): + return + if not path.begins_with("res://") or path.get_extension().to_lower() not in extensions: + errors.append("%s is invalid for '%s'" % [field, cue_id]) + + +func _is_positive_vector(value: Vector3) -> bool: + return value.is_finite() and value.x > 0.0 and value.y > 0.0 and value.z > 0.0 diff --git a/world/presentation/PresentationCueDefinition.gd.uid b/world/presentation/PresentationCueDefinition.gd.uid new file mode 100644 index 0000000..23e05cb --- /dev/null +++ b/world/presentation/PresentationCueDefinition.gd.uid @@ -0,0 +1 @@ +uid://bqm8k67sgvnb0 diff --git a/world/presentation/PresentationNavRequestBudget.gd b/world/presentation/PresentationNavRequestBudget.gd index 44259ce..f51b957 100644 --- a/world/presentation/PresentationNavRequestBudget.gd +++ b/world/presentation/PresentationNavRequestBudget.gd @@ -53,7 +53,7 @@ func get_pending_ids() -> Array[StringName]: var result: Array[StringName] = [] for stable_id in _pending_by_id: result.append(StringName(stable_id)) - result.sort() + result.sort_custom(_stable_id_before) return result @@ -61,7 +61,7 @@ func get_in_flight_ids() -> Array[StringName]: var result: Array[StringName] = [] for stable_id in _in_flight_ids: result.append(StringName(stable_id)) - result.sort() + result.sort_custom(_stable_id_before) return result @@ -75,3 +75,7 @@ func _request_before(first: Dictionary, second: Dictionary) -> bool: if int(first["priority"]) != int(second["priority"]): return int(first["priority"]) > int(second["priority"]) return String(first["stable_id"]) < String(second["stable_id"]) + + +func _stable_id_before(first: StringName, second: StringName) -> bool: + return String(first) < String(second) diff --git a/world/presentation/catalogs/animal_domestic_goat.tres b/world/presentation/catalogs/animal_domestic_goat.tres new file mode 100644 index 0000000..90000d6 --- /dev/null +++ b/world/presentation/catalogs/animal_domestic_goat.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="PresentationCueDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://world/presentation/PresentationCueDefinition.gd" id="1_cue"] + +[resource] +script = ExtResource("1_cue") +cue_id = &"animal.domestic_goat" +scene_path = "res://world/animals/grazer/cozy_grazer.tscn" +animation_hook_id = &"grazer_routine" +parameters = { +"body_scale": Vector3(1, 1, 1), +"fluff_scale": Vector3(1, 1, 1), +"show_beard": true, +"show_horns": true, +"visual_scale": Vector3(1, 1, 1) +} diff --git a/world/presentation/catalogs/animal_domestic_sheep.tres b/world/presentation/catalogs/animal_domestic_sheep.tres new file mode 100644 index 0000000..17fc47e --- /dev/null +++ b/world/presentation/catalogs/animal_domestic_sheep.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="PresentationCueDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://world/presentation/PresentationCueDefinition.gd" id="1_cue"] + +[resource] +script = ExtResource("1_cue") +cue_id = &"animal.domestic_sheep" +scene_path = "res://world/animals/grazer/cozy_grazer.tscn" +animation_hook_id = &"grazer_routine" +parameters = { +"body_scale": Vector3(1.12, 1.08, 1.12), +"fluff_scale": Vector3(1.16, 1.14, 1.16), +"show_beard": false, +"show_horns": false, +"visual_scale": Vector3(1.08, 1.04, 1.08) +} diff --git a/world/presentation/catalogs/core_presentation.tres b/world/presentation/catalogs/core_presentation.tres new file mode 100644 index 0000000..1bea818 --- /dev/null +++ b/world/presentation/catalogs/core_presentation.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="PresentationCatalogResource" load_steps=4 format=3] + +[ext_resource type="Script" path="res://world/presentation/PresentationCatalogResource.gd" id="1_catalog"] +[ext_resource type="Resource" path="res://world/presentation/catalogs/animal_domestic_goat.tres" id="2_goat"] +[ext_resource type="Resource" path="res://world/presentation/catalogs/animal_domestic_sheep.tres" id="3_sheep"] + +[resource] +script = ExtResource("1_catalog") +cues = Array[ExtResource("2_goat")]([ExtResource("2_goat"), ExtResource("3_sheep")])