extends CharacterBody3D signal interaction_feedback(heading: String, message: String, succeeded: bool) @export var move_speed := 7.0 @export var acceleration := 18.0 @export var rotation_speed := 12.0 @export var camera_rig: Node3D @export var simulation_manager: Node @export var pantry_storage: StorageNode @export var guard_site: ActivitySite @export var study_site: ActivitySite @export var stomach_capacity_for_food := 5 @export var interaction_range := 3.0 func _ready() -> void: add_to_group("grass_interactors") func _physics_process(delta: float) -> void: var input := Input.get_vector("move_left", "move_right", "move_forward", "move_backward") var forward := -camera_rig.global_transform.basis.z var right := camera_rig.global_transform.basis.x forward.y = 0 right.y = 0 forward = forward.normalized() right = right.normalized() var direction := (right * input.x + forward * -input.y).normalized() var target_velocity := direction * move_speed velocity.x = move_toward(velocity.x, target_velocity.x, acceleration * delta) velocity.z = move_toward(velocity.z, target_velocity.z, acceleration * delta) if not is_on_floor(): velocity.y -= 30.0 * delta else: velocity.y = 0.0 move_and_slide() if direction.length() > 0.01: var target_angle := atan2(direction.x, direction.z) rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta) func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("interact"): try_interact() func try_interact() -> void: var context := get_interaction_context() if context == null: return match context.kind: PlayerInteractionResult.KIND_ANIMAL: _execute_animal_interaction(context) PlayerInteractionResult.KIND_RESOURCE: _execute_resource_interaction(context) PlayerInteractionResult.KIND_GUARD: simulation_manager.add_safety(3.0) interaction_feedback.emit( "Village guarded", "Safety increased through real guard-post work.", true ) PlayerInteractionResult.KIND_STUDY: simulation_manager.add_knowledge(2.0) interaction_feedback.emit( "Knowledge shared", "Village knowledge increased at the study desk.", true ) PlayerInteractionResult.KIND_PANTRY: _execute_pantry_interaction(context) func get_interaction_context() -> PlayerInteractionResult: if simulation_manager == null: return null var animal := _find_feed_animal() if animal != null: return _build_animal_context(animal) var resource := _find_resource_node() if resource != null: return _build_resource_context(resource) if is_near_activity_site(guard_site): return PlayerInteractionResult.new( PlayerInteractionResult.KIND_GUARD, SimulationIds.ACTION_PATROL, guard_site.site_id, guard_site.display_name, "Help guard the village", "Work here · +3 safety", guard_site ) if is_near_activity_site(study_site): return PlayerInteractionResult.new( PlayerInteractionResult.KIND_STUDY, SimulationIds.ACTION_STUDY, study_site.site_id, study_site.display_name, "Share knowledge", "Work here · +2 knowledge", study_site ) if is_near_storage(pantry_storage): return _build_pantry_context() return null func _find_feed_animal() -> AnimalNode: if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null: push_error("Player: SimulationManager has no animal-care service") return null return simulation_manager.animal_care.find_node_for_player(global_position, interaction_range) func _find_resource_node() -> ResourceNode: if not simulation_manager.has_method("find_resource_node_for_player"): push_error("Player: SimulationManager cannot find ResourceNodes") return null return simulation_manager.find_resource_node_for_player(global_position, interaction_range) func _build_animal_context(node: AnimalNode) -> PlayerInteractionResult: var cost := _get_animal_feed_cost() var pantry_food := _get_pantry_food() var blocked_reason := "" var detail := "%.0f pantry food · %.0f available" % [cost, pantry_food] if pantry_food < cost: if pantry_food <= 0.0: blocked_reason = "The village pantry is empty." detail = "Pantry empty · needs %.0f food" % cost else: blocked_reason = "The village pantry does not have enough food." detail = "Needs %.0f pantry food · %.1f available" % [cost, pantry_food] return PlayerInteractionResult.new( PlayerInteractionResult.KIND_ANIMAL, SimulationIds.ACTION_FEED_ANIMAL, node.animal_id, node.display_name, "Feed %s" % node.display_name, detail, node, blocked_reason ) func _build_resource_context(node: ResourceNode) -> PlayerInteractionResult: var definition := SimulationDefinitions.get_action(node.action_id) var prompt := ( definition.display_name if definition != null else String(node.action_id).capitalize() ) var display_name := String(node.node_id).replace("_", " ").capitalize() return PlayerInteractionResult.new( PlayerInteractionResult.KIND_RESOURCE, node.action_id, node.node_id, display_name, prompt, "%s · %.1f remaining" % [display_name, node.get_amount_remaining()], node ) func _build_pantry_context() -> PlayerInteractionResult: var pantry_food := _get_pantry_food() var blocked_reason := "" if pantry_food > 0.0 else "The village pantry is empty." var detail := "%.0f food available" % pantry_food if not blocked_reason.is_empty(): detail = "Pantry empty" return PlayerInteractionResult.new( PlayerInteractionResult.KIND_PANTRY, SimulationIds.ACTION_EAT, SimulationIds.STORAGE_VILLAGE_PANTRY, "Village pantry", "Eat from the pantry", detail, pantry_storage, blocked_reason ) func _execute_animal_interaction(context: PlayerInteractionResult) -> void: var node := context.target_node as AnimalNode if node == null or not context.is_available(): interaction_feedback.emit( "%s is still hungry" % context.display_name, ( context.blocked_reason if not context.blocked_reason.is_empty() else "Animal care is unavailable." ), false ) return if not simulation_manager.has_method("feed_animal"): push_error("Player: SimulationManager cannot feed AnimalNodes") return if simulation_manager.feed_animal(context.target_id, -1): interaction_feedback.emit( "%s is fed" % context.display_name, "%.0f food moved from the village pantry." % _get_animal_feed_cost(), true ) return interaction_feedback.emit( "%s is still hungry" % context.display_name, "Animal care could not be completed.", false ) func _execute_resource_interaction(context: PlayerInteractionResult) -> void: var node := context.target_node as ResourceNode if node == null or not simulation_manager.has_method("harvest_resource_node"): push_error("Player: SimulationManager cannot harvest the resolved ResourceNode") return var extracted: float = simulation_manager.harvest_resource_node(node) if extracted <= 0.0: interaction_feedback.emit( "%s is unavailable" % context.display_name, "Nothing could be gathered.", false ) return var destination := "pantry" if node.resource_id == SimulationIds.RESOURCE_FOOD else "woodpile" interaction_feedback.emit( "%s gathered" % String(node.resource_id).capitalize(), "%.1f moved to the village %s." % [extracted, destination], true ) func _execute_pantry_interaction(context: PlayerInteractionResult) -> void: if not context.is_available(): interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) return var food_before := _get_pantry_food() simulation_manager.eat_food(stomach_capacity_for_food) var consumed := food_before - _get_pantry_food() interaction_feedback.emit( "Ate from the pantry", "%.0f village food consumed." % consumed, consumed > 0.0 ) func _get_animal_feed_cost() -> float: var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) return definition.completion_cost_amount if definition != null else 1.0 func _get_pantry_food() -> float: if not simulation_manager.has_method("get_pantry"): return 0.0 var pantry: StorageStateRecord = simulation_manager.get_pantry() return pantry.get_amount(SimulationIds.RESOURCE_FOOD) if pantry != null else 0.0 func is_near_storage(storage_node: StorageNode) -> bool: if storage_node == null: return false return global_position.distance_to(storage_node.get_interaction_position()) <= interaction_range func is_near_activity_site(activity_site: ActivitySite) -> bool: if activity_site == null: return false return ( global_position.distance_to(activity_site.get_interaction_position()) <= interaction_range )