extends CharacterBody3D signal interaction_feedback(heading: String, message: String, succeeded: bool) const DIALOGUE_MODE_CONTROLLER_SCRIPT := preload("res://world/dialogue/DialogueModeController.gd") @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 world_view_manager: Node @export var pantry_storage: StorageNode @export var woodpile_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 @export var villager_inspection_range := 3.0 @onready var combat_controller: Node = get_node_or_null("PlayerCombatController") var dialogue_mode_controller: DialogueModeController func _ready() -> void: add_to_group("grass_interactors") _install_dialogue_mode_controller() func _physics_process(delta: float) -> void: if is_dialogue_input_locked(): velocity.x = move_toward(velocity.x, 0.0, acceleration * delta) velocity.z = move_toward(velocity.z, 0.0, acceleration * delta) if not is_on_floor(): velocity.y -= 30.0 * delta else: velocity.y = 0.0 move_and_slide() return if _is_player_downed(): velocity.x = move_toward(velocity.x, 0.0, acceleration * delta) velocity.z = move_toward(velocity.z, 0.0, acceleration * delta) move_and_slide() return 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 var dash_velocity := Vector3.ZERO if combat_controller != null and combat_controller.has_method("get_dash_velocity"): dash_velocity = combat_controller.get_dash_velocity() if not dash_velocity.is_zero_approx(): target_velocity = dash_velocity direction = dash_velocity.normalized() velocity.x = target_velocity.x velocity.z = target_velocity.z else: 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 _is_player_downed() -> bool: return ( simulation_manager != null and simulation_manager.has_method("player_is_downed") and simulation_manager.player_is_downed() ) func _unhandled_input(event: InputEvent) -> void: if is_dialogue_input_locked(): return if ( event.is_action_pressed("interact") or (event.is_action_pressed("ui_accept") and _get_nearby_dialogue_npc_id() >= 0) ): try_interact() func try_interact() -> void: if is_dialogue_input_locked(): return var context := get_interaction_context() if context == null: return match context.kind: PlayerInteractionResult.KIND_DIALOGUE: if dialogue_mode_controller != null: dialogue_mode_controller.begin_conversation(int(String(context.target_id))) PlayerInteractionResult.KIND_ANIMAL: _execute_animal_interaction(context) PlayerInteractionResult.KIND_RESOURCE: _execute_resource_interaction(context) PlayerInteractionResult.KIND_ACTIVITY: _execute_activity_interaction(context) PlayerInteractionResult.KIND_PANTRY: _execute_pantry_interaction(context) PlayerInteractionResult.KIND_STORAGE_DEPOSIT: _execute_deposit_interaction(context) func get_interaction_context() -> PlayerInteractionResult: if simulation_manager == null or is_dialogue_input_locked(): return null var villager := _find_npc(_get_nearby_dialogue_npc_id()) if villager != null: return PlayerInteractionResult.new( PlayerInteractionResult.KIND_DIALOGUE, DialogueModeController.TALK_ACTION_ID, StringName(str(villager.id)), villager.npc_name, "Talk with %s" % villager.npc_name, "Ask what is happening nearby", 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 simulation_manager.has_method("get_player_activity_offer"): var activity_offer: Dictionary = simulation_manager.call( "get_player_activity_offer", global_position, interaction_range ) if not activity_offer.is_empty(): var effect: Dictionary = activity_offer.get("effect", {}) var amount := float(effect.get("amount", 0.0)) var metric_name := String(effect.get("metric_id", "benefit")).capitalize() return ( PlayerInteractionResult . new( PlayerInteractionResult.KIND_ACTIVITY, StringName(activity_offer["action_id"]), StringName(activity_offer["target_id"]), String(activity_offer["display_name"]), String(activity_offer["action_name"]), "Work here · +%.1f %s" % [amount, metric_name.to_lower()], null, { "blocked_reason": String(activity_offer.get("blocked_reason", "")), "expected_state_revision": int(activity_offer.get("state_revision", -1)), "target_generation": int(activity_offer.get("target_generation", -1)), "target_context_id": String(activity_offer.get("target_context_id", "")), "target_registry_instance_id": int(activity_offer.get("target_registry_instance_id", 0)), } ) ) if is_near_storage(pantry_storage): if _get_carried_food() > 0.0: return _build_deposit_context(SimulationIds.RESOURCE_FOOD, pantry_storage) return _build_pantry_context() if woodpile_storage != null and is_near_storage(woodpile_storage): if _get_carried_wood() > 0.0: return _build_deposit_context(SimulationIds.RESOURCE_WOOD, woodpile_storage) return null func is_dialogue_input_locked() -> bool: if ( simulation_manager != null and simulation_manager.has_method("is_dialogue_active") and bool(simulation_manager.call("is_dialogue_active")) ): return true return ( dialogue_mode_controller != null and is_instance_valid(dialogue_mode_controller) and dialogue_mode_controller.is_dialogue_active() ) func _install_dialogue_mode_controller() -> void: var existing := get_node_or_null("DialogueModeController") as DialogueModeController if existing != null: dialogue_mode_controller = existing else: dialogue_mode_controller = DIALOGUE_MODE_CONTROLLER_SCRIPT.new() dialogue_mode_controller.name = "DialogueModeController" dialogue_mode_controller.configure(self, simulation_manager, world_view_manager) add_child(dialogue_mode_controller) if existing != null: dialogue_mode_controller.configure(self, simulation_manager, world_view_manager) func _get_nearby_dialogue_npc_id() -> int: if ( world_view_manager == null or not world_view_manager.has_method("find_nearest_active_npc_id") ): return -1 return int( world_view_manager.call( "find_nearest_active_npc_id", global_position, villager_inspection_range ) ) func get_nearby_villager_inspection() -> VillagerInspectionResult: if ( simulation_manager == null or world_view_manager == null or not world_view_manager.has_method("find_nearest_active_npc_id") ): return null var npc_id: int = world_view_manager.find_nearest_active_npc_id( global_position, villager_inspection_range ) var npc := _find_npc(npc_id) if npc == null: return null var action_name := _get_action_display_name(npc.current_task) var target_name := _get_target_display_name(npc.target_id) var carried_amounts := {} for raw_item_id in npc.inventory: var amount := npc.get_inventory_amount(StringName(raw_item_id)) if amount > 0.0: carried_amounts[String(raw_item_id)] = amount var reason := "" if simulation_manager.has_method("get_latest_decision"): var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id) if decision != null and decision.action_id == npc.current_task: reason = decision.reason var need := _get_open_need_for_npc(npc) need["opportunity_note"] = _build_opportunity_note(npc) return VillagerInspectionResult.new( npc.id, npc.npc_name, npc.current_task, action_name, npc.task_state, npc.target_id, target_name, carried_amounts, reason, need ) func _build_opportunity_note(npc: SimNPC) -> VillagerOpportunityNote: if ( simulation_manager == null or not simulation_manager.has_method("get_active_opportunity") or not simulation_manager.has_method("get_active_opportunity_player_response") or not simulation_manager.has_method("get_active_opportunity_helper") ): return null var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() if ( opportunity == null or opportunity.get_interested_npc_id() != npc.id or ( opportunity.get_target_id() not in [SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.STORAGE_VILLAGE_WOODPILE] ) ): return null var player_response: OpportunityPlayerResponseResult = ( simulation_manager.get_active_opportunity_player_response() ) var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() var helper_name := "" if helper != null: var helper_npc := _find_npc(helper.helper_npc_id) if helper_npc != null: helper_name = helper_npc.npc_name return VillagerOpportunityNote.derive( opportunity, player_response, helper, helper_name, _get_opportunity_storage(opportunity), _get_target_display_name(opportunity.get_target_id()), _display_id(opportunity.get_resource_id()).to_lower() ) func _get_opportunity_storage(opportunity: OpportunityStateRecord) -> StorageStateRecord: if simulation_manager == null: return null if opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY: return simulation_manager.get_pantry() as StorageStateRecord if ( opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_WOODPILE and simulation_manager.has_method("get_woodpile") ): return simulation_manager.get_woodpile() as StorageStateRecord return null func _get_open_need_for_npc(npc: SimNPC) -> Dictionary: if not simulation_manager.has_method("get_latest_opportunity_for_npc"): return { "has_open_need": false, "need_type": &"", "progress": "", "response": "", } var opportunity: OpportunityStateRecord = simulation_manager.get_latest_opportunity_for_npc( npc.id ) if ( opportunity == null or opportunity.get_status() != OpportunityStateRecord.STATUS_OPEN or opportunity.get_interested_npc_id() != npc.id ): return { "has_open_need": false, "need_type": &"", "progress": "", "response": "", } var resource_name := String(opportunity.get_resource_id()).capitalize() var current_amount := _get_need_storage_amount(opportunity) var progress := ( "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()] ) var response := "No one is able to supply this right now." if simulation_manager.has_method("get_active_opportunity_helper"): var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() if helper != null and helper.opportunity_id == opportunity.get_opportunity_id(): var helper_name := _get_npc_name(helper.helper_npc_id) var action_phrase := _get_action_phrase(helper.action_id) response = "%s can %s." % [helper_name, action_phrase] if response == "No one is able to supply this right now.": if simulation_manager.has_method("get_active_opportunity_player_response"): var player_route: OpportunityPlayerResponseResult = ( simulation_manager.get_active_opportunity_player_response() ) if player_route != null: var route_phrase := ( "forage food" if opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD else "harvest a tree" ) response = ( "You can help — %s to the %s." % [route_phrase, _get_need_target_name(opportunity)] ) return { "has_open_need": true, "need_type": opportunity.get_opportunity_type(), "progress": progress, "response": response, } func _get_need_storage_amount(opportunity: OpportunityStateRecord) -> float: if not simulation_manager.has_method("get_pantry"): return 0.0 match opportunity.get_target_id(): SimulationIds.STORAGE_VILLAGE_PANTRY: var pantry: StorageStateRecord = simulation_manager.get_pantry() return pantry.get_amount(opportunity.get_resource_id()) if pantry != null else 0.0 SimulationIds.STORAGE_VILLAGE_WOODPILE: if simulation_manager.has_method("get_woodpile"): var woodpile: StorageStateRecord = simulation_manager.get_woodpile() return ( woodpile.get_amount(opportunity.get_resource_id()) if woodpile != null else 0.0 ) return 0.0 func _get_npc_name(npc_id: int) -> String: for npc in simulation_manager.npcs: if npc.id == npc_id: return npc.npc_name return "Someone" func _get_action_phrase(action_id: StringName) -> String: var definition := SimulationDefinitions.get_action(action_id) return definition.display_name.to_lower() if definition != null else String(action_id) func _get_need_target_name(opportunity: OpportunityStateRecord) -> String: match opportunity.get_target_id(): SimulationIds.STORAGE_VILLAGE_PANTRY: return "village pantry" SimulationIds.STORAGE_VILLAGE_WOODPILE: return "village woodpile" return String(opportunity.get_target_id()).replace("_", " ") 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 _find_npc(npc_id: int) -> SimNPC: if npc_id < 0: return null for npc in simulation_manager.npcs: if npc.id == npc_id and not npc.is_dead: return npc return null func _get_action_display_name(action_id: StringName) -> String: if action_id == SimulationIds.ACTION_IDLE: return "Idle" if action_id == SimulationIds.ACTION_DEAD: return "Dead" var definition := SimulationDefinitions.get_action(action_id) return definition.display_name if definition != null else _display_id(action_id) func _get_target_display_name(target_id: StringName) -> String: if target_id.is_empty(): return "None recorded" if "animal_care" in simulation_manager and simulation_manager.animal_care != null: var animal_state: AnimalStateRecord = simulation_manager.animal_care.get_state(target_id) if animal_state != null: return animal_state.get_display_name() var storage := StorageNode.get_by_id(target_id) as StorageNode if storage != null: return storage.display_name var activity := ActivitySite.get_by_id(target_id) as ActivitySite if activity != null: return activity.display_name return _display_id(target_id) static func _display_id(value: StringName) -> String: return String(value).replace("_", " ").capitalize() 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": 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() var carried := _get_player_carried(node.resource_id) var detail := ( "%s · %.1f remaining · carrying %.1f" % [display_name, node.get_amount_remaining(), carried] ) if _get_available_carry() <= 0.0: detail = "Hands full — deposit before gathering" return PlayerInteractionResult.new( PlayerInteractionResult.KIND_RESOURCE, node.action_id, node.node_id, display_name, prompt, detail, node, { "blocked_reason": "" if _get_available_carry() > 0.0 else "You cannot carry more right now." } ) func _build_pantry_context() -> PlayerInteractionResult: var pantry_food := _get_pantry_food() var carried_food := _get_carried_food() var blocked_reason := "" var detail := "%.0f food available" % pantry_food if pantry_food <= 0.0 and carried_food < 1.0: blocked_reason = "The village pantry is empty and you carry no food." detail = "Pantry empty · carry nothing" elif pantry_food <= 0.0: blocked_reason = "The village pantry is empty." detail = "Pantry empty · carry %.0f food" % carried_food return PlayerInteractionResult.new( PlayerInteractionResult.KIND_PANTRY, SimulationIds.ACTION_EAT, SimulationIds.STORAGE_VILLAGE_PANTRY, "Village pantry", "Eat from the pantry", detail, pantry_storage, {"blocked_reason": blocked_reason} ) func _build_deposit_context( resource_id: StringName, storage_node: StorageNode ) -> PlayerInteractionResult: var resource_name := String(resource_id).capitalize() var carried := ( _get_carried_food() if resource_id == SimulationIds.RESOURCE_FOOD else _get_carried_wood() ) var action_id := ( SimulationIds.ACTION_DEPOSIT_FOOD if resource_id == SimulationIds.RESOURCE_FOOD else SimulationIds.ACTION_DEPOSIT_WOOD ) return PlayerInteractionResult.new( PlayerInteractionResult.KIND_STORAGE_DEPOSIT, action_id, storage_node.storage_id, storage_node.display_name, "Deposit %s" % resource_name.to_lower(), "%s · carrying %.0f" % [storage_node.display_name, carried], storage_node ) 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("player_gather"): push_error("Player: SimulationManager cannot gather the resolved ResourceNode") return var extracted: float = simulation_manager.player_gather(node) if extracted <= 0.0: interaction_feedback.emit( "%s is unavailable" % context.display_name, ( context.blocked_reason if not context.blocked_reason.is_empty() else "Nothing could be gathered." ), false ) return interaction_feedback.emit( "%s gathered" % String(node.resource_id).capitalize(), ( "%.1f added to your pack — deposit it at the %s." % [extracted, _storage_name_for(node.resource_id)] ), true ) func _execute_activity_interaction(context: PlayerInteractionResult) -> void: if not context.is_available(): interaction_feedback.emit( "%s unavailable" % context.display_name, context.blocked_reason, false ) return if not simulation_manager.has_method("execute_player_activity_action"): push_error("Player: SimulationManager cannot execute authored activity actions") return if simulation_manager.has_method("update_player_combatant"): simulation_manager.call("update_player_combatant", global_position) var result := ( simulation_manager.call( "execute_player_activity_action", context.action_id, context.target_id, context.expected_state_revision, context.target_generation, context.target_context_id, context.target_registry_instance_id ) as ActionResult ) if result == null or not result.did_succeed(): var reason := result.get_message() if result != null else "Activity service unavailable." interaction_feedback.emit("%s unavailable" % context.display_name, reason, false) return var payload := result.get_payload() var amount := float(payload.get("amount", 0.0)) var metric_name := String(payload.get("metric_id", "benefit")).capitalize() interaction_feedback.emit( String(context.action_id).capitalize(), "%s increased by %.1f through authoritative work." % [metric_name, amount], true ) func _execute_talk_interaction(context: PlayerInteractionResult) -> void: var npc_id := _talk_npc_id_from_target(context.target_id) if npc_id < 0: return var quest: PlayerQuestRecord = simulation_manager.accept_villager_request(npc_id) if quest == null: interaction_feedback.emit( "%s declines" % context.display_name, "They have no request you can accept right now.", false ) return interaction_feedback.emit( "Request accepted", ( "%s asked you for help · +%.0f Standing on completion" % [context.display_name, quest.get_standing_reward()] ), true ) func _talk_npc_id_from_target(target_id: StringName) -> int: var prefix := "npc_" var suffix := "_inventory" var raw := String(target_id) if not raw.begins_with(prefix) or not raw.ends_with(suffix): return -1 var id_text := raw.trim_prefix(prefix).trim_suffix(suffix) return id_text.to_int() func _execute_pantry_interaction(context: PlayerInteractionResult) -> void: if not context.is_available(): interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) return if not simulation_manager.has_method("eat_player_food"): push_error("Player: SimulationManager cannot consume player food") return if simulation_manager.eat_player_food(): interaction_feedback.emit("Ate", "Hunger eased from carried or pantry food.", true) return interaction_feedback.emit("Nothing to eat", "The village pantry is empty.", false) func _execute_deposit_interaction(context: PlayerInteractionResult) -> void: if not simulation_manager.has_method("deposit_player_inventory"): push_error("Player: SimulationManager cannot deposit player inventory") return var resource_id := ( SimulationIds.RESOURCE_FOOD if context.action_id == SimulationIds.ACTION_DEPOSIT_FOOD else SimulationIds.RESOURCE_WOOD ) var deposited: float = simulation_manager.deposit_player_inventory(resource_id) var resource_name := String(resource_id).capitalize() if deposited <= 0.0: interaction_feedback.emit( "%s not stored" % resource_name, "The %s is full." % context.display_name.to_lower(), false ) return interaction_feedback.emit( "%s stored" % resource_name, "%.0f moved to the %s." % [deposited, context.display_name.to_lower()], true ) func _storage_name_for(resource_id: StringName) -> String: return "village pantry" if resource_id == SimulationIds.RESOURCE_FOOD else "village woodpile" func _get_player_hunger() -> float: if simulation_manager == null or not simulation_manager.has_method("get_player_state"): return 0.0 var state: PlayerStateRecord = simulation_manager.get_player_state() return state.get_hunger() if state != null else 0.0 func is_starving() -> bool: if simulation_manager == null or not simulation_manager.has_method("get_player_state"): return false var state: PlayerStateRecord = simulation_manager.get_player_state() return state.is_starving() if state != null else false func _needs_speed_factor() -> float: if simulation_manager == null or not simulation_manager.has_method("get_player_state"): return 1.0 var state: PlayerStateRecord = simulation_manager.get_player_state() if state == null or state.is_dead(): return 0.0 var factor := 1.0 if state.is_starving(): factor *= 0.5 var energy := state.get_energy() if energy < 20.0: factor *= 0.6 elif energy < 45.0: factor *= 0.8 return factor 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 _get_carried_food() -> float: if not simulation_manager.has_method("get_player_state"): return 0.0 return simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD) func _get_carried_wood() -> float: if not simulation_manager.has_method("get_player_state"): return 0.0 return simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_WOOD) func _get_player_carried(resource_id: StringName) -> float: if simulation_manager == null or not simulation_manager.has_method("get_player_state"): return 0.0 var state: PlayerStateRecord = simulation_manager.get_player_state() return state.get_inventory_amount(resource_id) if state != null else 0.0 func _get_available_carry() -> float: if simulation_manager == null or not simulation_manager.has_method("get_player_state"): return 0.0 var state: PlayerStateRecord = simulation_manager.get_player_state() return state.get_available_carry() if state != 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 )