403 lines
13 KiB
GDScript
403 lines
13 KiB
GDScript
class_name AnimalCareSystem
|
|
extends RefCounted
|
|
|
|
signal economic_event_requested(
|
|
event_type: StringName,
|
|
actor_id: int,
|
|
source_id: StringName,
|
|
destination_id: StringName,
|
|
item_id: StringName,
|
|
amount: float,
|
|
world_position: Vector3
|
|
)
|
|
signal narrative_event_requested(
|
|
event_type: StringName,
|
|
actor_id: int,
|
|
source_id: StringName,
|
|
action_display: String,
|
|
action_id: StringName,
|
|
item_id: StringName,
|
|
required_amount: float
|
|
)
|
|
signal routine_started(animal_id: StringName, site_id: StringName, target_position: Vector3)
|
|
signal routine_completed(animal_id: StringName, site_id: StringName, position: Vector3)
|
|
|
|
const ROUTINE_STAY_TICKS := 40
|
|
const ROUTINE_RETRY_TICKS := 10
|
|
|
|
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:
|
|
economy = economy_service
|
|
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()
|
|
animal_ids.sort()
|
|
for animal_id in animal_ids:
|
|
var animal_state := states[animal_id] as AnimalStateRecord
|
|
if animal_state == null:
|
|
continue
|
|
animal_state.advance_tick()
|
|
if animal_state.has_active_travel_target() and animal_state.needs_feed():
|
|
animal_state.cancel_travel(current_tick + ROUTINE_RETRY_TICKS)
|
|
elif animal_state.is_routine_due(current_tick):
|
|
_begin_due_routine(animal_state)
|
|
|
|
|
|
func register_loaded_nodes() -> void:
|
|
for node in AnimalNode.get_all():
|
|
register_node(node)
|
|
|
|
|
|
func register_node(node: AnimalNode) -> bool:
|
|
if node == null or node.animal_id.is_empty():
|
|
return false
|
|
if (
|
|
ResourceNode.get_by_id(node.animal_id) != null
|
|
or StorageNode.get_by_id(node.animal_id) != null
|
|
or ActivitySite.get_by_id(node.animal_id) != null
|
|
):
|
|
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 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 invalid definition '%s' or species '%s'"
|
|
% [node.animal_id, node.animal_definition_id, node.species_id]
|
|
)
|
|
)
|
|
return false
|
|
var animal_state := get_state(node.animal_id)
|
|
if animal_state == null:
|
|
animal_state = AnimalStateRecord.create_from_node(node)
|
|
states[node.animal_id] = animal_state
|
|
elif not animal_state.apply_definition(node):
|
|
push_error("AnimalCareSystem: AnimalNode definition mismatch for '%s'" % node.animal_id)
|
|
return false
|
|
if not node.position_changed.is_connected(_on_node_position_changed):
|
|
node.position_changed.connect(_on_node_position_changed)
|
|
if not node.routine_arrived.is_connected(_on_node_routine_arrived):
|
|
node.routine_arrived.connect(_on_node_routine_arrived)
|
|
if not node.navigation_failed.is_connected(_on_node_navigation_failed):
|
|
node.navigation_failed.connect(_on_node_navigation_failed)
|
|
return node.bind_state(animal_state)
|
|
|
|
|
|
func get_state(animal_id: StringName) -> AnimalStateRecord:
|
|
return states.get(animal_id) as AnimalStateRecord
|
|
|
|
|
|
func reserve(animal_id: StringName, agent_id: int) -> bool:
|
|
var animal_state := get_state(animal_id)
|
|
return animal_state != null and animal_state.can_npc_feed() and animal_state.reserve(agent_id)
|
|
|
|
|
|
func release(animal_id: StringName, agent_id: int) -> void:
|
|
var animal_state := get_state(animal_id)
|
|
if animal_state != null:
|
|
animal_state.release(agent_id)
|
|
|
|
|
|
func can_complete_feed(animal_id: StringName, agent_id: int) -> bool:
|
|
var animal_state := get_state(animal_id)
|
|
return (
|
|
animal_state != null
|
|
and animal_state.needs_feed()
|
|
and animal_state.get_reserved_by() == agent_id
|
|
)
|
|
|
|
|
|
func requires_feed_pickup(npc: SimNPC) -> bool:
|
|
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
|
return (
|
|
npc != null
|
|
and definition != null
|
|
and definition.has_completion_cost()
|
|
and (
|
|
npc.get_inventory_amount(definition.completion_cost_resource_id)
|
|
< definition.completion_cost_amount
|
|
)
|
|
)
|
|
|
|
|
|
func requires_working_feed_recovery(npc: SimNPC) -> bool:
|
|
return (
|
|
npc != null
|
|
and npc.current_task == SimulationIds.ACTION_FEED_ANIMAL
|
|
and npc.task_state == SimNPC.TASK_STATE_WORKING
|
|
and requires_feed_pickup(npc)
|
|
and can_complete_feed(npc.target_id, npc.id)
|
|
)
|
|
|
|
|
|
func collect_feed_supply(npc: SimNPC) -> bool:
|
|
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
|
if (
|
|
npc == null
|
|
or npc.current_task != SimulationIds.ACTION_FEED_ANIMAL
|
|
or npc.target_id.is_empty()
|
|
or not can_complete_feed(npc.target_id, npc.id)
|
|
or definition == null
|
|
or not definition.has_completion_cost()
|
|
or economy == null
|
|
):
|
|
return false
|
|
var resource_id := definition.completion_cost_resource_id
|
|
var required_amount := definition.completion_cost_amount
|
|
var carried_amount := npc.get_inventory_amount(resource_id)
|
|
var pickup_amount := maxf(required_amount - carried_amount, 0.0)
|
|
if pickup_amount <= 0.0:
|
|
return true
|
|
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
|
|
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
|
|
if pantry == null or available < pickup_amount:
|
|
_emit_feed_blocked(
|
|
npc.id, pantry.get_storage_id() if pantry != null else &"", definition, available
|
|
)
|
|
return false
|
|
var withdrawn: float = economy.withdraw_exact_to_inventory(npc, resource_id, pickup_amount)
|
|
if not is_equal_approx(withdrawn, pickup_amount):
|
|
_emit_feed_blocked(npc.id, pantry.get_storage_id(), definition, available)
|
|
return false
|
|
return true
|
|
|
|
|
|
func has_available_feed_target(npc_id: int) -> bool:
|
|
if active_world_adapter == null or not active_world_adapter.has_method("get_animal_candidates"):
|
|
return false
|
|
var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(
|
|
SimulationIds.ACTION_FEED_ANIMAL
|
|
)
|
|
for candidate in candidates:
|
|
var animal_state := get_state(StringName(candidate["target_id"]))
|
|
if (
|
|
animal_state != null
|
|
and animal_state.can_npc_feed()
|
|
and animal_state.is_available_for(npc_id)
|
|
):
|
|
return true
|
|
return false
|
|
|
|
|
|
func find_node_for_player(from_position: Vector3, max_distance: float) -> AnimalNode:
|
|
if (
|
|
active_world_adapter == null
|
|
or not active_world_adapter.has_method("get_animal_nodes_in_radius")
|
|
):
|
|
return null
|
|
var candidates: Array[AnimalNode] = active_world_adapter.get_animal_nodes_in_radius(
|
|
from_position, max_distance
|
|
)
|
|
for node in candidates:
|
|
var animal_state := get_state(node.animal_id)
|
|
if (
|
|
animal_state != null
|
|
and animal_state.can_player_feed_animal()
|
|
and animal_state.needs_feed()
|
|
):
|
|
return node
|
|
return null
|
|
|
|
|
|
func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tick: int) -> bool:
|
|
var animal_state := get_state(animal_id)
|
|
if animal_state == null or not animal_state.needs_feed() or current_tick < 0:
|
|
return false
|
|
var actor_npc: SimNPC
|
|
if actor_id >= 0:
|
|
actor_npc = _find_npc(actor_id, npcs)
|
|
if (
|
|
actor_npc == null
|
|
or not animal_state.can_npc_feed()
|
|
or animal_state.get_reserved_by() != actor_id
|
|
):
|
|
return false
|
|
elif not animal_state.can_player_feed_animal():
|
|
return false
|
|
|
|
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
|
if definition == null or not definition.has_completion_cost() or economy == null:
|
|
return false
|
|
var resource_id := definition.completion_cost_resource_id
|
|
var required_amount := definition.completion_cost_amount
|
|
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
|
|
var source_id := SimulationIds.STORAGE_VILLAGE_PANTRY
|
|
var consumed := 0.0
|
|
if actor_npc != null:
|
|
var carried_amount := actor_npc.get_inventory_amount(resource_id)
|
|
if carried_amount < required_amount:
|
|
_emit_feed_blocked(
|
|
actor_id, SimulationIds.npc_inventory_id(actor_id), definition, carried_amount
|
|
)
|
|
return false
|
|
consumed = economy.remove_exact_from_inventory(actor_npc, resource_id, required_amount)
|
|
source_id = SimulationIds.npc_inventory_id(actor_id)
|
|
else:
|
|
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
|
|
if pantry == null or available < required_amount:
|
|
return false
|
|
consumed = economy.withdraw_resource(resource_id, required_amount)
|
|
if not is_equal_approx(consumed, required_amount):
|
|
return false
|
|
if not animal_state.feed(actor_id, current_tick):
|
|
if actor_npc != null:
|
|
economy.restore_to_inventory(actor_npc, resource_id, consumed)
|
|
else:
|
|
economy.deposit_resource(resource_id, consumed)
|
|
return false
|
|
if animal_state.has_active_travel_target():
|
|
animal_state.cancel_travel(current_tick + ROUTINE_STAY_TICKS)
|
|
else:
|
|
animal_state.schedule_routine_at(current_tick + ROUTINE_STAY_TICKS)
|
|
|
|
economic_event_requested.emit(
|
|
SimulationIds.EVENT_ANIMAL_FED,
|
|
actor_id,
|
|
source_id,
|
|
animal_state.get_animal_id(),
|
|
resource_id,
|
|
consumed,
|
|
animal_state.get_position()
|
|
)
|
|
return true
|
|
|
|
|
|
func _emit_feed_blocked(
|
|
actor_id: int, source_id: StringName, definition: ActionDefinition, available_amount: float
|
|
) -> void:
|
|
if actor_id < 0:
|
|
return
|
|
(
|
|
narrative_event_requested
|
|
. emit(
|
|
SimulationIds.EVENT_TASK_BLOCKED,
|
|
actor_id,
|
|
source_id,
|
|
(
|
|
"%s: needs %.0f %s (%.1f available)"
|
|
% [
|
|
definition.display_name,
|
|
definition.completion_cost_amount,
|
|
String(definition.completion_cost_resource_id).capitalize(),
|
|
available_amount,
|
|
]
|
|
),
|
|
definition.action_id,
|
|
definition.completion_cost_resource_id,
|
|
definition.completion_cost_amount
|
|
)
|
|
)
|
|
|
|
|
|
func append_state_records(record: SimulationStateRecord) -> void:
|
|
var animal_ids: Array = states.keys()
|
|
animal_ids.sort()
|
|
for animal_id in animal_ids:
|
|
var animal_state := states[animal_id] as AnimalStateRecord
|
|
record.animals.append(animal_state)
|
|
|
|
|
|
func restore_state_records(records: Array[AnimalStateRecord], restored_tick: int) -> void:
|
|
current_tick = restored_tick
|
|
states.clear()
|
|
for animal_record in records:
|
|
states[animal_record.get_animal_id()] = animal_record
|
|
register_loaded_nodes()
|
|
|
|
|
|
func _begin_due_routine(animal_state: AnimalStateRecord) -> bool:
|
|
if (
|
|
active_world_adapter == null
|
|
or not active_world_adapter.has_method("get_animal_routine_sites")
|
|
):
|
|
return false
|
|
if AnimalNode.get_by_id(animal_state.get_animal_id()) == null:
|
|
return false
|
|
var candidates: Array[Dictionary] = active_world_adapter.get_animal_routine_sites(
|
|
animal_state.get_species_id(), animal_state.get_animal_id()
|
|
)
|
|
if candidates.is_empty():
|
|
return false
|
|
var current_site_id := animal_state.get_routine_site_id()
|
|
for candidate in candidates:
|
|
var candidate_id := StringName(candidate["site_id"])
|
|
if candidate_id == current_site_id:
|
|
continue
|
|
var target_position: Vector3 = candidate["position"]
|
|
if not animal_state.begin_travel(candidate_id, target_position):
|
|
return false
|
|
routine_started.emit(animal_state.get_animal_id(), candidate_id, target_position)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _on_node_position_changed(animal_id: StringName, active_position: Vector3) -> void:
|
|
var animal_state := get_state(animal_id)
|
|
if animal_state != null and animal_state.has_active_travel_target():
|
|
animal_state.set_position(active_position)
|
|
|
|
|
|
func _on_node_routine_arrived(
|
|
animal_id: StringName, site_id: StringName, arrival_position: Vector3
|
|
) -> void:
|
|
var animal_state := get_state(animal_id)
|
|
if (
|
|
animal_state == null
|
|
or not animal_state.has_active_travel_target()
|
|
or animal_state.get_travel_target_site_id() != site_id
|
|
):
|
|
return
|
|
if animal_state.complete_travel(arrival_position, current_tick + ROUTINE_STAY_TICKS):
|
|
routine_completed.emit(animal_id, site_id, arrival_position)
|
|
|
|
|
|
func _on_node_navigation_failed(animal_id: StringName, site_id: StringName) -> void:
|
|
var animal_state := get_state(animal_id)
|
|
if (
|
|
animal_state != null
|
|
and animal_state.has_active_travel_target()
|
|
and animal_state.get_travel_target_site_id() == site_id
|
|
):
|
|
animal_state.cancel_travel(current_tick + ROUTINE_RETRY_TICKS)
|
|
|
|
|
|
static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC:
|
|
for npc in npcs:
|
|
if npc.id == npc_id:
|
|
return npc
|
|
return null
|