371 lines
11 KiB
GDScript
371 lines
11 KiB
GDScript
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()
|