Files
gamedev-the-steward/world/ui/ui.gd
T
2026-07-16 01:50:19 +02:00

498 lines
16 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends CanvasLayer
@export var simulation_manager: Node
@onready var village_stats_label: Label = $VillagePanel/MarginContainer/VillageStatsLabel
@onready var npc_inspector_label: Label = $NpcInspectorPanel/MarginContainer/NpcInspectorLabel
var selected_npc_index := 0
var inspector_refresh_accumulator := 0.0
var debug_overlay_visible := true
func _ready() -> void:
if simulation_manager == null:
push_error("VillageUI: simulation_manager missing")
return
if simulation_manager.has_signal("village_changed"):
simulation_manager.village_changed.connect(_on_village_changed)
else:
push_error("VillageUI: SimulationManager has no village_changed signal")
if simulation_manager.has_signal("npc_decision_recorded"):
simulation_manager.npc_decision_recorded.connect(_on_npc_decision_recorded)
else:
push_error("VillageUI: SimulationManager has no npc_decision_recorded signal")
if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_state_restored)
if simulation_manager.has_signal("speed_changed"):
simulation_manager.speed_changed.connect(_on_speed_changed)
if simulation_manager.has_signal("relationship_changed"):
simulation_manager.relationship_changed.connect(_on_relationship_changed)
if simulation_manager.has_signal("event_knowledge_changed"):
simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed)
if simulation_manager.has_signal("event_knowledge_forgotten"):
simulation_manager.event_knowledge_forgotten.connect(_on_event_knowledge_forgotten)
if simulation_manager.has_signal("opportunity_opened"):
simulation_manager.opportunity_opened.connect(_on_opportunity_opened)
if simulation_manager.has_signal("opportunity_resolved"):
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
if simulation_manager.has_signal("opportunity_invalidated"):
simulation_manager.opportunity_invalidated.connect(_on_opportunity_invalidated)
if "village" in simulation_manager:
_on_village_changed(simulation_manager.village)
_refresh_npc_inspector()
func _process(delta: float) -> void:
inspector_refresh_accumulator += delta
if inspector_refresh_accumulator < 0.25:
return
inspector_refresh_accumulator = 0.0
_refresh_npc_inspector()
func _unhandled_key_input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.echo:
return
if event.keycode != KEY_TAB or simulation_manager.npcs.is_empty():
return
selected_npc_index = (selected_npc_index + 1) % simulation_manager.npcs.size()
_refresh_npc_inspector()
get_viewport().set_input_as_handled()
func _on_village_changed(village: SimVillage) -> void:
var name_map := {}
for npc in simulation_manager.npcs:
name_map[npc.id] = npc.npc_name
var event_lines: Array[String] = []
for event in simulation_manager.get_recent_events(3):
event_lines.append(event.description(name_map))
var event_text := "\n".join(event_lines)
if event_text.is_empty():
event_text = ""
else:
event_text = "\n" + event_text
var speed_text := ""
if simulation_manager.has_method("get_current_speed"):
speed_text = simulation_manager.get_current_speed()
var rates: Dictionary = simulation_manager.get_resource_rates()
var food_rate := ""
if rates["food_per_day"] > 0.01:
food_rate = " (%.0f/d)" % rates["food_per_day"]
var wood_rate := ""
if rates["wood_per_day"] > 0.01:
wood_rate = " (%.0f/d)" % rates["wood_per_day"]
var opportunity_text := _build_active_opportunity_display()
village_stats_label.text = (
"""
Village [%s]
Food: %s%s
Wood: %s%s
Safety: %s
Knowledge: %s
Starving: %s
Food Mod: %.2f
Safety Mod: %.2f
Knowledge Mod: %.2f
%s%s
"""
% [
speed_text,
round(village.food),
food_rate,
round(village.wood),
wood_rate,
round(village.safety),
round(village.knowledge),
simulation_manager.get_starving_count(),
village.food_modifier,
village.safety_modifier,
village.knowledge_modifier,
opportunity_text,
event_text,
]
)
func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void:
_refresh_npc_inspector()
func _on_state_restored() -> void:
selected_npc_index = 0
_refresh_village_display()
_refresh_npc_inspector()
func _on_speed_changed(_multiplier: float) -> void:
if simulation_manager != null and "village" in simulation_manager:
_on_village_changed(simulation_manager.village)
func _on_relationship_changed(
_relationship: RelationshipStateRecord, _cause_event: EconomicEventRecord
) -> void:
_refresh_npc_inspector()
func _on_event_knowledge_changed(_knower_id: int, _event: EconomicEventRecord) -> void:
_refresh_npc_inspector()
func _on_event_knowledge_forgotten(_knower_id: int, _event: EconomicEventRecord) -> void:
_refresh_npc_inspector()
func _on_opportunity_opened(_opportunity: RefCounted) -> void:
_refresh_village_display()
_refresh_npc_inspector()
func _on_opportunity_resolved(
_opportunity: RefCounted, _resolution_event: EconomicEventRecord
) -> void:
_refresh_village_display()
_refresh_npc_inspector()
func _on_opportunity_invalidated(_opportunity: RefCounted) -> void:
_refresh_village_display()
_refresh_npc_inspector()
func _refresh_village_display() -> void:
if simulation_manager != null and "village" in simulation_manager:
_on_village_changed(simulation_manager.village)
func _refresh_npc_inspector() -> void:
if not debug_overlay_visible:
return
if simulation_manager == null or simulation_manager.npcs.is_empty():
npc_inspector_label.text = "No villagers available"
return
selected_npc_index = clampi(selected_npc_index, 0, simulation_manager.npcs.size() - 1)
var npc: SimNPC = simulation_manager.npcs[selected_npc_index]
var profession_definition := SimulationDefinitions.get_profession(npc.profession)
var profession_name := String(npc.profession)
if profession_definition != null:
profession_name = profession_definition.display_name
var action_name := _get_action_name(npc.current_task)
var destination_name := "Resolving"
if not npc.target_id.is_empty():
destination_name = String(npc.target_id)
var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id)
var reason_text := "Awaiting the next decision"
var score_text := ""
if decision != null:
reason_text = decision.reason
var score_keys: Array = decision.scores.keys()
score_keys.sort()
var score_rows: Array[String] = []
var rejection_rows: Array[String] = []
for action_id in score_keys:
if decision.rejections.has(action_id):
rejection_rows.append(
(
"%s%s"
% [
_get_action_name(StringName(action_id)),
String(decision.rejections[action_id])
]
)
)
continue
score_rows.append(
(
"%s %.2f"
% [_get_action_name(StringName(action_id)), float(decision.scores[action_id])]
)
)
if not score_rows.is_empty():
score_text = "\n\nUtility\n" + "\n".join(score_rows)
if not rejection_rows.is_empty():
score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows)
var event_text := _build_event_history(npc)
var relationship_text := _build_relationship_display(npc)
var memory_history_text := _build_memory_history_display(npc)
var opportunity_text := _build_npc_opportunity_display(npc)
npc_inspector_label.text = (
(
"%s · %s\n"
+ "Task: %s (%s)\n"
+ "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\n"
+ "Carrying food: %.0f wood: %.0f\n"
+ "%s\n"
+ "%s\n"
+ "%s"
+ "Why\n%s%s\n\n"
+ "Own history\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F11] Pantry story [F12] Reset"
)
% [
npc.npc_name,
profession_name,
action_name,
npc.task_state,
destination_name,
npc.hunger,
npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
relationship_text,
memory_history_text,
opportunity_text,
reason_text,
score_text,
event_text
]
)
func _build_active_opportunity_display() -> String:
if not simulation_manager.has_method("get_active_opportunity"):
return ""
var opportunity = simulation_manager.get_active_opportunity()
if opportunity == null:
return ""
var interested_name := _get_npc_name(opportunity.get_interested_npc_id())
var resource_name := String(opportunity.get_resource_id()).capitalize()
var current_amount := _get_opportunity_current_amount(opportunity)
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
var action_name := _get_opportunity_action_name(opportunity)
return (
"\n\nVillage need\n"
+ "◆ Supply wood for blocked work\n"
+ "%s could not finish %s\n" % [interested_name, action_name]
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
)
return (
"\n\nVillage need\n"
+ "◆ Restock the empty pantry\n"
+ "%s is worried about the shortage\n" % interested_name
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
)
func _build_npc_opportunity_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_latest_opportunity_for_npc"):
return ""
var opportunity = simulation_manager.get_latest_opportunity_for_npc(npc.id)
if opportunity == null:
return ""
var resource_name := String(opportunity.get_resource_id()).capitalize()
if opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN:
var current_amount := _get_opportunity_current_amount(opportunity)
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
return (
"Open village need\n"
+ "◆ Find wood for %s\n" % _get_opportunity_action_name(opportunity)
+ (
"%s %.0f / %.0f\n\n"
% [resource_name, current_amount, opportunity.get_target_amount()]
)
)
return (
"Open village need\n"
+ "◆ Restock the empty pantry\n"
+ (
"%s %.0f / %.0f\n\n"
% [resource_name, current_amount, opportunity.get_target_amount()]
)
)
if opportunity.get_status() == OpportunityStateRecord.STATUS_INVALIDATED:
var reason := (
"The interested villager died"
if (
opportunity.get_invalidation_reason()
== SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED
)
else "The evidence became stale"
)
return "Closed village need\n%s\n\n" % reason
if opportunity.get_status() != OpportunityStateRecord.STATUS_RESOLVED:
return ""
var resolution_event: EconomicEventRecord = simulation_manager.get_opportunity_resolution_event(
opportunity
)
var actor_name := "Someone"
if resolution_event != null:
var actor_id := int(resolution_event.data["actor_id"])
actor_name = "Player" if actor_id < 0 else _get_npc_name(actor_id)
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
return (
"Resolved village need\n"
+ "%s supplied the woodpile\n" % actor_name
+ "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()]
)
return (
"Resolved village need\n"
+ "%s restocked the pantry\n" % actor_name
+ "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()]
)
func _get_opportunity_current_amount(opportunity: OpportunityStateRecord) -> float:
var storage: StorageStateRecord
match opportunity.get_target_id():
SimulationIds.STORAGE_VILLAGE_PANTRY:
storage = simulation_manager.get_pantry()
SimulationIds.STORAGE_VILLAGE_WOODPILE:
storage = simulation_manager.get_woodpile()
return storage.get_amount(opportunity.get_resource_id()) if storage != null else 0.0
func _get_opportunity_action_name(opportunity: OpportunityStateRecord) -> String:
var trigger: EconomicEventRecord = simulation_manager.get_opportunity_trigger_event(opportunity)
if trigger == null:
return "work"
return _get_action_name(StringName(trigger.data.get("action_id", &"")))
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_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)
if definition != null:
return definition.display_name
return String(action_id).capitalize()
func set_debug_overlay_visible(is_visible: bool) -> void:
debug_overlay_visible = is_visible
visible = is_visible
if is_visible:
_refresh_npc_inspector()
func _build_event_history(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_npc_events"):
return ""
var events: Array = simulation_manager.get_npc_events(npc.id, 3)
if events.is_empty():
return "No recorded events yet"
var name_map := {}
for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name
var lines: Array[String] = []
for event in events:
lines.append(event.description(name_map))
return "\n".join(lines)
func _build_relationship_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_primary_relationship"):
return ""
var relationship: RelationshipStateRecord = simulation_manager.get_primary_relationship(npc.id)
if relationship == null:
return ""
var other_name := "Someone"
for other_npc in simulation_manager.npcs:
if other_npc.id == relationship.get_subject_id():
other_name = other_npc.npc_name
break
var display := (
"Relationship: %s — familiar %.0f%%, trust %.0f%%"
% [
other_name,
relationship.get_familiarity() * 100.0,
relationship.get_trust() * 100.0,
]
)
if not simulation_manager.has_method("get_relationship_cause"):
return display
var cause: EconomicEventRecord = simulation_manager.get_relationship_cause(relationship)
if cause == null:
return display
var name_map := {}
for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name
return "%s\nBecause: %s" % [display, cause.description(name_map)]
func _build_memory_history_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_retained_memory_events"):
return "Memories unavailable"
var summary: Dictionary = simulation_manager.get_memory_summary(npc.id)
var header := (
"Memories %d lasting · %d recent"
% [int(summary.get("lasting", 0)), int(summary.get("recent", 0))]
)
var retained_events: Array = simulation_manager.get_retained_memory_events(npc.id, 4)
if retained_events.is_empty():
return header + "\nNo retained memories"
var name_map := {}
for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name
var lines: Array[String] = [header]
for event in retained_events:
var event_id := int(event.data["event_id"])
var known_record: KnownEventStateRecord = simulation_manager.get_known_event_record(
npc.id, event_id
)
if known_record == null:
continue
var lasting: bool = simulation_manager.is_known_event_lasting(npc.id, event_id)
var marker := "" if lasting else ""
var retention := "lasting" if lasting else "recent"
(
lines
. append(
(
"%s %s · %s · %s"
% [
marker,
retention,
_build_compact_memory_summary(event, name_map),
_build_memory_provenance(known_record, name_map),
]
)
)
)
return "\n".join(lines)
func _build_compact_memory_summary(event: EconomicEventRecord, name_map: Dictionary) -> String:
var event_type := StringName(event.data["event_type"])
var item_id := StringName(event.data["item_id"])
if (
event_type == SimulationIds.EVENT_STORAGE_DEPOSITED
and item_id == SimulationIds.RESOURCE_FOOD
):
var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone")
return "%s stocked %.0f food" % [actor_name, float(event.data["amount"])]
if (
event_type == SimulationIds.EVENT_STORAGE_WITHDRAWN
and item_id == SimulationIds.RESOURCE_FOOD
):
var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone")
return "%s took %.0f food from the pantry" % [actor_name, float(event.data["amount"])]
return event.description(name_map)
func _build_memory_provenance(record: KnownEventStateRecord, name_map: Dictionary) -> String:
match record.get_acquisition_method():
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED:
return "own action"
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED:
return "witnessed"
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
var source_name: String = name_map.get(record.get_source_npc_id(), "someone")
return "heard %s" % source_name
return "source unknown"