feat: add npc decision inspector
This commit is contained in:
@@ -101,3 +101,26 @@ theme_override_constants/margin_bottom = 4
|
|||||||
[node name="VillageStatsLabel" type="Label" parent="UI/VillagePanel/MarginContainer" unique_id=1554319259]
|
[node name="VillageStatsLabel" type="Label" parent="UI/VillagePanel/MarginContainer" unique_id=1554319259]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 10
|
theme_override_font_sizes/font_size = 10
|
||||||
|
|
||||||
|
[node name="NpcInspectorPanel" type="PanelContainer" parent="UI"]
|
||||||
|
anchors_preset = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -370.0
|
||||||
|
offset_top = 20.0
|
||||||
|
offset_right = -20.0
|
||||||
|
offset_bottom = 330.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
|
||||||
|
[node name="MarginContainer" type="MarginContainer" parent="UI/NpcInspectorPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 16
|
||||||
|
theme_override_constants/margin_top = 12
|
||||||
|
theme_override_constants/margin_right = 16
|
||||||
|
theme_override_constants/margin_bottom = 12
|
||||||
|
|
||||||
|
[node name="NpcInspectorLabel" type="Label" parent="UI/NpcInspectorPanel/MarginContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.96, 0.91, 0.78, 1)
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "Villager inspector"
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const DEBUG_LOGS := true
|
|||||||
@export var stuck_timeout := 1.5
|
@export var stuck_timeout := 1.5
|
||||||
|
|
||||||
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
|
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||||||
|
@onready var body_mesh: MeshInstance3D = $MeshInstance3D
|
||||||
|
@onready var profession_prop: MeshInstance3D = $ProfessionProp
|
||||||
|
@onready var profession_label: Label3D = $ProfessionLabel
|
||||||
@onready var carried_food_visual: MeshInstance3D = $CarriedFood
|
@onready var carried_food_visual: MeshInstance3D = $CarriedFood
|
||||||
|
|
||||||
var has_reported_arrival := false
|
var has_reported_arrival := false
|
||||||
@@ -45,9 +48,66 @@ func setup_from_sim(npc: SimNPC) -> void:
|
|||||||
npc_name = npc.npc_name
|
npc_name = npc.npc_name
|
||||||
profession = npc.profession
|
profession = npc.profession
|
||||||
name = "NPC_%s_%s" % [sim_id, npc_name]
|
name = "NPC_%s_%s" % [sim_id, npc_name]
|
||||||
|
_apply_profession_presentation()
|
||||||
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
|
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_profession_presentation() -> void:
|
||||||
|
var definition := SimulationDefinitions.get_profession(profession)
|
||||||
|
if definition == null:
|
||||||
|
profession_label.text = npc_name
|
||||||
|
profession_prop.visible = false
|
||||||
|
return
|
||||||
|
|
||||||
|
var body_material := StandardMaterial3D.new()
|
||||||
|
body_material.albedo_color = definition.visual_color
|
||||||
|
body_material.roughness = 0.82
|
||||||
|
body_mesh.material_override = body_material
|
||||||
|
|
||||||
|
var prop_material := StandardMaterial3D.new()
|
||||||
|
prop_material.albedo_color = definition.prop_color
|
||||||
|
prop_material.roughness = 0.78
|
||||||
|
profession_prop.material_override = prop_material
|
||||||
|
profession_prop.mesh = _create_profession_prop_mesh()
|
||||||
|
profession_prop.visible = profession_prop.mesh != null
|
||||||
|
profession_label.text = "%s\n%s" % [npc_name, definition.display_name]
|
||||||
|
|
||||||
|
|
||||||
|
func _create_profession_prop_mesh() -> PrimitiveMesh:
|
||||||
|
profession_prop.position = Vector3(0.48, 0.85, 0.0)
|
||||||
|
profession_prop.rotation_degrees = Vector3.ZERO
|
||||||
|
match profession:
|
||||||
|
SimulationIds.PROFESSION_FARMER:
|
||||||
|
var basket := SphereMesh.new()
|
||||||
|
basket.radius = 0.24
|
||||||
|
basket.height = 0.34
|
||||||
|
return basket
|
||||||
|
SimulationIds.PROFESSION_WOODCUTTER:
|
||||||
|
var tool := BoxMesh.new()
|
||||||
|
tool.size = Vector3(0.12, 0.72, 0.16)
|
||||||
|
profession_prop.position = Vector3(0.48, 0.92, 0.0)
|
||||||
|
profession_prop.rotation_degrees.z = -18.0
|
||||||
|
return tool
|
||||||
|
SimulationIds.PROFESSION_GUARD:
|
||||||
|
var shield := CylinderMesh.new()
|
||||||
|
shield.top_radius = 0.3
|
||||||
|
shield.bottom_radius = 0.3
|
||||||
|
shield.height = 0.12
|
||||||
|
profession_prop.rotation_degrees.x = 90.0
|
||||||
|
return shield
|
||||||
|
SimulationIds.PROFESSION_SCHOLAR:
|
||||||
|
var book := BoxMesh.new()
|
||||||
|
book.size = Vector3(0.42, 0.1, 0.3)
|
||||||
|
return book
|
||||||
|
SimulationIds.PROFESSION_WANDERER:
|
||||||
|
var pack := SphereMesh.new()
|
||||||
|
pack.radius = 0.28
|
||||||
|
pack.height = 0.5
|
||||||
|
profession_prop.position = Vector3(-0.42, 0.86, 0.0)
|
||||||
|
return pack
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
func set_carried_food_visible(is_visible: bool) -> void:
|
func set_carried_food_visible(is_visible: bool) -> void:
|
||||||
carried_food_visual.visible = is_visible and not is_dead_visual
|
carried_food_visual.visible = is_visible and not is_dead_visual
|
||||||
|
|
||||||
@@ -179,6 +239,7 @@ func _report_navigation_failure_once() -> void:
|
|||||||
func apply_dead_visual_state() -> void:
|
func apply_dead_visual_state() -> void:
|
||||||
is_dead_visual = true
|
is_dead_visual = true
|
||||||
carried_food_visual.visible = false
|
carried_food_visual.visible = false
|
||||||
|
profession_prop.visible = false
|
||||||
path_request_id += 1
|
path_request_id += 1
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
current_path = PackedVector3Array()
|
current_path = PackedVector3Array()
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ mesh = SubResource("CapsuleMesh_d844k")
|
|||||||
transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.35, 0, 1.2, 0.59052056)
|
transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.35, 0, 1.2, 0.59052056)
|
||||||
mesh = SubResource("BoxMesh_d844k")
|
mesh = SubResource("BoxMesh_d844k")
|
||||||
|
|
||||||
|
[node name="ProfessionProp" type="MeshInstance3D" parent="."]
|
||||||
|
|
||||||
|
[node name="ProfessionLabel" type="Label3D" parent="."]
|
||||||
|
position = Vector3(0, 2.05, 0)
|
||||||
|
billboard = 1
|
||||||
|
no_depth_test = true
|
||||||
|
text = "Villager"
|
||||||
|
font_size = 24
|
||||||
|
outline_size = 6
|
||||||
|
modulate = Color(1, 0.96, 0.82, 1)
|
||||||
|
pixel_size = 0.007
|
||||||
|
|
||||||
[node name="CarriedFood" type="MeshInstance3D" parent="."]
|
[node name="CarriedFood" type="MeshInstance3D" parent="."]
|
||||||
visible = false
|
visible = false
|
||||||
transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0)
|
transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ signal npc_travel_requested(npc: SimNPC, target_position: Vector3)
|
|||||||
signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
|
signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
|
||||||
signal economic_event_recorded(event: EconomicEventRecord)
|
signal economic_event_recorded(event: EconomicEventRecord)
|
||||||
signal state_restored
|
signal state_restored
|
||||||
|
signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult)
|
||||||
|
|
||||||
var village := SimVillage.new()
|
var village := SimVillage.new()
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ var resource_states: Dictionary = {}
|
|||||||
var storage_states: Dictionary = {}
|
var storage_states: Dictionary = {}
|
||||||
var economic_events: Array[EconomicEventRecord] = []
|
var economic_events: Array[EconomicEventRecord] = []
|
||||||
var next_event_id := 0
|
var next_event_id := 0
|
||||||
|
var latest_decisions: Dictionary = {}
|
||||||
var action_selector := ActionSelectionSystem.new()
|
var action_selector := ActionSelectionSystem.new()
|
||||||
var action_executor := ActionExecutionSystem.new()
|
var action_executor := ActionExecutionSystem.new()
|
||||||
var target_resolver := ActionTargetResolver.new()
|
var target_resolver := ActionTargetResolver.new()
|
||||||
@@ -111,6 +113,8 @@ func simulate_tick() -> void:
|
|||||||
):
|
):
|
||||||
var selection := action_selector.select_action(npc, village)
|
var selection := action_selector.select_action(npc, village)
|
||||||
if selection != null:
|
if selection != null:
|
||||||
|
latest_decisions[npc.id] = selection
|
||||||
|
npc_decision_recorded.emit(npc, selection)
|
||||||
npc.set_task(selection.action_id, selection.duration_override)
|
npc.set_task(selection.action_id, selection.duration_override)
|
||||||
|
|
||||||
if old_task != npc.current_task and old_target != &"":
|
if old_task != npc.current_task and old_target != &"":
|
||||||
@@ -679,6 +683,10 @@ func get_state_checksum() -> String:
|
|||||||
return create_state_record().to_json().sha256_text()
|
return create_state_record().to_json().sha256_text()
|
||||||
|
|
||||||
|
|
||||||
|
func get_latest_decision(npc_id: int) -> ActionSelectionResult:
|
||||||
|
return latest_decisions.get(npc_id)
|
||||||
|
|
||||||
|
|
||||||
func create_state_record() -> SimulationStateRecord:
|
func create_state_record() -> SimulationStateRecord:
|
||||||
var record := SimulationStateRecord.new()
|
var record := SimulationStateRecord.new()
|
||||||
var wander_streams: Array[Dictionary] = []
|
var wander_streams: Array[Dictionary] = []
|
||||||
@@ -747,6 +755,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
storage_states[storage_record.get_storage_id()] = storage_record
|
storage_states[storage_record.get_storage_id()] = storage_record
|
||||||
_sync_village_food()
|
_sync_village_food()
|
||||||
economic_events = record.economic_events.duplicate()
|
economic_events = record.economic_events.duplicate()
|
||||||
|
latest_decisions.clear()
|
||||||
|
|
||||||
npcs.clear()
|
npcs.clear()
|
||||||
for npc_record in record.npcs:
|
for npc_record in record.npcs:
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ extends RefCounted
|
|||||||
|
|
||||||
var action_id: StringName
|
var action_id: StringName
|
||||||
var duration_override := -1.0
|
var duration_override := -1.0
|
||||||
|
var reason: String
|
||||||
|
var scores: Dictionary
|
||||||
|
|
||||||
|
|
||||||
func _init(selected_action_id: StringName, selected_duration_override: float = -1.0) -> void:
|
func _init(
|
||||||
|
selected_action_id: StringName,
|
||||||
|
selected_duration_override: float = -1.0,
|
||||||
|
decision_reason: String = "",
|
||||||
|
decision_scores: Dictionary = {}
|
||||||
|
) -> void:
|
||||||
action_id = selected_action_id
|
action_id = selected_action_id
|
||||||
duration_override = selected_duration_override
|
duration_override = selected_duration_override
|
||||||
|
reason = decision_reason
|
||||||
|
scores = decision_scores.duplicate(true)
|
||||||
|
|||||||
@@ -7,24 +7,40 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
|
|||||||
return null
|
return null
|
||||||
if npc.is_starving:
|
if npc.is_starving:
|
||||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_EAT, 1.0)
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food"
|
||||||
|
)
|
||||||
if village.food > 0:
|
if village.food > 0:
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_WITHDRAW_FOOD, 1.0)
|
return ActionSelectionResult.new(
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD, 4.0)
|
SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food"
|
||||||
|
)
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_GATHER_FOOD, 4.0, "Starving; pantry is empty"
|
||||||
|
)
|
||||||
if npc.hunger > 75.0:
|
if npc.hunger > 75.0:
|
||||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_EAT)
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_EAT, -1.0, "Hungry and carrying food"
|
||||||
|
)
|
||||||
if village.food > 0:
|
if village.food > 0:
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_WITHDRAW_FOOD)
|
return ActionSelectionResult.new(
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD)
|
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Hungry; pantry has food"
|
||||||
|
)
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Hungry; pantry is empty"
|
||||||
|
)
|
||||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_DEPOSIT_FOOD)
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
|
||||||
|
)
|
||||||
if npc.energy < 25.0:
|
if npc.energy < 25.0:
|
||||||
return ActionSelectionResult.new(SimulationIds.ACTION_REST)
|
return ActionSelectionResult.new(
|
||||||
return ActionSelectionResult.new(_choose_best_work_action(npc, village))
|
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
||||||
|
)
|
||||||
|
return _choose_best_work_action(npc, village)
|
||||||
|
|
||||||
|
|
||||||
func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> StringName:
|
func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
|
||||||
var scores := {
|
var scores := {
|
||||||
SimulationIds.ACTION_GATHER_FOOD:
|
SimulationIds.ACTION_GATHER_FOOD:
|
||||||
_calculate_score(
|
_calculate_score(
|
||||||
@@ -80,7 +96,9 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> StringName:
|
|||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_action = action_id
|
best_action = action_id
|
||||||
best_score = score
|
best_score = score
|
||||||
return best_action
|
return ActionSelectionResult.new(
|
||||||
|
best_action, -1.0, "Highest current work utility", scores
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _calculate_score(
|
func _calculate_score(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ extends Resource
|
|||||||
|
|
||||||
@export var profession_id: StringName
|
@export var profession_id: StringName
|
||||||
@export var display_name: String
|
@export var display_name: String
|
||||||
|
@export var visual_color := Color.WHITE
|
||||||
|
@export var prop_color := Color(0.35, 0.25, 0.15, 1.0)
|
||||||
|
|
||||||
|
|
||||||
func validate() -> Array[String]:
|
func validate() -> Array[String]:
|
||||||
@@ -11,4 +13,6 @@ func validate() -> Array[String]:
|
|||||||
errors.append("profession_id is empty")
|
errors.append("profession_id is empty")
|
||||||
if display_name.is_empty():
|
if display_name.is_empty():
|
||||||
errors.append("display_name is empty for '%s'" % profession_id)
|
errors.append("display_name is empty for '%s'" % profession_id)
|
||||||
|
if visual_color.a <= 0.0:
|
||||||
|
errors.append("visual_color is transparent for '%s'" % profession_id)
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profession_id = &"farmer"
|
profession_id = &"farmer"
|
||||||
display_name = "Farmer"
|
display_name = "Farmer"
|
||||||
|
visual_color = Color(0.36, 0.62, 0.28, 1)
|
||||||
|
prop_color = Color(0.65, 0.45, 0.2, 1)
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profession_id = &"guard"
|
profession_id = &"guard"
|
||||||
display_name = "Guard"
|
display_name = "Guard"
|
||||||
|
visual_color = Color(0.25, 0.42, 0.68, 1)
|
||||||
|
prop_color = Color(0.72, 0.75, 0.78, 1)
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profession_id = &"scholar"
|
profession_id = &"scholar"
|
||||||
display_name = "Scholar"
|
display_name = "Scholar"
|
||||||
|
visual_color = Color(0.52, 0.34, 0.68, 1)
|
||||||
|
prop_color = Color(0.78, 0.66, 0.28, 1)
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profession_id = &"wanderer"
|
profession_id = &"wanderer"
|
||||||
display_name = "Wanderer"
|
display_name = "Wanderer"
|
||||||
|
visual_color = Color(0.72, 0.57, 0.3, 1)
|
||||||
|
prop_color = Color(0.38, 0.28, 0.19, 1)
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profession_id = &"woodcutter"
|
profession_id = &"woodcutter"
|
||||||
display_name = "Woodcutter"
|
display_name = "Woodcutter"
|
||||||
|
visual_color = Color(0.67, 0.34, 0.2, 1)
|
||||||
|
prop_color = Color(0.34, 0.22, 0.14, 1)
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ func _test_selection_and_execution_are_separate() -> void:
|
|||||||
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
|
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
|
||||||
"Selection should independently choose food retrieval"
|
"Selection should independently choose food retrieval"
|
||||||
)
|
)
|
||||||
|
_check(
|
||||||
|
not selection.reason.is_empty(),
|
||||||
|
"Selection should expose the real decision branch for presentation"
|
||||||
|
)
|
||||||
|
npc.hunger = 20.0
|
||||||
|
npc.energy = 80.0
|
||||||
|
var work_selection := selector.select_action(npc, village)
|
||||||
|
_check(
|
||||||
|
work_selection.scores.size() == 4,
|
||||||
|
"Ordinary work selection should expose all compared utility scores"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _test_target_resolution_and_travel_are_separate() -> void:
|
func _test_target_resolution_and_travel_are_separate() -> void:
|
||||||
|
|||||||
@@ -16,10 +16,38 @@ func _run() -> void:
|
|||||||
|
|
||||||
var manager: Node = main_scene.get_node("SimulationManager")
|
var manager: Node = main_scene.get_node("SimulationManager")
|
||||||
var view: Node = main_scene.get_node("WorldViewManager")
|
var view: Node = main_scene.get_node("WorldViewManager")
|
||||||
|
var ui: CanvasLayer = main_scene.get_node("UI")
|
||||||
manager.set_process(false)
|
manager.set_process(false)
|
||||||
var npc: SimNPC = manager.npcs[0]
|
var npc: SimNPC = manager.npcs[0]
|
||||||
var visual: Node3D = view.active_npc_visuals[npc.id]
|
var visual: Node3D = view.active_npc_visuals[npc.id]
|
||||||
visual.set_physics_process(false)
|
visual.set_physics_process(false)
|
||||||
|
var profession_definition := SimulationDefinitions.get_profession(npc.profession)
|
||||||
|
var body: MeshInstance3D = visual.get_node("MeshInstance3D")
|
||||||
|
_check(
|
||||||
|
body.material_override.albedo_color == profession_definition.visual_color,
|
||||||
|
"NPC body color should come from its profession definition"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
visual.get_node("ProfessionProp").mesh != null
|
||||||
|
and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
|
||||||
|
"NPC visual should expose a profession prop and readable label"
|
||||||
|
)
|
||||||
|
var test_decision := ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_GATHER_FOOD,
|
||||||
|
-1.0,
|
||||||
|
"Test shortage reason",
|
||||||
|
{SimulationIds.ACTION_GATHER_FOOD: 4.5}
|
||||||
|
)
|
||||||
|
manager.latest_decisions[npc.id] = test_decision
|
||||||
|
manager.emit_signal("npc_decision_recorded", npc, test_decision)
|
||||||
|
var inspector_label: Label = ui.get_node(
|
||||||
|
"NpcInspectorPanel/MarginContainer/NpcInspectorLabel"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
"Test shortage reason" in inspector_label.text
|
||||||
|
and npc.npc_name in inspector_label.text,
|
||||||
|
"NPC inspector should show the selected villager's real decision reason"
|
||||||
|
)
|
||||||
var carried_food: MeshInstance3D = visual.get_node("CarriedFood")
|
var carried_food: MeshInstance3D = visual.get_node("CarriedFood")
|
||||||
_check(not carried_food.visible, "NPC should begin without a carried-food prop")
|
_check(not carried_food.visible, "NPC should begin without a carried-food prop")
|
||||||
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
|
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
|
||||||
|
|||||||
@@ -38,11 +38,19 @@ func _test_registry_integrity() -> void:
|
|||||||
|
|
||||||
var profession_ids := SimulationDefinitions.get_profession_ids()
|
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||||
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
||||||
|
var profession_colors := {}
|
||||||
for profession_id in profession_ids:
|
for profession_id in profession_ids:
|
||||||
|
var profession := SimulationDefinitions.get_profession(profession_id)
|
||||||
_check(
|
_check(
|
||||||
SimulationDefinitions.get_profession(profession_id) != null,
|
profession != null,
|
||||||
"Profession lookup should return '%s'" % profession_id
|
"Profession lookup should return '%s'" % profession_id
|
||||||
)
|
)
|
||||||
|
if profession != null:
|
||||||
|
profession_colors[profession.visual_color.to_html()] = true
|
||||||
|
_check(
|
||||||
|
profession_colors.size() == profession_ids.size(),
|
||||||
|
"Each profession should have a distinct definition-backed visual color"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _test_definition_backed_behavior() -> void:
|
func _test_definition_backed_behavior() -> void:
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ const DEBUG_LOGS := false
|
|||||||
@export var simulation_manager: Node
|
@export var simulation_manager: Node
|
||||||
|
|
||||||
@onready var village_stats_label: Label = $VillagePanel/MarginContainer/VillageStatsLabel
|
@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
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -16,9 +20,34 @@ func _ready() -> void:
|
|||||||
simulation_manager.village_changed.connect(_on_village_changed)
|
simulation_manager.village_changed.connect(_on_village_changed)
|
||||||
else:
|
else:
|
||||||
push_error("VillageUI: SimulationManager has no village_changed signal")
|
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 "village" in simulation_manager:
|
if "village" in simulation_manager:
|
||||||
_on_village_changed(simulation_manager.village)
|
_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:
|
func _on_village_changed(village: SimVillage) -> void:
|
||||||
@@ -47,3 +76,73 @@ func _on_village_changed(village: SimVillage) -> void:
|
|||||||
village.knowledge_modifier
|
village.knowledge_modifier
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void:
|
||||||
|
_refresh_npc_inspector()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_state_restored() -> void:
|
||||||
|
selected_npc_index = 0
|
||||||
|
_refresh_npc_inspector()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_npc_inspector() -> void:
|
||||||
|
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] = []
|
||||||
|
for action_id in score_keys:
|
||||||
|
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)
|
||||||
|
npc_inspector_label.text = (
|
||||||
|
"%s · %s\n"
|
||||||
|
+ "Task: %s (%s)\n"
|
||||||
|
+ "Destination: %s\n"
|
||||||
|
+ "Hunger: %.0f Energy: %.0f\n"
|
||||||
|
+ "Carrying food: %.0f\n\n"
|
||||||
|
+ "Why\n%s%s\n\n"
|
||||||
|
+ "[Tab] Next villager"
|
||||||
|
) % [
|
||||||
|
npc.npc_name,
|
||||||
|
profession_name,
|
||||||
|
action_name,
|
||||||
|
npc.task_state,
|
||||||
|
destination_name,
|
||||||
|
npc.hunger,
|
||||||
|
npc.energy,
|
||||||
|
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
|
||||||
|
reason_text,
|
||||||
|
score_text
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user