Files
gamedev-the-steward/player/player.gd
T

526 lines
17 KiB
GDScript

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 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
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 * _needs_speed_factor()
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_DEPOSIT:
_execute_deposit_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(woodpile_storage) and _get_player_carried(SimulationIds.RESOURCE_WOOD) > 0.0:
return _build_deposit_context(
SimulationIds.RESOURCE_WOOD,
SimulationIds.STORAGE_VILLAGE_WOODPILE,
woodpile_storage,
"Village woodpile"
)
if is_near_storage(pantry_storage) and _get_player_carried(SimulationIds.RESOURCE_FOOD) > 0.0:
return _build_deposit_context(
SimulationIds.RESOURCE_FOOD,
SimulationIds.STORAGE_VILLAGE_PANTRY,
pantry_storage,
"Village pantry"
)
if is_near_storage(pantry_storage):
return _build_pantry_context()
return null
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 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,
opportunity_note
)
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 not simulation_manager.has_method("get_pantry"):
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:
if simulation_manager.has_method("get_woodpile"):
return simulation_manager.get_woodpile() as StorageStateRecord
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 _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
)
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,
"" if _get_available_carry() > 0.0 else "You cannot carry more right now."
)
func _build_deposit_context(
resource_id: StringName, target_id: StringName, target_node: StorageNode, display_name: String
) -> PlayerInteractionResult:
var carried := _get_player_carried(resource_id)
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_DEPOSIT,
(
SimulationIds.ACTION_DEPOSIT_FOOD
if resource_id == SimulationIds.RESOURCE_FOOD
else SimulationIds.ACTION_DEPOSIT_WOOD
),
target_id,
display_name,
"Deposit %s" % String(resource_id).capitalize(),
"%.1f %s in your hands" % [carried, String(resource_id).to_lower()],
target_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("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
var carried := _get_player_carried(node.resource_id)
interaction_feedback.emit(
"%s gathered" % String(node.resource_id).capitalize(),
"%.1f added to your hands · carrying %.1f" % [extracted, carried],
true
)
func _deposit_carried(resource_id: StringName) -> float:
if not simulation_manager.has_method("player_deposit"):
push_error("Player: SimulationManager cannot deposit carried resources")
return 0.0
return simulation_manager.player_deposit(resource_id)
func _execute_deposit_interaction(context: PlayerInteractionResult) -> void:
var resource_id := (
SimulationIds.RESOURCE_FOOD
if context.action_id == SimulationIds.ACTION_DEPOSIT_FOOD
else SimulationIds.RESOURCE_WOOD
)
var deposited: float = _deposit_carried(resource_id)
if deposited <= 0.0:
interaction_feedback.emit("Deposit blocked", "You are not carrying that here.", false)
return
interaction_feedback.emit(
"Deposited to %s" % context.display_name, "%.1f moved into the village." % deposited, 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()
var player_hunger := _get_player_hunger()
if consumed > 0.0 and player_hunger > 0.0:
simulation_manager.player_eat(consumed)
interaction_feedback.emit(
"Ate from the pantry", "%.0f village food consumed." % consumed, consumed > 0.0
)
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_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
)