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) static var _all: Array[AnimalNode] = [] @export var animal_id: StringName @export var display_name := "Animal" @export var species_id: StringName = SimulationIds.SPECIES_GOAT @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 @onready var interaction_point: Marker3D = get_node_or_null("InteractionPoint") as Marker3D var state: AnimalStateRecord func _ready() -> void: 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") _try_register_with_simulation() _update_presentation() func _exit_tree() -> void: _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: 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() _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 == SimulationIds.ACTION_FEED_ANIMAL 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 _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()) _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 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: text += "\nUNBOUND" label.text = text func set_debug_label_enabled(is_enabled: bool) -> void: debug_label_enabled = is_enabled _update_presentation() 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()