feat: surface player standing and quest journal in the village HUD

This commit is contained in:
2026-08-08 17:48:13 +02:00
parent 9e6a6afcf5
commit 089fef9968
6 changed files with 419 additions and 1 deletions
+81
View File
@@ -15,6 +15,7 @@
[ext_resource type="Script" uid="uid://dwwxux24jc6yq" path="res://world/ui/village_whisper_hud.gd" id="15_whisper"]
[ext_resource type="Script" path="res://world/ui/player_interaction_hud.gd" id="16_interaction"]
[ext_resource type="Script" path="res://world/ui/villager_field_note_hud.gd" id="17_villager_note"]
[ext_resource type="Script" path="res://world/ui/quest_journal_hud.gd" id="18_quest_journal"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
radius = 0.4
@@ -390,6 +391,86 @@ text = "The pantry is empty."
horizontal_alignment = 1
vertical_alignment = 1
[node name="QuestJournalLayer" type="CanvasLayer" parent="."]
layer = 4
[node name="QuestJournal" type="Control" parent="QuestJournalLayer" node_paths=PackedStringArray("simulation_manager")]
visible = false
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -360.0
offset_top = -150.0
offset_right = -16.0
offset_bottom = -20.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 2
script = ExtResource("18_quest_journal")
simulation_manager = NodePath("../../SimulationManager")
[node name="Accent" type="ColorRect" parent="QuestJournalLayer/QuestJournal"]
layout_mode = 1
anchors_preset = 9
anchor_bottom = 1.0
offset_right = 2.0
grow_vertical = 2
mouse_filter = 2
color = Color(0.55, 0.42, 0.2, 0.9)
[node name="Copy" type="VBoxContainer" parent="QuestJournalLayer/QuestJournal"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 12.0
offset_top = 8.0
mouse_filter = 2
theme_override_constants/separation = 2
alignment = 2
[node name="Standing" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.98, 0.92, 0.79, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 18
text = "Standing · Stranger · 0/100"
[node name="QuestKicker" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.93, 0.7, 0.36, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9)
theme_override_constants/outline_size = 5
theme_override_font_sizes/font_size = 11
text = "ACTIVE QUEST"
[node name="Quest" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.9, 0.84, 0.7, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 13
text = "Amina · bring 1 food to the village pantry"
autowrap_mode = 2
[node name="Thanks" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
visible = false
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.95, 0.76, 0.42, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94)
theme_override_constants/outline_size = 5
theme_override_font_sizes/font_size = 13
text = "Gratitude"
horizontal_alignment = 1
autowrap_mode = 2
[node name="DemoController" type="Node" parent="." unique_id=644713371 node_paths=PackedStringArray("ui", "active_npcs_parent", "simulation_manager", "world_view_manager", "camera_rig", "player", "pantry_storage", "crisis_caption")]
script = ExtResource("13_demo")
ui = NodePath("../UI")
+173
View File
@@ -0,0 +1,173 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
for _frame in 10:
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
var view: Node = main_scene.get_node("WorldViewManager")
var player: CharacterBody3D = main_scene.get_node("Player")
var journal := main_scene.get_node("QuestJournalLayer/QuestJournal")
manager.set_process(false)
player.set_physics_process(false)
_freeze_visuals(view)
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
_set_loaded_position(manager, view, npc, Vector3(40.0 + npc.id * 10.0, 0.0, 30.0))
actor.position = Vector3.ZERO
interested.position = Vector3(4.0, 0.0, 0.0)
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
var quest: PlayerQuestRecord = manager.get_active_player_quest()
_check(quest != null, "An unassisted known need should generate a player quest")
if quest == null:
manager.set_process(true)
_finish()
return
_check(
(
quest.get_requester_npc_id() == interested.id
and journal.visible
and "Standing" in (journal.get_node("Copy/Standing") as Label).text
and "Stranger" in (journal.get_node("Copy/Standing") as Label).text
and interested.npc_name in (journal.get_node("Copy/Quest") as Label).text
),
"The quest journal should surface the standing tier and the named need",
)
var food_node: ResourceNode = _find_food_node(manager)
_check(food_node != null, "The runtime proof needs a stocked player-usable food source")
if food_node == null:
_finish()
return
var checksum_before: String = manager.get_state_checksum()
_check(
manager.harvest_resource_node(food_node) > 0.0,
"The player should resolve the quest through the ordinary finite harvest"
)
_check(
(
manager.get_active_player_quest() == null
and is_equal_approx(
manager.get_player_standing().get_standing(), quest.get_standing_reward()
)
and is_equal_approx(manager.get_player_standing().get_gratitude(interested.id), 0.1)
and manager.get_state_checksum() != checksum_before
),
"Resolving the need should complete the quest and grant standing + gratitude",
)
var saved_json: String = manager.serialize_state()
var restored := _create_restored_manager(saved_json)
_check(
(
restored != null
and restored.get_state_checksum() == manager.get_state_checksum()
and restored.get_player_standing().get_resolved_needs() == 1
and is_equal_approx(
restored.get_player_standing().get_standing(), quest.get_standing_reward()
)
),
"Completed quest standing should restore deterministically through the schema",
)
manager.set_process(true)
_finish()
func _create_restored_manager(saved_json: String) -> Node:
var restored: Node = load("res://simulation/SimulationManager.gd").new()
restored.simulation_seed = 909
restored.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(2.0, 0.0, 0.0),
Vector3(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
restored.home_positions = home_positions
root.add_child(restored)
restored.set_process(false)
if not restored.restore_state_from_json(saved_json):
return null
return restored
func _find_food_node(manager: Node) -> ResourceNode:
for node in ResourceNode.get_all():
var state: ResourceStateRecord = (
manager.get_resource_state(node.node_id) as ResourceStateRecord
)
if (
node.resource_id == SimulationIds.RESOURCE_FOOD
and state != null
and state.can_player_use_resource()
and state.can_extract()
and state.get_amount_remaining() >= 1.0
):
return node
return null
func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void:
for npc in [first, second]:
npc.set_task(SimulationIds.ACTION_PATROL)
npc.target_id = &"guard_post"
npc.start_working()
first.position = position
second.position = position + Vector3(1.0, 0.0, 0.0)
func _set_pantry_amount(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void:
manager.synchronize_npc_position(npc.id, position)
var visual := view.active_npc_visuals.get(npc.id) as Node3D
if visual != null:
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
visual.global_position = position
func _freeze_visuals(view: Node) -> void:
for visual in view.active_npc_visuals.values():
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Quest journal runtime surfacing passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://bl3m6a3k78cxe
+143
View File
@@ -0,0 +1,143 @@
class_name QuestJournalHud
extends Control
const ENTER_OFFSET := 8.0
const THANKS_SECONDS := 3.4
@export var simulation_manager: Node
@onready var accent: ColorRect = $Accent
@onready var copy: VBoxContainer = $Copy
@onready var standing_label: Label = $Copy/Standing
@onready var quest_label: Label = $Copy/Quest
@onready var quest_kicker: Label = $Copy/QuestKicker
@onready var thanks_label: Label = $Copy/Thanks
var resting_position: Vector2
var feedback_version := 0
var feedback_tween: Tween
var journal_tween: Tween
func _ready() -> void:
resting_position = position
visible = false
modulate.a = 0.0
if simulation_manager == null:
push_error("QuestJournalHud: simulation_manager missing")
return
if simulation_manager.has_signal("player_quest_opened"):
simulation_manager.player_quest_opened.connect(_on_quest_opened)
if simulation_manager.has_signal("player_quest_completed"):
simulation_manager.player_quest_completed.connect(_on_quest_completed)
if simulation_manager.has_signal("player_quest_expired"):
simulation_manager.player_quest_expired.connect(_on_quest_expired)
if simulation_manager.has_signal("player_standing_changed"):
simulation_manager.player_standing_changed.connect(_on_standing_changed)
if simulation_manager.has_signal("opportunity_resolved"):
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_state_restored)
_refresh()
func _on_quest_opened(_quest: PlayerQuestRecord) -> void:
_refresh()
func _on_quest_expired(_quest: PlayerQuestRecord) -> void:
_refresh()
func _on_standing_changed(_standing: PlayerStandingRecord) -> void:
_refresh()
func _on_opportunity_resolved(
_opportunity: OpportunityStateRecord, _cause_event: EconomicEventRecord
) -> void:
_refresh()
func _on_state_restored() -> void:
_refresh()
func _refresh() -> void:
if simulation_manager == null:
return
var standing: PlayerStandingRecord = simulation_manager.get_player_standing()
if standing == null:
return
var standing_text := (
"Standing · %s · %.0f/100" % [standing.get_tier_name(), standing.get_standing()]
)
standing_label.text = standing_text
var quest: PlayerQuestRecord = simulation_manager.get_active_player_quest()
if quest == null:
quest_kicker.text = ""
quest_label.text = ""
else:
var requester_name := _get_npc_name(quest.get_requester_npc_id())
quest_kicker.text = "ACTIVE QUEST"
quest_label.text = (
"%s · bring %.0f %s to the %s"
% [
requester_name,
quest.get_target_amount(),
String(quest.get_resource_id()).replace("_", " "),
String(quest.get_target_id()).replace("_", " "),
]
)
var has_content := not quest_kicker.text.is_empty() or not standing_text.is_empty()
if not has_content:
return
if not visible:
visible = true
modulate.a = 0.0
position = resting_position + Vector2(0.0, ENTER_OFFSET)
if journal_tween != null and journal_tween.is_valid():
journal_tween.kill()
journal_tween = create_tween().set_parallel(true)
journal_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
journal_tween.tween_property(self, "position", resting_position, 0.3)
journal_tween.tween_property(self, "modulate:a", 1.0, 0.22)
func _on_quest_completed(quest: PlayerQuestRecord) -> void:
_refresh()
var requester_name := _get_npc_name(quest.get_requester_npc_id())
_show_thanks(
"Gratitude",
(
"%s thanks you for your help. +%.0f Standing"
% [requester_name, quest.get_standing_reward()]
)
)
func _show_thanks(heading: String, message: String) -> void:
feedback_version += 1
var version := feedback_version
if feedback_tween != null and feedback_tween.is_valid():
feedback_tween.kill()
thanks_label.text = "%s\n%s" % [heading, message]
thanks_label.visible = true
thanks_label.modulate.a = 0.0
feedback_tween = create_tween().set_parallel(true)
feedback_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
feedback_tween.tween_property(thanks_label, "modulate:a", 1.0, 0.3)
feedback_tween.chain().tween_interval(THANKS_SECONDS)
feedback_tween.chain().tween_property(thanks_label, "modulate:a", 0.0, 0.5)
feedback_tween.chain().tween_callback(
func() -> void:
if version == feedback_version:
thanks_label.visible = false
)
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"
+1
View File
@@ -0,0 +1 @@
uid://bj0u6l60v2o5f
+20 -1
View File
@@ -86,6 +86,7 @@ func _on_village_changed(village: SimVillage) -> void:
if rates["wood_per_day"] > 0.01:
wood_rate = " (%.0f/d)" % rates["wood_per_day"]
var opportunity_text := _build_active_opportunity_display()
var standing_text := _build_standing_display()
village_stats_label.text = (
"""
Village [%s]
@@ -99,7 +100,7 @@ func _on_village_changed(village: SimVillage) -> void:
Food Mod: %.2f
Safety Mod: %.2f
Knowledge Mod: %.2f
%s%s
%s%s%s
"""
% [
speed_text,
@@ -113,12 +114,30 @@ func _on_village_changed(village: SimVillage) -> void:
village.food_modifier,
village.safety_modifier,
village.knowledge_modifier,
standing_text,
opportunity_text,
event_text,
]
)
func _build_standing_display() -> String:
if not simulation_manager.has_method("get_player_standing"):
return ""
var standing: PlayerStandingRecord = simulation_manager.get_player_standing()
if standing == null:
return ""
return (
"\n\nStanding\n%s · %.0f/100 · %d need%s met"
% [
standing.get_tier_name(),
standing.get_standing(),
standing.get_resolved_needs(),
"" if standing.get_resolved_needs() == 1 else "s",
]
)
func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void:
_refresh_npc_inspector()