class_name AnimalNode extends Node3D signal hunger_changed(animal_id: StringName, hunger: float) signal fed(animal_id: StringName, actor_id: int) signal reservation_changed(animal_id: StringName, agent_id: int) signal position_changed(animal_id: StringName, active_position: Vector3) signal routine_arrived(animal_id: StringName, site_id: StringName, arrival_position: Vector3) 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 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 @export var can_player_feed := true @export var debug_label_enabled := true @export var initial_routine_site_id: StringName @export_range(0, 10000, 1) var initial_next_routine_tick := 12 @export_range(0.1, 10.0, 0.1) var move_speed := 1.6 @export_range(0.05, 1.0, 0.05) var waypoint_reached_distance := 0.25 @export_range(0.05, 1.0, 0.05) var arrival_distance := 0.3 @export_range(0.1, 20.0, 0.1) var rotation_speed := 7.0 @onready var interaction_point: Marker3D = get_node_or_null("InteractionPoint") as Marker3D var state: AnimalStateRecord var current_path := PackedVector3Array() var path_index := 0 var navigation_site_id: StringName 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 _update_presentation() return var existing := get_by_id(animal_id) if existing != null: push_error( ( "Duplicate AnimalNode animal_id '%s' at %s; first registered at %s" % [animal_id, get_path(), existing.get_path()] ) ) initial_enabled = false _update_presentation() return _all.append(self) add_to_group("animal_nodes") add_to_group("grass_interactors") _try_register_with_simulation() _update_presentation() func _exit_tree() -> void: navigation_request_id += 1 _all.erase(self) _disconnect_state() state = null func bind_state(animal_state: AnimalStateRecord) -> bool: 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 if not state.changed.is_connected(_on_state_changed): state.changed.connect(_on_state_changed) if not state.fed.is_connected(_on_state_fed): state.fed.connect(_on_state_fed) global_position = state.get_position() var visual := get_node_or_null("Visual") if visual != null and visual.has_method("reset_transient_feedback"): visual.reset_transient_feedback() _stop_navigation() call_deferred("_sync_navigation_from_state") _update_presentation() hunger_changed.emit(animal_id, state.get_hunger()) return true func _disconnect_state() -> void: if state == null: return if state.changed.is_connected(_on_state_changed): state.changed.disconnect(_on_state_changed) if state.fed.is_connected(_on_state_fed): state.fed.disconnect(_on_state_fed) func _try_register_with_simulation() -> void: var managers := get_tree().get_nodes_in_group("simulation_manager") if managers.size() != 1: return var manager := managers[0] if "animal_care" in manager and manager.animal_care != null: manager.animal_care.register_node(self) func get_interaction_position() -> Vector3: return interaction_point.global_position if interaction_point != null else global_position func supports_action(action_id: StringName) -> bool: 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: return state.get_hunger() if state != null else initial_hunger func needs_feed() -> bool: if state != null: return state.needs_feed() return initial_enabled and initial_hunger >= AnimalStateRecord.FEED_THRESHOLD func is_enabled() -> bool: return state.is_enabled() if state != null else initial_enabled func is_navigating() -> bool: return not navigation_site_id.is_empty() and not has_reported_navigation_result func get_navigation_path() -> PackedVector3Array: return current_path.duplicate() func _physics_process(delta: float) -> void: if ( state == null or not state.has_active_travel_target() or navigation_site_id.is_empty() or has_reported_navigation_result ): return if path_pending: return if _horizontal_distance_to(navigation_target) <= arrival_distance: _report_arrival_once() return if current_path.is_empty() or path_index >= current_path.size(): _report_navigation_failure_once() return var next_position := current_path[path_index] var offset := next_position - global_position if offset.length() <= waypoint_reached_distance: path_index += 1 return var direction := offset.normalized() global_position = global_position.move_toward(next_position, move_speed * delta) position_changed.emit(animal_id, global_position) var target_angle := atan2(direction.x, direction.z) rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta) func _on_state_changed(changed_state: AnimalStateRecord) -> void: if changed_state != state: return if not global_position.is_equal_approx(state.get_position()): global_position = state.get_position() hunger_changed.emit(animal_id, state.get_hunger()) reservation_changed.emit(animal_id, state.get_reserved_by()) _sync_navigation_from_state() _update_presentation() func _on_state_fed(fed_state: AnimalStateRecord, actor_id: int) -> void: if fed_state != state: return var visual := get_node_or_null("Visual") if visual != null and visual.has_method("play_fed_response"): visual.play_fed_response() fed.emit(animal_id, actor_id) func _update_presentation() -> void: var visual := get_node_or_null("Visual") if visual != null: visual.visible = is_enabled() if visual.has_method("set_hungry"): visual.set_hungry(needs_feed()) if visual.has_method("set_moving"): visual.set_moving(is_navigating()) if not has_node("DebugLabel"): return var label := $DebugLabel as Label3D label.visible = debug_label_enabled if not debug_label_enabled: return var text := "%s\n%s · hunger %.0f" % [display_name, animal_id, get_hunger()] if needs_feed(): text += "\nHUNGRY" if state != null and state.get_reserved_by() >= 0: text += "\nheld:%d" % state.get_reserved_by() if state != null and state.has_active_travel_target(): text += "\n→ %s" % state.get_travel_target_site_id() elif state != null and not state.get_routine_site_id().is_empty(): text += "\n@ %s" % state.get_routine_site_id() if state == null: text += "\nUNBOUND" label.text = text func set_debug_label_enabled(is_enabled: bool) -> void: debug_label_enabled = is_enabled _update_presentation() func _sync_navigation_from_state() -> void: if state == null or not state.has_active_travel_target(): if not navigation_site_id.is_empty(): _stop_navigation() return var target_site_id := state.get_travel_target_site_id() var target_position := state.get_travel_target_position() if ( navigation_site_id == target_site_id and navigation_target.is_equal_approx(target_position) and not has_reported_navigation_result ): return _request_navigation(target_site_id, target_position) func _request_navigation(site_id: StringName, target_position: Vector3) -> void: navigation_request_id += 1 var request_id := navigation_request_id navigation_site_id = site_id navigation_target = target_position current_path = PackedVector3Array() path_index = 0 path_pending = true has_reported_navigation_result = false _update_presentation() await get_tree().physics_frame if request_id != navigation_request_id or state == null: return current_path = NavigationServer3D.map_get_path( get_world_3d().navigation_map, global_position, target_position, true ) path_pending = false if current_path.is_empty(): _report_navigation_failure_once() func _stop_navigation() -> void: navigation_request_id += 1 current_path = PackedVector3Array() path_index = 0 navigation_site_id = &"" navigation_target = Vector3.INF path_pending = false has_reported_navigation_result = true var visual := get_node_or_null("Visual") if visual != null and visual.has_method("set_moving"): visual.set_moving(false) func _report_arrival_once() -> void: if has_reported_navigation_result: return has_reported_navigation_result = true global_position = navigation_target position_changed.emit(animal_id, global_position) var arrived_site_id := navigation_site_id current_path = PackedVector3Array() path_index = 0 path_pending = false var visual := get_node_or_null("Visual") if visual != null and visual.has_method("set_moving"): visual.set_moving(false) routine_arrived.emit(animal_id, arrived_site_id, global_position) func _report_navigation_failure_once() -> void: if has_reported_navigation_result: return has_reported_navigation_result = true var failed_site_id := navigation_site_id current_path = PackedVector3Array() path_index = 0 path_pending = false var visual := get_node_or_null("Visual") if visual != null and visual.has_method("set_moving"): visual.set_moving(false) navigation_failed.emit(animal_id, failed_site_id) func _horizontal_distance_to(target_position: Vector3) -> float: return Vector2(global_position.x, global_position.z).distance_to( Vector2(target_position.x, target_position.z) ) static func get_by_id(search_id: StringName) -> AnimalNode: for node in _all: if node.animal_id == search_id: return node return null static func get_all() -> Array[AnimalNode]: return _all.duplicate()