Files
gamedev-the-steward/player/player.gd
T
2026-08-13 00:08:30 +02:00

665 lines
22 KiB
GDScript

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)
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 _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()
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 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("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
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_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_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 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
)