diff --git a/simulation/animals/AnimalCatalog.gd b/simulation/animals/AnimalCatalog.gd new file mode 100644 index 0000000..a5a60e0 --- /dev/null +++ b/simulation/animals/AnimalCatalog.gd @@ -0,0 +1,93 @@ +class_name AnimalCatalog +extends Resource + +const CORE_CATALOG_PATH := "res://simulation/animals/definitions/core_animals.tres" + +@export var animals: Array[AnimalDefinition] = [] + +var _definitions_by_id: Dictionary = {} +var _definition_ids: Array[StringName] = [] +var _errors: Array[String] = [] + + +static func create_core() -> AnimalCatalog: + var catalog := load(CORE_CATALOG_PATH) as AnimalCatalog + if catalog != null: + catalog.rebuild() + return catalog + + +func rebuild() -> Array[String]: + _definitions_by_id.clear() + _definition_ids.clear() + _errors.clear() + for definition in animals: + if definition == null: + _errors.append("Animal catalog contains a null definition") + continue + for error in definition.validate(): + _errors.append("Animal '%s': %s" % [definition.animal_definition_id, error]) + if _definitions_by_id.has(definition.animal_definition_id): + _errors.append("Duplicate animal definition '%s'" % definition.animal_definition_id) + else: + _definitions_by_id[definition.animal_definition_id] = definition + _definition_ids.append(definition.animal_definition_id) + _definition_ids.sort_custom(_id_less) + _errors.sort() + return get_errors() + + +func is_valid() -> bool: + return rebuild().is_empty() + + +func get_errors() -> Array[String]: + return _errors.duplicate() + + +func has_definition(animal_definition_id: StringName) -> bool: + _ensure_built() + return _definitions_by_id.has(animal_definition_id) + + +func get_definition(animal_definition_id: StringName) -> AnimalDefinition: + _ensure_built() + return _definitions_by_id.get(animal_definition_id) as AnimalDefinition + + +func matches_identity(animal_definition_id: StringName, species_id: StringName) -> bool: + var definition := get_definition(animal_definition_id) + return definition != null and definition.species_id == species_id + + +func get_definition_ids() -> Array[StringName]: + _ensure_built() + return _definition_ids.duplicate() + + +func get_definitions_for_behavior(behavior_id: StringName) -> Array[AnimalDefinition]: + _ensure_built() + var result: Array[AnimalDefinition] = [] + for definition_id in _definition_ids: + var definition := _definitions_by_id[definition_id] as AnimalDefinition + if definition.behavior_id == behavior_id: + result.append(definition) + return result + + +func find_definition_for_species(species_id: StringName) -> AnimalDefinition: + _ensure_built() + for definition_id in _definition_ids: + var definition := _definitions_by_id[definition_id] as AnimalDefinition + if definition.species_id == species_id: + return definition + return null + + +func _ensure_built() -> void: + if _definitions_by_id.is_empty() and not animals.is_empty(): + rebuild() + + +static func _id_less(left: StringName, right: StringName) -> bool: + return String(left) < String(right) diff --git a/simulation/animals/AnimalCatalog.gd.uid b/simulation/animals/AnimalCatalog.gd.uid new file mode 100644 index 0000000..93b31f0 --- /dev/null +++ b/simulation/animals/AnimalCatalog.gd.uid @@ -0,0 +1 @@ +uid://bbd405aahq78w diff --git a/simulation/animals/AnimalDefinition.gd b/simulation/animals/AnimalDefinition.gd new file mode 100644 index 0000000..d51debd --- /dev/null +++ b/simulation/animals/AnimalDefinition.gd @@ -0,0 +1,94 @@ +class_name AnimalDefinition +extends Resource + +const BEHAVIOR_GRAZER_ROUTINE := &"grazer_routine" + +@export var animal_definition_id: StringName +@export var species_id: StringName +@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_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]: + var errors: Array[String] = [] + if not is_valid_stable_id(animal_definition_id): + errors.append("animal_definition_id is invalid") + if not is_valid_stable_id(species_id): + errors.append("species_id is invalid for '%s'" % animal_definition_id) + if display_name.strip_edges().is_empty(): + errors.append("display_name is empty for '%s'" % animal_definition_id) + if behavior_id != BEHAVIOR_GRAZER_ROUTINE: + 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_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): + errors.append("supported action ID is invalid for '%s'" % animal_definition_id) + elif seen.has(action_id): + errors.append( + "duplicate supported action '%s' for '%s'" % [action_id, animal_definition_id] + ) + else: + seen[action_id] = true + return errors + + +func supports_action(action_id: StringName) -> bool: + return action_id in supported_action_ids + + +static func is_valid_stable_id(value: Variant) -> bool: + if value is not String and value is not StringName: + return false + var text := String(value) + if text.is_empty() or text.length() > 96: + return false + for character in text: + var code := character.unicode_at(0) + if not ( + (code >= 48 and code <= 57) + or (code >= 65 and code <= 90) + or (code >= 97 and code <= 122) + or character in ["_", "-", ".", ":"] + ): + return false + return true diff --git a/simulation/animals/AnimalDefinition.gd.uid b/simulation/animals/AnimalDefinition.gd.uid new file mode 100644 index 0000000..48abbfd --- /dev/null +++ b/simulation/animals/AnimalDefinition.gd.uid @@ -0,0 +1 @@ +uid://cg5ou2nnnvcr2 diff --git a/simulation/animals/AnimalFactory.gd b/simulation/animals/AnimalFactory.gd new file mode 100644 index 0000000..e2271ef --- /dev/null +++ b/simulation/animals/AnimalFactory.gd @@ -0,0 +1,117 @@ +class_name AnimalFactory +extends RefCounted + +const CONFIGURATION_KEYS := [ + "initial_hunger", + "initial_enabled", + "can_npcs_feed", + "can_player_feed", + "debug_label_enabled", + "initial_routine_site_id", + "initial_next_routine_tick", +] + +var last_error := "" + +var _catalog: AnimalCatalog + + +func _init(catalog: AnimalCatalog = null) -> void: + _catalog = catalog if catalog != null else AnimalCatalog.create_core() + if _catalog == null: + last_error = "Animal catalog could not be loaded" + elif not _catalog.rebuild().is_empty(): + last_error = "; ".join(_catalog.get_errors()) + + +func get_catalog() -> AnimalCatalog: + return _catalog + + +func create_presentation( + animal_definition_id: StringName, + animal_id: StringName, + display_name: String, + configuration: Dictionary = {} +) -> AnimalNode: + last_error = "" + if _catalog == null or not _catalog.has_definition(animal_definition_id): + return _fail("Unknown animal definition '%s'" % animal_definition_id) + if not AnimalDefinition.is_valid_stable_id(animal_id): + return _fail("animal_id is invalid") + if display_name.strip_edges().is_empty(): + return _fail("display_name is empty") + 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 + var node := ( + presentation_scene.instantiate() as AnimalNode if presentation_scene != null else null + ) + if node == null: + return _fail( + "Presentation scene for '%s' must instantiate AnimalNode" % animal_definition_id + ) + if not node.apply_animal_definition(definition): + node.free() + return _fail("Presentation rejected definition '%s'" % animal_definition_id) + node.animal_id = animal_id + node.display_name = display_name + _apply_configuration(node, configuration) + return node + + +func _configuration_is_valid(configuration: Dictionary) -> bool: + for key in configuration: + if (key is not String and key is not StringName) or String(key) not in CONFIGURATION_KEYS: + return _fail_bool("Unknown animal presentation configuration '%s'" % key) + if configuration.has("initial_hunger"): + if ( + configuration["initial_hunger"] is not int + and configuration["initial_hunger"] is not float + ): + return _fail_bool("initial_hunger must be numeric") + var hunger := float(configuration["initial_hunger"]) + if not is_finite(hunger) or hunger < 0.0 or hunger > 100.0: + return _fail_bool("initial_hunger must be between 0 and 100") + if ( + configuration.has("initial_routine_site_id") + and not AnimalDefinition.is_valid_stable_id(configuration["initial_routine_site_id"]) + ): + return _fail_bool("initial_routine_site_id is invalid") + if configuration.has("initial_next_routine_tick"): + if configuration["initial_next_routine_tick"] is not int: + return _fail_bool("initial_next_routine_tick must be an int") + if int(configuration["initial_next_routine_tick"]) < 0: + return _fail_bool("initial_next_routine_tick must not be negative") + for key in ["initial_enabled", "can_npcs_feed", "can_player_feed", "debug_label_enabled"]: + if configuration.has(key) and configuration[key] is not bool: + return _fail_bool("%s must be a bool" % key) + return true + + +func _apply_configuration(node: AnimalNode, configuration: Dictionary) -> void: + if configuration.has("initial_hunger"): + node.initial_hunger = float(configuration["initial_hunger"]) + if configuration.has("initial_enabled"): + node.initial_enabled = bool(configuration["initial_enabled"]) + if configuration.has("can_npcs_feed"): + node.can_npcs_feed = bool(configuration["can_npcs_feed"]) + if configuration.has("can_player_feed"): + node.can_player_feed = bool(configuration["can_player_feed"]) + if configuration.has("debug_label_enabled"): + node.debug_label_enabled = bool(configuration["debug_label_enabled"]) + if configuration.has("initial_routine_site_id"): + node.initial_routine_site_id = StringName(configuration["initial_routine_site_id"]) + if configuration.has("initial_next_routine_tick"): + node.initial_next_routine_tick = int(configuration["initial_next_routine_tick"]) + + +func _fail(message: String) -> AnimalNode: + last_error = message + return null + + +func _fail_bool(message: String) -> bool: + last_error = message + return false diff --git a/simulation/animals/AnimalFactory.gd.uid b/simulation/animals/AnimalFactory.gd.uid new file mode 100644 index 0000000..6777093 --- /dev/null +++ b/simulation/animals/AnimalFactory.gd.uid @@ -0,0 +1 @@ +uid://cgpayfff6eeug diff --git a/simulation/animals/animal_care_system.gd b/simulation/animals/animal_care_system.gd index c89242a..82d7619 100644 --- a/simulation/animals/animal_care_system.gd +++ b/simulation/animals/animal_care_system.gd @@ -29,6 +29,7 @@ var states: Dictionary = {} var economy: RefCounted var active_world_adapter: Node var current_tick := 0 +var _animal_catalog: AnimalCatalog func configure(economy_service: RefCounted, world_adapter: Node) -> void: @@ -36,6 +37,19 @@ func configure(economy_service: RefCounted, world_adapter: Node) -> void: active_world_adapter = world_adapter +func configure_animal_catalog(catalog: AnimalCatalog) -> bool: + if catalog == null or not catalog.rebuild().is_empty(): + return false + _animal_catalog = catalog + return true + + +func get_animal_catalog() -> AnimalCatalog: + if _animal_catalog == null: + _animal_catalog = AnimalCatalog.create_core() + return _animal_catalog + + func advance(simulation_tick: int) -> void: current_tick = simulation_tick var animal_ids: Array = states.keys() @@ -67,15 +81,23 @@ func register_node(node: AnimalNode) -> bool: push_error("AnimalCareSystem: Animal ID '%s' collides with a world target" % node.animal_id) return false var action_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) + var animal_catalog := get_animal_catalog() + var animal_definition := ( + animal_catalog.get_definition(node.animal_definition_id) if animal_catalog != null else null + ) if ( action_definition == null or action_definition.target_type != SimulationIds.TARGET_ANIMAL - or node.species_id != SimulationIds.SPECIES_GOAT + or animal_definition == null + or animal_definition.behavior_id != AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE + or animal_definition.species_id != node.species_id + or not animal_definition.supports_action(SimulationIds.ACTION_FEED_ANIMAL) + or not node.supports_action(SimulationIds.ACTION_FEED_ANIMAL) ): push_error( ( - "AnimalCareSystem: AnimalNode '%s' has an invalid feed action or species '%s'" - % [node.animal_id, node.species_id] + "AnimalCareSystem: AnimalNode '%s' has invalid definition '%s' or species '%s'" + % [node.animal_id, node.animal_definition_id, node.species_id] ) ) return false diff --git a/simulation/animals/definitions/core_animals.tres b/simulation/animals/definitions/core_animals.tres new file mode 100644 index 0000000..453851a --- /dev/null +++ b/simulation/animals/definitions/core_animals.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="AnimalCatalog" load_steps=4 format=3] + +[ext_resource type="Script" path="res://simulation/animals/AnimalCatalog.gd" id="1_catalog"] +[ext_resource type="Resource" path="res://simulation/animals/definitions/domestic_goat.tres" id="2_goat"] +[ext_resource type="Resource" path="res://simulation/animals/definitions/domestic_sheep.tres" id="3_sheep"] + +[resource] +script = ExtResource("1_catalog") +animals = Array[ExtResource("2_goat")]([ExtResource("2_goat"), ExtResource("3_sheep")]) diff --git a/simulation/animals/definitions/domestic_goat.tres b/simulation/animals/definitions/domestic_goat.tres new file mode 100644 index 0000000..3f9f4cb --- /dev/null +++ b/simulation/animals/definitions/domestic_goat.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="AnimalDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/animals/AnimalDefinition.gd" id="1_definition"] + +[resource] +script = ExtResource("1_definition") +animal_definition_id = &"domestic_goat" +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" +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 new file mode 100644 index 0000000..bcedd5f --- /dev/null +++ b/simulation/animals/definitions/domestic_sheep.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="AnimalDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/animals/AnimalDefinition.gd" id="1_definition"] + +[resource] +script = ExtResource("1_definition") +animal_definition_id = &"domestic_sheep" +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" +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/state/animal_state_record.gd b/simulation/state/animal_state_record.gd index 4800b15..5870d7a 100644 --- a/simulation/state/animal_state_record.gd +++ b/simulation/state/animal_state_record.gd @@ -4,12 +4,14 @@ extends RefCounted signal changed(state: AnimalStateRecord) signal fed(state: AnimalStateRecord, actor_id: int) -const SCHEMA_VERSION := 2 +const SCHEMA_VERSION := 3 +const ROUTINE_SCHEMA_VERSION := 2 const LEGACY_SCHEMA_VERSION := 1 const HUNGER_PER_TICK := 0.125 const FEED_THRESHOLD := 65.0 const HUNGER_RELIEF := 55.0 const NEVER_FED_TICK := -1 +const LEGACY_DEFINITION_BY_SPECIES := {"goat": "domestic_goat"} var data: Dictionary @@ -25,6 +27,7 @@ static func create_from_node(node: AnimalNode) -> AnimalStateRecord: { "schema_version": SCHEMA_VERSION, "animal_id": String(node.animal_id), + "animal_definition_id": String(node.animal_definition_id), "display_name": node.display_name, "species_id": String(node.species_id), "position": @@ -49,6 +52,8 @@ static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord: var version := int(record_data.get("schema_version", -1)) if version == LEGACY_SCHEMA_VERSION: record_data = _migrate_legacy(record_data) + elif version == ROUTINE_SCHEMA_VERSION: + record_data = _migrate_definition_identity(record_data) elif version != SCHEMA_VERSION: return null if not ( @@ -56,6 +61,7 @@ static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord: . has_all( [ "animal_id", + "animal_definition_id", "display_name", "species_id", "position", @@ -75,6 +81,7 @@ static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord: ): return null var animal_id := String(record_data["animal_id"]) + var animal_definition_id := String(record_data["animal_definition_id"]) var display_name := String(record_data["display_name"]) var species_id := String(record_data["species_id"]) var saved_position = record_data["position"] @@ -87,9 +94,12 @@ static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord: var has_travel_target := bool(record_data["has_travel_target"]) var next_routine_tick := int(record_data["next_routine_tick"]) var enabled := bool(record_data["enabled"]) - if animal_id.is_empty() or display_name.is_empty() or species_id.is_empty(): - return null - if StringName(species_id) != SimulationIds.SPECIES_GOAT: + if ( + not AnimalDefinition.is_valid_stable_id(animal_id) + or not AnimalDefinition.is_valid_stable_id(animal_definition_id) + or display_name.is_empty() + or not AnimalDefinition.is_valid_stable_id(species_id) + ): return null if not saved_position is Array or saved_position.size() != 3: return null @@ -120,6 +130,7 @@ static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord: var normalized := record_data.duplicate(true) normalized["schema_version"] = SCHEMA_VERSION normalized["animal_id"] = animal_id + normalized["animal_definition_id"] = animal_definition_id normalized["display_name"] = display_name normalized["species_id"] = species_id normalized["position"] = [ @@ -145,7 +156,8 @@ func apply_definition(node: AnimalNode) -> bool: if node == null or node.animal_id != get_animal_id(): return false return ( - node.display_name == get_display_name() + node.animal_definition_id == get_animal_definition_id() + and node.display_name == get_display_name() and node.species_id == get_species_id() and node.can_npcs_feed == can_npc_feed() and node.can_player_feed == can_player_feed_animal() @@ -156,6 +168,10 @@ func get_animal_id() -> StringName: return StringName(data["animal_id"]) +func get_animal_definition_id() -> StringName: + return StringName(data["animal_definition_id"]) + + func get_display_name() -> String: return String(data["display_name"]) @@ -350,10 +366,18 @@ func to_dictionary() -> Dictionary: static func _migrate_legacy(record_data: Dictionary) -> Dictionary: var migrated := record_data.duplicate(true) - migrated["schema_version"] = SCHEMA_VERSION + migrated["schema_version"] = ROUTINE_SCHEMA_VERSION migrated["routine_site_id"] = "" migrated["travel_target_site_id"] = "" migrated["travel_target_position"] = [0.0, 0.0, 0.0] migrated["has_travel_target"] = false migrated["next_routine_tick"] = 0 + return _migrate_definition_identity(migrated) + + +static func _migrate_definition_identity(record_data: Dictionary) -> Dictionary: + var migrated := record_data.duplicate(true) + migrated["schema_version"] = SCHEMA_VERSION + var species_id := String(migrated.get("species_id", "")) + migrated["animal_definition_id"] = String(LEGACY_DEFINITION_BY_SPECIES.get(species_id, "")) return migrated diff --git a/tests/animal_definition_contract_test.gd b/tests/animal_definition_contract_test.gd new file mode 100644 index 0000000..01b90ab --- /dev/null +++ b/tests/animal_definition_contract_test.gd @@ -0,0 +1,234 @@ +extends SceneTree + + +class FakeRoutineWorld: + extends Node + + func get_animal_routine_sites( + species_id: StringName, animal_id: StringName + ) -> Array[Dictionary]: + return [ + { + "site_id": &"%s_%s_meadow" % [species_id, animal_id], + "position": Vector3(3.0 if species_id == &"goat" else -3.0, 0.0, 2.0), + } + ] + + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var catalog := AnimalCatalog.create_core() + _check(catalog != null and catalog.rebuild().is_empty(), "Core animal content should validate") + if catalog == null: + _finish() + return + var definition_ids := catalog.get_definition_ids() + _check( + definition_ids == [&"domestic_goat", &"domestic_sheep"], + "Animal definitions should iterate in deterministic stable-ID order" + ) + var goat_definition := catalog.get_definition(&"domestic_goat") + var sheep_definition := catalog.get_definition(&"domestic_sheep") + _check( + ( + goat_definition != null + and sheep_definition != null + and goat_definition.species_id == &"goat" + and sheep_definition.species_id == &"sheep" + ), + "Goat and sheep should be distinct data definitions" + ) + if goat_definition == null or sheep_definition == null: + _finish() + return + _check( + ( + 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 + ), + "Both animals should reuse one grazer behavior and presentation contract" + ) + _check( + ( + goat_definition.supports_action(&"feed_animal") + and sheep_definition.supports_action(&"feed_animal") + ), + "Both data definitions should advertise the existing care action" + ) + + var generic_scene := load(goat_definition.presentation_scene_path) as PackedScene + var generic := generic_scene.instantiate() as AnimalNode + _check( + ( + generic != null + and generic.animal_id.is_empty() + and generic.animal_definition_id.is_empty() + and generic.species_id.is_empty() + and generic.display_name == "Animal" + and (generic.get_node("DebugLabel") as Label3D).text == "Animal" + ), + "The reusable grazer scene should contain no Dunja-specific identity" + ) + if generic != null: + generic.free() + + var factory := AnimalFactory.new(catalog) + var goat := ( + factory + . create_presentation( + &"domestic_goat", + &"test_goat", + "Dunja Test", + { + "initial_hunger": 10.0, + "initial_routine_site_id": &"goat_barn", + "initial_next_routine_tick": 0, + "debug_label_enabled": false, + } + ) + ) + var sheep := ( + factory + . create_presentation( + &"domestic_sheep", + &"test_sheep", + "Mila Test", + { + "initial_hunger": 10.0, + "initial_routine_site_id": &"sheep_fold", + "initial_next_routine_tick": 0, + "debug_label_enabled": false, + } + ) + ) + _check(goat != null and sheep != null, "The shared factory should create both definitions") + if goat == null or sheep == null: + if goat != null: + goat.free() + if sheep != null: + sheep.free() + _finish() + return + root.add_child(goat) + root.add_child(sheep) + _check( + ( + goat.get_script() == sheep.get_script() + and goat.get_node("Visual").get_script() == sheep.get_node("Visual").get_script() + ), + "A second animal should add no movement or visual-controller system" + ) + _check( + ( + goat.get_node("Visual/HeadRoot/HornLeft").visible + and not sheep.get_node("Visual/HeadRoot/HornLeft").visible + and not sheep.get_node("Visual/HeadRoot/Beard").visible + ), + "Definition data should configure bounded presentation differences" + ) + + var routine_world := FakeRoutineWorld.new() + root.add_child(routine_world) + var care := AnimalCareSystem.new() + care.configure(null, routine_world) + _check(care.configure_animal_catalog(catalog), "Animal care should accept the isolated catalog") + var goat_registered := care.register_node(goat) + var sheep_registered := care.register_node(sheep) + _check( + goat_registered and sheep_registered, + "Existing AnimalCareSystem should bind both shared-contract nodes" + ) + var goat_state := care.get_state(&"test_goat") + var sheep_state := care.get_state(&"test_sheep") + _check( + ( + goat_state != null + and sheep_state != null + and goat_state.get_animal_definition_id() == &"domestic_goat" + and sheep_state.get_animal_definition_id() == &"domestic_sheep" + and goat_state.get_script() == sheep_state.get_script() + ), + "Authoritative records should retain stable animal definition IDs" + ) + if goat_state != null and sheep_state != null: + var restored_sheep := AnimalStateRecord.from_dictionary(sheep_state.to_dictionary()) + _check( + ( + restored_sheep != null + and restored_sheep.get_animal_definition_id() == &"domestic_sheep" + and restored_sheep.get_species_id() == &"sheep" + ), + "The second animal should round-trip through the existing animal record system" + ) + var started: Array[StringName] = [] + care.routine_started.connect( + func(animal_id: StringName, _site_id: StringName, _position: Vector3) -> void: + started.append(animal_id) + ) + care.advance(0) + started.sort_custom( + func(left: StringName, right: StringName) -> bool: return String(left) < String(right) + ) + _check( + ( + started == [&"test_goat", &"test_sheep"] + and goat_state.has_active_travel_target() + and sheep_state.has_active_travel_target() + ), + "Goat and sheep should execute the same deterministic routine scheduler" + ) + _test_nested_record_migration(goat_state) + + goat.free() + sheep.free() + routine_world.free() + _finish() + + +func _test_nested_record_migration(goat_state: AnimalStateRecord) -> void: + var previous_record := goat_state.to_dictionary() + previous_record["schema_version"] = AnimalStateRecord.ROUTINE_SCHEMA_VERSION + previous_record.erase("animal_definition_id") + var migrated := AnimalStateRecord.from_dictionary(previous_record) + _check( + ( + migrated != null + and migrated.get_animal_definition_id() == &"domestic_goat" + and migrated.to_dictionary()["schema_version"] == AnimalStateRecord.SCHEMA_VERSION + ), + "Nested v2 animal records should migrate to stable goat definition identity" + ) + var mismatched := goat_state.to_dictionary() + mismatched["animal_definition_id"] = "domestic_sheep" + var mismatched_record := AnimalStateRecord.from_dictionary(mismatched) + _check( + ( + mismatched_record != null + and not AnimalCatalog.create_core().matches_identity( + mismatched_record.get_animal_definition_id(), mismatched_record.get_species_id() + ) + ), + "Data-only restore should remain extensible while the catalog detects identity mismatches" + ) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Shared animal definition contract passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/animal_definition_contract_test.gd.uid b/tests/animal_definition_contract_test.gd.uid new file mode 100644 index 0000000..2ec721a --- /dev/null +++ b/tests/animal_definition_contract_test.gd.uid @@ -0,0 +1 @@ +uid://dad5kvxbeg72u diff --git a/world/animals/animal_node.gd b/world/animals/animal_node.gd index 057df4e..bc1b597 100644 --- a/world/animals/animal_node.gd +++ b/world/animals/animal_node.gd @@ -9,10 +9,13 @@ signal routine_arrived(animal_id: StringName, site_id: StringName, arrival_posit signal navigation_failed(animal_id: StringName, site_id: StringName) static var _all: Array[AnimalNode] = [] +static var _core_animal_catalog: AnimalCatalog @export var animal_id: StringName @export var display_name := "Animal" -@export var species_id: StringName = SimulationIds.SPECIES_GOAT +@export var animal_definition_id: StringName +@export var species_id: StringName +@export var supported_action_ids: Array[StringName] = [] @export_range(0.0, 100.0, 0.5) var initial_hunger := 70.0 @export var initial_enabled := true @export var can_npcs_feed := true @@ -35,9 +38,20 @@ var navigation_target := Vector3.INF var navigation_request_id := 0 var path_pending := false var has_reported_navigation_result := true +var _animal_definition: AnimalDefinition func _ready() -> void: + if not _ensure_animal_definition(): + push_error( + ( + "AnimalNode at %s has invalid animal_definition_id '%s'" + % [get_path(), animal_definition_id] + ) + ) + initial_enabled = false + _update_presentation() + return if animal_id.is_empty(): push_error("AnimalNode at %s has empty animal_id" % get_path()) initial_enabled = false @@ -69,7 +83,11 @@ func _exit_tree() -> void: func bind_state(animal_state: AnimalStateRecord) -> bool: - if animal_state == null or animal_state.get_animal_id() != animal_id: + if ( + animal_state == null + or animal_state.get_animal_id() != animal_id + or animal_state.get_animal_definition_id() != animal_definition_id + ): return false _disconnect_state() state = animal_state @@ -111,7 +129,42 @@ func get_interaction_position() -> Vector3: func supports_action(action_id: StringName) -> bool: - return action_id == SimulationIds.ACTION_FEED_ANIMAL + return action_id in supported_action_ids + + +func apply_animal_definition(definition: AnimalDefinition) -> 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: + return false + _animal_definition = definition + animal_definition_id = definition.animal_definition_id + species_id = definition.species_id + supported_action_ids = definition.supported_action_ids.duplicate() + move_speed = definition.movement_speed + 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 true + + +func get_animal_definition() -> AnimalDefinition: + return _animal_definition + + +func _ensure_animal_definition() -> bool: + if ( + _animal_definition != null + and _animal_definition.animal_definition_id == animal_definition_id + ): + return true + if animal_definition_id.is_empty(): + return false + if _core_animal_catalog == null: + _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)) func get_hunger() -> float: diff --git a/world/animals/animal_routine_site.gd b/world/animals/animal_routine_site.gd index 4c15467..5ffc718 100644 --- a/world/animals/animal_routine_site.gd +++ b/world/animals/animal_routine_site.gd @@ -6,7 +6,7 @@ static var _all: Array[AnimalRoutineSite] = [] @export var site_id: StringName @export var display_name := "Animal site" -@export var species_id: StringName = SimulationIds.SPECIES_GOAT +@export var species_id: StringName @export var resident_animal_id: StringName @export var debug_label_enabled := false diff --git a/world/animals/goat/cozy_goat.tscn b/world/animals/goat/cozy_goat.tscn index a98c8c8..31c1b08 100644 --- a/world/animals/goat/cozy_goat.tscn +++ b/world/animals/goat/cozy_goat.tscn @@ -1,316 +1,12 @@ -[gd_scene load_steps=24 format=3] +[gd_scene load_steps=2 format=3] -[ext_resource type="Script" path="res://world/animals/animal_node.gd" id="1_animal"] -[ext_resource type="Script" path="res://world/animals/goat/cozy_goat_visual.gd" id="2_visual"] +[ext_resource type="PackedScene" path="res://world/animals/grazer/cozy_grazer.tscn" id="1_grazer"] -[sub_resource type="StandardMaterial3D" id="Material_wool"] -albedo_color = Color(0.88, 0.82, 0.67, 1) -roughness = 0.96 - -[sub_resource type="StandardMaterial3D" id="Material_face"] -albedo_color = Color(0.58, 0.43, 0.29, 1) -roughness = 0.92 - -[sub_resource type="StandardMaterial3D" id="Material_dark"] -albedo_color = Color(0.13, 0.09, 0.065, 1) -roughness = 0.9 - -[sub_resource type="StandardMaterial3D" id="Material_horn"] -albedo_color = Color(0.78, 0.68, 0.5, 1) -roughness = 0.86 - -[sub_resource type="StandardMaterial3D" id="Material_collar"] -albedo_color = Color(0.12, 0.5, 0.5, 1) -roughness = 0.72 - -[sub_resource type="StandardMaterial3D" id="Material_hunger"] -transparency = 1 -shading_mode = 0 -albedo_color = Color(0.98, 0.62, 0.22, 0.9) -emission_enabled = true -emission = Color(0.92, 0.36, 0.08, 1) -emission_energy_multiplier = 0.32 - -[sub_resource type="StandardMaterial3D" id="Material_fed"] -transparency = 1 -shading_mode = 0 -albedo_color = Color(0.72, 0.92, 0.5, 0.92) -emission_enabled = true -emission = Color(0.45, 0.78, 0.28, 1) -emission_energy_multiplier = 0.38 - -[sub_resource type="CapsuleMesh" id="Mesh_body"] -material = SubResource("Material_wool") -radius = 0.48 -height = 1.55 -radial_segments = 12 -rings = 4 - -[sub_resource type="SphereMesh" id="Mesh_fluff"] -material = SubResource("Material_wool") -radius = 0.42 -height = 0.72 -radial_segments = 12 -rings = 6 - -[sub_resource type="CapsuleMesh" id="Mesh_leg"] -material = SubResource("Material_face") -radius = 0.105 -height = 0.62 -radial_segments = 8 -rings = 3 - -[sub_resource type="BoxMesh" id="Mesh_hoof"] -material = SubResource("Material_dark") -size = Vector3(0.2, 0.13, 0.28) - -[sub_resource type="SphereMesh" id="Mesh_head"] -material = SubResource("Material_face") -radius = 0.34 -height = 0.58 -radial_segments = 12 -rings = 6 - -[sub_resource type="SphereMesh" id="Mesh_snout"] -material = SubResource("Material_wool") -radius = 0.21 -height = 0.32 -radial_segments = 10 -rings = 5 - -[sub_resource type="SphereMesh" id="Mesh_ear"] -material = SubResource("Material_face") -radius = 0.24 -height = 0.42 -radial_segments = 10 -rings = 5 - -[sub_resource type="SphereMesh" id="Mesh_eye"] -material = SubResource("Material_dark") -radius = 0.045 -height = 0.075 -radial_segments = 8 -rings = 4 - -[sub_resource type="CylinderMesh" id="Mesh_horn"] -material = SubResource("Material_horn") -top_radius = 0.035 -bottom_radius = 0.09 -height = 0.42 -radial_segments = 8 - -[sub_resource type="CylinderMesh" id="Mesh_beard"] -material = SubResource("Material_wool") -top_radius = 0.015 -bottom_radius = 0.11 -height = 0.34 -radial_segments = 8 - -[sub_resource type="CylinderMesh" id="Mesh_tail"] -material = SubResource("Material_wool") -top_radius = 0.07 -bottom_radius = 0.12 -height = 0.42 -radial_segments = 8 - -[sub_resource type="TorusMesh" id="Mesh_collar"] -material = SubResource("Material_collar") -inner_radius = 0.27 -outer_radius = 0.32 -rings = 14 -ring_segments = 8 - -[sub_resource type="TorusMesh" id="Mesh_hunger_bowl"] -material = SubResource("Material_hunger") -inner_radius = 0.18 -outer_radius = 0.27 -rings = 14 -ring_segments = 8 - -[sub_resource type="SphereMesh" id="Mesh_hunger_food"] -material = SubResource("Material_hunger") -radius = 0.09 -height = 0.14 -radial_segments = 8 -rings = 4 - -[sub_resource type="TorusMesh" id="Mesh_fed_ring"] -material = SubResource("Material_fed") -inner_radius = 0.32 -outer_radius = 0.42 -rings = 16 -ring_segments = 8 - -[sub_resource type="SphereMesh" id="Mesh_fed_mote"] -material = SubResource("Material_fed") -radius = 0.075 -height = 0.13 -radial_segments = 8 -rings = 4 - -[node name="CozyGoat" type="Node3D"] -script = ExtResource("1_animal") +[node name="CozyGoat" instance=ExtResource("1_grazer")] +animal_definition_id = &"domestic_goat" animal_id = &"goat_dunja" display_name = "Dunja" species_id = &"goat" initial_hunger = 40.0 initial_routine_site_id = &"dunja_shelter" initial_next_routine_tick = 8 - -[node name="Visual" type="Node3D" parent="."] -script = ExtResource("2_visual") - -[node name="Body" type="MeshInstance3D" parent="Visual"] -position = Vector3(0, 0.95, 0) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("Mesh_body") - -[node name="FluffFront" type="MeshInstance3D" parent="Visual"] -position = Vector3(0, 1.02, 0.42) -scale = Vector3(1.08, 1.05, 1) -mesh = SubResource("Mesh_fluff") - -[node name="FluffBack" type="MeshInstance3D" parent="Visual"] -position = Vector3(0, 1.02, -0.42) -scale = Vector3(1.08, 1.05, 1) -mesh = SubResource("Mesh_fluff") - -[node name="LegFrontLeft" type="MeshInstance3D" parent="Visual"] -position = Vector3(-0.3, 0.43, 0.48) -mesh = SubResource("Mesh_leg") - -[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontLeft"] -position = Vector3(0, -0.29, 0.035) -mesh = SubResource("Mesh_hoof") - -[node name="LegFrontRight" type="MeshInstance3D" parent="Visual"] -position = Vector3(0.3, 0.43, 0.48) -mesh = SubResource("Mesh_leg") - -[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontRight"] -position = Vector3(0, -0.29, 0.035) -mesh = SubResource("Mesh_hoof") - -[node name="LegBackLeft" type="MeshInstance3D" parent="Visual"] -position = Vector3(-0.3, 0.43, -0.48) -mesh = SubResource("Mesh_leg") - -[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackLeft"] -position = Vector3(0, -0.29, 0.035) -mesh = SubResource("Mesh_hoof") - -[node name="LegBackRight" type="MeshInstance3D" parent="Visual"] -position = Vector3(0.3, 0.43, -0.48) -mesh = SubResource("Mesh_leg") - -[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackRight"] -position = Vector3(0, -0.29, 0.035) -mesh = SubResource("Mesh_hoof") - -[node name="HeadRoot" type="Node3D" parent="Visual"] -position = Vector3(0, 1.28, 0.88) -rotation_degrees = Vector3(-3, 0, 0) - -[node name="Head" type="MeshInstance3D" parent="Visual/HeadRoot"] -mesh = SubResource("Mesh_head") - -[node name="Snout" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(0, -0.08, 0.31) -scale = Vector3(1.05, 0.72, 0.9) -mesh = SubResource("Mesh_snout") - -[node name="EyeLeft" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(-0.18, 0.08, 0.285) -mesh = SubResource("Mesh_eye") - -[node name="EyeRight" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(0.18, 0.08, 0.285) -mesh = SubResource("Mesh_eye") - -[node name="EarLeft" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(-0.35, 0.13, 0) -rotation_degrees = Vector3(8, 0, 67) -scale = Vector3(0.82, 0.28, 1) -mesh = SubResource("Mesh_ear") - -[node name="EarRight" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(0.35, 0.13, 0) -rotation_degrees = Vector3(8, 0, -67) -scale = Vector3(0.82, 0.28, 1) -mesh = SubResource("Mesh_ear") - -[node name="HornLeft" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(-0.17, 0.34, -0.06) -rotation_degrees = Vector3(-24, 0, -20) -mesh = SubResource("Mesh_horn") - -[node name="HornRight" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(0.17, 0.34, -0.06) -rotation_degrees = Vector3(-24, 0, 20) -mesh = SubResource("Mesh_horn") - -[node name="Beard" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(0, -0.35, 0.08) -mesh = SubResource("Mesh_beard") - -[node name="Collar" type="MeshInstance3D" parent="Visual/HeadRoot"] -position = Vector3(0, -0.31, -0.16) -rotation_degrees = Vector3(90, 0, 0) -scale = Vector3(0.85, 0.85, 0.85) -mesh = SubResource("Mesh_collar") - -[node name="TailRoot" type="Node3D" parent="Visual"] -position = Vector3(0, 1.16, -0.92) -rotation_degrees = Vector3(60, 0, 0) - -[node name="Tail" type="MeshInstance3D" parent="Visual/TailRoot"] -position = Vector3(0, 0.18, 0) -mesh = SubResource("Mesh_tail") - -[node name="HungerCueRoot" type="Node3D" parent="Visual"] -position = Vector3(0, 2.05, 0.18) - -[node name="EmptyBowl" type="MeshInstance3D" parent="Visual/HungerCueRoot"] -rotation_degrees = Vector3(72, 0, 0) -scale = Vector3(1.1, 0.72, 1.1) -mesh = SubResource("Mesh_hunger_bowl") - -[node name="FoodMoteLeft" type="MeshInstance3D" parent="Visual/HungerCueRoot"] -position = Vector3(-0.13, 0.22, 0) -mesh = SubResource("Mesh_hunger_food") - -[node name="FoodMoteRight" type="MeshInstance3D" parent="Visual/HungerCueRoot"] -position = Vector3(0.13, 0.29, 0) -scale = Vector3(0.82, 0.82, 0.82) -mesh = SubResource("Mesh_hunger_food") - -[node name="FedResponseRoot" type="Node3D" parent="Visual"] -visible = false -position = Vector3(0, 1.8, 0.12) -scale = Vector3(0.2, 0.2, 0.2) - -[node name="Halo" type="MeshInstance3D" parent="Visual/FedResponseRoot"] -rotation_degrees = Vector3(74, 0, 0) -mesh = SubResource("Mesh_fed_ring") - -[node name="MoteLeft" type="MeshInstance3D" parent="Visual/FedResponseRoot"] -position = Vector3(-0.34, 0.18, 0) -mesh = SubResource("Mesh_fed_mote") - -[node name="MoteRight" type="MeshInstance3D" parent="Visual/FedResponseRoot"] -position = Vector3(0.34, 0.28, 0) -scale = Vector3(0.82, 0.82, 0.82) -mesh = SubResource("Mesh_fed_mote") - -[node name="InteractionPoint" type="Marker3D" parent="."] -position = Vector3(0, 0, 2.05) - -[node name="DebugLabel" type="Label3D" parent="."] -position = Vector3(0, 2.55, 0) -billboard = 1 -no_depth_test = true -font_size = 22 -outline_size = 5 -text = "Dunja" -modulate = Color(1, 0.94, 0.78, 1) -pixel_size = 0.006 diff --git a/world/animals/goat/cozy_goat_visual.gd b/world/animals/goat/cozy_goat_visual.gd index 3fa08ec..21a4f9d 100644 --- a/world/animals/goat/cozy_goat_visual.gd +++ b/world/animals/goat/cozy_goat_visual.gd @@ -1,90 +1 @@ -extends Node3D - -const HUNGER_CUE_BASE_Y := 2.05 -const FED_RESPONSE_BASE_Y := 1.8 - -@onready var head_root: Node3D = $HeadRoot -@onready var tail_root: Node3D = $TailRoot -@onready var hunger_cue_root: Node3D = $HungerCueRoot -@onready var fed_response_root: Node3D = $FedResponseRoot -@onready var leg_front_left: Node3D = $LegFrontLeft -@onready var leg_front_right: Node3D = $LegFrontRight -@onready var leg_back_left: Node3D = $LegBackLeft -@onready var leg_back_right: Node3D = $LegBackRight - -var idle_phase := 0.0 -var is_hungry := false -var is_moving := false -var fed_tween: Tween -var head_base_position: Vector3 -var visual_base_position: Vector3 - - -func _ready() -> void: - head_base_position = head_root.position - visual_base_position = position - reset_transient_feedback() - - -func _process(delta: float) -> void: - idle_phase = fmod(idle_phase + delta, TAU) - var breath := sin(idle_phase * 1.7) * 0.025 - var hungry_drop := -0.12 if is_hungry else 0.0 - var walking_bob := absf(sin(idle_phase * 5.4)) * 0.045 if is_moving else 0.0 - position.y = visual_base_position.y + walking_bob - head_root.position.y = head_base_position.y + breath + hungry_drop - head_root.rotation_degrees.x = ( - 8.0 + sin(idle_phase * 1.2) * 2.0 if is_hungry else -3.0 + sin(idle_phase * 1.4) * 2.5 - ) - tail_root.rotation_degrees.z = sin(idle_phase * 3.1) * (4.0 if is_hungry else 13.0) - var stride := sin(idle_phase * 5.4) * 14.0 if is_moving else 0.0 - leg_front_left.rotation_degrees.x = stride - leg_back_right.rotation_degrees.x = stride - leg_front_right.rotation_degrees.x = -stride - leg_back_left.rotation_degrees.x = -stride - if hunger_cue_root.visible: - hunger_cue_root.position.y = HUNGER_CUE_BASE_Y + sin(idle_phase * 2.2) * 0.07 - - -func set_hungry(value: bool) -> void: - is_hungry = value - hunger_cue_root.visible = value - - -func set_moving(value: bool) -> void: - is_moving = value - if not value: - position = visual_base_position - leg_front_left.rotation_degrees.x = 0.0 - leg_front_right.rotation_degrees.x = 0.0 - leg_back_left.rotation_degrees.x = 0.0 - leg_back_right.rotation_degrees.x = 0.0 - - -func reset_transient_feedback() -> void: - if fed_tween != null: - fed_tween.kill() - fed_tween = null - fed_response_root.visible = false - fed_response_root.position.y = FED_RESPONSE_BASE_Y - fed_response_root.scale = Vector3(0.2, 0.2, 0.2) - - -func play_fed_response() -> void: - reset_transient_feedback() - fed_response_root.visible = true - fed_tween = create_tween() - fed_tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) - fed_tween.tween_property(fed_response_root, "scale", Vector3.ONE, 0.34) - fed_tween.parallel().tween_property( - fed_response_root, "position:y", FED_RESPONSE_BASE_Y + 0.22, 0.34 - ) - fed_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) - fed_tween.tween_interval(0.7) - fed_tween.tween_property(fed_response_root, "scale", Vector3(0.2, 0.2, 0.2), 0.42) - fed_tween.finished.connect(_on_fed_response_finished) - - -func _on_fed_response_finished() -> void: - fed_response_root.visible = false - fed_tween = null +extends "res://world/animals/grazer/cozy_grazer_visual.gd" diff --git a/world/animals/grazer/cozy_grazer.tscn b/world/animals/grazer/cozy_grazer.tscn new file mode 100644 index 0000000..9363d3f --- /dev/null +++ b/world/animals/grazer/cozy_grazer.tscn @@ -0,0 +1,311 @@ +[gd_scene load_steps=24 format=3] + +[ext_resource type="Script" path="res://world/animals/animal_node.gd" id="1_animal"] +[ext_resource type="Script" path="res://world/animals/grazer/cozy_grazer_visual.gd" id="2_visual"] + +[sub_resource type="StandardMaterial3D" id="Material_wool"] +albedo_color = Color(0.88, 0.82, 0.67, 1) +roughness = 0.96 + +[sub_resource type="StandardMaterial3D" id="Material_face"] +albedo_color = Color(0.58, 0.43, 0.29, 1) +roughness = 0.92 + +[sub_resource type="StandardMaterial3D" id="Material_dark"] +albedo_color = Color(0.13, 0.09, 0.065, 1) +roughness = 0.9 + +[sub_resource type="StandardMaterial3D" id="Material_horn"] +albedo_color = Color(0.78, 0.68, 0.5, 1) +roughness = 0.86 + +[sub_resource type="StandardMaterial3D" id="Material_collar"] +albedo_color = Color(0.12, 0.5, 0.5, 1) +roughness = 0.72 + +[sub_resource type="StandardMaterial3D" id="Material_hunger"] +transparency = 1 +shading_mode = 0 +albedo_color = Color(0.98, 0.62, 0.22, 0.9) +emission_enabled = true +emission = Color(0.92, 0.36, 0.08, 1) +emission_energy_multiplier = 0.32 + +[sub_resource type="StandardMaterial3D" id="Material_fed"] +transparency = 1 +shading_mode = 0 +albedo_color = Color(0.72, 0.92, 0.5, 0.92) +emission_enabled = true +emission = Color(0.45, 0.78, 0.28, 1) +emission_energy_multiplier = 0.38 + +[sub_resource type="CapsuleMesh" id="Mesh_body"] +material = SubResource("Material_wool") +radius = 0.48 +height = 1.55 +radial_segments = 12 +rings = 4 + +[sub_resource type="SphereMesh" id="Mesh_fluff"] +material = SubResource("Material_wool") +radius = 0.42 +height = 0.72 +radial_segments = 12 +rings = 6 + +[sub_resource type="CapsuleMesh" id="Mesh_leg"] +material = SubResource("Material_face") +radius = 0.105 +height = 0.62 +radial_segments = 8 +rings = 3 + +[sub_resource type="BoxMesh" id="Mesh_hoof"] +material = SubResource("Material_dark") +size = Vector3(0.2, 0.13, 0.28) + +[sub_resource type="SphereMesh" id="Mesh_head"] +material = SubResource("Material_face") +radius = 0.34 +height = 0.58 +radial_segments = 12 +rings = 6 + +[sub_resource type="SphereMesh" id="Mesh_snout"] +material = SubResource("Material_wool") +radius = 0.21 +height = 0.32 +radial_segments = 10 +rings = 5 + +[sub_resource type="SphereMesh" id="Mesh_ear"] +material = SubResource("Material_face") +radius = 0.24 +height = 0.42 +radial_segments = 10 +rings = 5 + +[sub_resource type="SphereMesh" id="Mesh_eye"] +material = SubResource("Material_dark") +radius = 0.045 +height = 0.075 +radial_segments = 8 +rings = 4 + +[sub_resource type="CylinderMesh" id="Mesh_horn"] +material = SubResource("Material_horn") +top_radius = 0.035 +bottom_radius = 0.09 +height = 0.42 +radial_segments = 8 + +[sub_resource type="CylinderMesh" id="Mesh_beard"] +material = SubResource("Material_wool") +top_radius = 0.015 +bottom_radius = 0.11 +height = 0.34 +radial_segments = 8 + +[sub_resource type="CylinderMesh" id="Mesh_tail"] +material = SubResource("Material_wool") +top_radius = 0.07 +bottom_radius = 0.12 +height = 0.42 +radial_segments = 8 + +[sub_resource type="TorusMesh" id="Mesh_collar"] +material = SubResource("Material_collar") +inner_radius = 0.27 +outer_radius = 0.32 +rings = 14 +ring_segments = 8 + +[sub_resource type="TorusMesh" id="Mesh_hunger_bowl"] +material = SubResource("Material_hunger") +inner_radius = 0.18 +outer_radius = 0.27 +rings = 14 +ring_segments = 8 + +[sub_resource type="SphereMesh" id="Mesh_hunger_food"] +material = SubResource("Material_hunger") +radius = 0.09 +height = 0.14 +radial_segments = 8 +rings = 4 + +[sub_resource type="TorusMesh" id="Mesh_fed_ring"] +material = SubResource("Material_fed") +inner_radius = 0.32 +outer_radius = 0.42 +rings = 16 +ring_segments = 8 + +[sub_resource type="SphereMesh" id="Mesh_fed_mote"] +material = SubResource("Material_fed") +radius = 0.075 +height = 0.13 +radial_segments = 8 +rings = 4 + +[node name="CozyGrazer" type="Node3D"] +script = ExtResource("1_animal") +display_name = "Animal" + +[node name="Visual" type="Node3D" parent="."] +script = ExtResource("2_visual") + +[node name="Body" type="MeshInstance3D" parent="Visual"] +position = Vector3(0, 0.95, 0) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("Mesh_body") + +[node name="FluffFront" type="MeshInstance3D" parent="Visual"] +position = Vector3(0, 1.02, 0.42) +scale = Vector3(1.08, 1.05, 1) +mesh = SubResource("Mesh_fluff") + +[node name="FluffBack" type="MeshInstance3D" parent="Visual"] +position = Vector3(0, 1.02, -0.42) +scale = Vector3(1.08, 1.05, 1) +mesh = SubResource("Mesh_fluff") + +[node name="LegFrontLeft" type="MeshInstance3D" parent="Visual"] +position = Vector3(-0.3, 0.43, 0.48) +mesh = SubResource("Mesh_leg") + +[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontLeft"] +position = Vector3(0, -0.29, 0.035) +mesh = SubResource("Mesh_hoof") + +[node name="LegFrontRight" type="MeshInstance3D" parent="Visual"] +position = Vector3(0.3, 0.43, 0.48) +mesh = SubResource("Mesh_leg") + +[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontRight"] +position = Vector3(0, -0.29, 0.035) +mesh = SubResource("Mesh_hoof") + +[node name="LegBackLeft" type="MeshInstance3D" parent="Visual"] +position = Vector3(-0.3, 0.43, -0.48) +mesh = SubResource("Mesh_leg") + +[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackLeft"] +position = Vector3(0, -0.29, 0.035) +mesh = SubResource("Mesh_hoof") + +[node name="LegBackRight" type="MeshInstance3D" parent="Visual"] +position = Vector3(0.3, 0.43, -0.48) +mesh = SubResource("Mesh_leg") + +[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackRight"] +position = Vector3(0, -0.29, 0.035) +mesh = SubResource("Mesh_hoof") + +[node name="HeadRoot" type="Node3D" parent="Visual"] +position = Vector3(0, 1.28, 0.88) +rotation_degrees = Vector3(-3, 0, 0) + +[node name="Head" type="MeshInstance3D" parent="Visual/HeadRoot"] +mesh = SubResource("Mesh_head") + +[node name="Snout" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(0, -0.08, 0.31) +scale = Vector3(1.05, 0.72, 0.9) +mesh = SubResource("Mesh_snout") + +[node name="EyeLeft" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(-0.18, 0.08, 0.285) +mesh = SubResource("Mesh_eye") + +[node name="EyeRight" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(0.18, 0.08, 0.285) +mesh = SubResource("Mesh_eye") + +[node name="EarLeft" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(-0.35, 0.13, 0) +rotation_degrees = Vector3(8, 0, 67) +scale = Vector3(0.82, 0.28, 1) +mesh = SubResource("Mesh_ear") + +[node name="EarRight" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(0.35, 0.13, 0) +rotation_degrees = Vector3(8, 0, -67) +scale = Vector3(0.82, 0.28, 1) +mesh = SubResource("Mesh_ear") + +[node name="HornLeft" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(-0.17, 0.34, -0.06) +rotation_degrees = Vector3(-24, 0, -20) +mesh = SubResource("Mesh_horn") + +[node name="HornRight" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(0.17, 0.34, -0.06) +rotation_degrees = Vector3(-24, 0, 20) +mesh = SubResource("Mesh_horn") + +[node name="Beard" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(0, -0.35, 0.08) +mesh = SubResource("Mesh_beard") + +[node name="Collar" type="MeshInstance3D" parent="Visual/HeadRoot"] +position = Vector3(0, -0.31, -0.16) +rotation_degrees = Vector3(90, 0, 0) +scale = Vector3(0.85, 0.85, 0.85) +mesh = SubResource("Mesh_collar") + +[node name="TailRoot" type="Node3D" parent="Visual"] +position = Vector3(0, 1.16, -0.92) +rotation_degrees = Vector3(60, 0, 0) + +[node name="Tail" type="MeshInstance3D" parent="Visual/TailRoot"] +position = Vector3(0, 0.18, 0) +mesh = SubResource("Mesh_tail") + +[node name="HungerCueRoot" type="Node3D" parent="Visual"] +position = Vector3(0, 2.05, 0.18) + +[node name="EmptyBowl" type="MeshInstance3D" parent="Visual/HungerCueRoot"] +rotation_degrees = Vector3(72, 0, 0) +scale = Vector3(1.1, 0.72, 1.1) +mesh = SubResource("Mesh_hunger_bowl") + +[node name="FoodMoteLeft" type="MeshInstance3D" parent="Visual/HungerCueRoot"] +position = Vector3(-0.13, 0.22, 0) +mesh = SubResource("Mesh_hunger_food") + +[node name="FoodMoteRight" type="MeshInstance3D" parent="Visual/HungerCueRoot"] +position = Vector3(0.13, 0.29, 0) +scale = Vector3(0.82, 0.82, 0.82) +mesh = SubResource("Mesh_hunger_food") + +[node name="FedResponseRoot" type="Node3D" parent="Visual"] +visible = false +position = Vector3(0, 1.8, 0.12) +scale = Vector3(0.2, 0.2, 0.2) + +[node name="Halo" type="MeshInstance3D" parent="Visual/FedResponseRoot"] +rotation_degrees = Vector3(74, 0, 0) +mesh = SubResource("Mesh_fed_ring") + +[node name="MoteLeft" type="MeshInstance3D" parent="Visual/FedResponseRoot"] +position = Vector3(-0.34, 0.18, 0) +mesh = SubResource("Mesh_fed_mote") + +[node name="MoteRight" type="MeshInstance3D" parent="Visual/FedResponseRoot"] +position = Vector3(0.34, 0.28, 0) +scale = Vector3(0.82, 0.82, 0.82) +mesh = SubResource("Mesh_fed_mote") + +[node name="InteractionPoint" type="Marker3D" parent="."] +position = Vector3(0, 0, 2.05) + +[node name="DebugLabel" type="Label3D" parent="."] +position = Vector3(0, 2.55, 0) +billboard = 1 +no_depth_test = true +font_size = 22 +outline_size = 5 +text = "Animal" +modulate = Color(1, 0.94, 0.78, 1) +pixel_size = 0.006 diff --git a/world/animals/grazer/cozy_grazer_visual.gd b/world/animals/grazer/cozy_grazer_visual.gd new file mode 100644 index 0000000..e89d192 --- /dev/null +++ b/world/animals/grazer/cozy_grazer_visual.gd @@ -0,0 +1,125 @@ +extends Node3D + +const HUNGER_CUE_BASE_Y := 2.05 +const FED_RESPONSE_BASE_Y := 1.8 + +@onready var head_root: Node3D = $HeadRoot +@onready var tail_root: Node3D = $TailRoot +@onready var hunger_cue_root: Node3D = $HungerCueRoot +@onready var fed_response_root: Node3D = $FedResponseRoot +@onready var leg_front_left: Node3D = $LegFrontLeft +@onready var leg_front_right: Node3D = $LegFrontRight +@onready var leg_back_left: Node3D = $LegBackLeft +@onready var leg_back_right: Node3D = $LegBackRight +@onready var body: Node3D = $Body +@onready var fluff_front: Node3D = $FluffFront +@onready var fluff_back: Node3D = $FluffBack +@onready var horn_left: Node3D = $HeadRoot/HornLeft +@onready var horn_right: Node3D = $HeadRoot/HornRight +@onready var beard: Node3D = $HeadRoot/Beard + +var idle_phase := 0.0 +var is_hungry := false +var is_moving := false +var fed_tween: Tween +var head_base_position: Vector3 +var visual_base_position: Vector3 +var body_base_scale: Vector3 +var fluff_front_base_scale: Vector3 +var fluff_back_base_scale: Vector3 +var _animal_definition: AnimalDefinition + + +func _ready() -> void: + head_base_position = head_root.position + visual_base_position = position + body_base_scale = body.scale + fluff_front_base_scale = fluff_front.scale + fluff_back_base_scale = fluff_back.scale + _apply_definition_presentation() + reset_transient_feedback() + + +func apply_animal_definition(definition: AnimalDefinition) -> bool: + if definition == null or not definition.validate().is_empty(): + return false + _animal_definition = definition + if is_node_ready(): + _apply_definition_presentation() + return true + + +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 + + +func _process(delta: float) -> void: + idle_phase = fmod(idle_phase + delta, TAU) + var breath := sin(idle_phase * 1.7) * 0.025 + var hungry_drop := -0.12 if is_hungry else 0.0 + var walking_bob := absf(sin(idle_phase * 5.4)) * 0.045 if is_moving else 0.0 + position.y = visual_base_position.y + walking_bob + head_root.position.y = head_base_position.y + breath + hungry_drop + head_root.rotation_degrees.x = ( + 8.0 + sin(idle_phase * 1.2) * 2.0 if is_hungry else -3.0 + sin(idle_phase * 1.4) * 2.5 + ) + tail_root.rotation_degrees.z = sin(idle_phase * 3.1) * (4.0 if is_hungry else 13.0) + var stride := sin(idle_phase * 5.4) * 14.0 if is_moving else 0.0 + leg_front_left.rotation_degrees.x = stride + leg_back_right.rotation_degrees.x = stride + leg_front_right.rotation_degrees.x = -stride + leg_back_left.rotation_degrees.x = -stride + if hunger_cue_root.visible: + hunger_cue_root.position.y = HUNGER_CUE_BASE_Y + sin(idle_phase * 2.2) * 0.07 + + +func set_hungry(value: bool) -> void: + is_hungry = value + hunger_cue_root.visible = value + + +func set_moving(value: bool) -> void: + is_moving = value + if not value: + position = visual_base_position + leg_front_left.rotation_degrees.x = 0.0 + leg_front_right.rotation_degrees.x = 0.0 + leg_back_left.rotation_degrees.x = 0.0 + leg_back_right.rotation_degrees.x = 0.0 + + +func reset_transient_feedback() -> void: + if fed_tween != null: + fed_tween.kill() + fed_tween = null + fed_response_root.visible = false + fed_response_root.position.y = FED_RESPONSE_BASE_Y + fed_response_root.scale = Vector3(0.2, 0.2, 0.2) + + +func play_fed_response() -> void: + reset_transient_feedback() + fed_response_root.visible = true + fed_tween = create_tween() + fed_tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + fed_tween.tween_property(fed_response_root, "scale", Vector3.ONE, 0.34) + fed_tween.parallel().tween_property( + fed_response_root, "position:y", FED_RESPONSE_BASE_Y + 0.22, 0.34 + ) + fed_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) + fed_tween.tween_interval(0.7) + fed_tween.tween_property(fed_response_root, "scale", Vector3(0.2, 0.2, 0.2), 0.42) + fed_tween.finished.connect(_on_fed_response_finished) + + +func _on_fed_response_finished() -> void: + fed_response_root.visible = false + fed_tween = null diff --git a/world/animals/grazer/cozy_grazer_visual.gd.uid b/world/animals/grazer/cozy_grazer_visual.gd.uid new file mode 100644 index 0000000..e664f71 --- /dev/null +++ b/world/animals/grazer/cozy_grazer_visual.gd.uid @@ -0,0 +1 @@ +uid://ckytaadvxm4sh