feat: add nearby villager field notes
This commit is contained in:
@@ -171,6 +171,24 @@ State restore clears active feedback and re-derives the prompt from current
|
||||
simulation and loaded-world facts. The HUD is separate from development
|
||||
overlays so the embodied action remains legible in cinematic mode.
|
||||
|
||||
## Player villager-inspection contract
|
||||
|
||||
`WorldViewManager.find_nearest_active_npc_id()` resolves only loaded, living
|
||||
NPC presentations within the player's bounded range. It compares presentation
|
||||
positions and uses stable NPC ID order for equal distances; it does not inspect
|
||||
tasks or choose actions. `Player.get_nearby_villager_inspection()` reacquires
|
||||
that ID from the manager's current `SimNPC` records and constructs one
|
||||
ephemeral `VillagerInspectionResult` from the authoritative name, action, task
|
||||
state, exact target ID, and carried inventory.
|
||||
|
||||
The optional decision reason is honest only when the current transient
|
||||
`ActionSelectionResult` still names the NPC's live action. A missing or stale
|
||||
trace renders `Awaiting the next decision`. `VillagerFieldNoteHud` formats that
|
||||
result in a separate player-facing layer, so observation never changes `E`
|
||||
priority or execution. Visual unload removes the context, while restore
|
||||
reacquires the newly reconstructed NPC record and derives saved facts again;
|
||||
neither the selection, result, nor HUD enters simulation state or checksums.
|
||||
|
||||
## Compound animal-care delivery contract
|
||||
|
||||
NPC `feed_animal` remains one task and one exact animal claim across two travel
|
||||
|
||||
@@ -829,15 +829,22 @@ Completed:
|
||||
food sack; delivery consumes that unit and records the inventory-to-animal
|
||||
fact. One-unit contention, both route legs, and post-pickup continuation
|
||||
prove conservation without adding a saved phase field.
|
||||
45. `Jajce Villager Field Note 12`: a quiet bottom-left field note now selects
|
||||
the nearest loaded living villager with stable-ID tie order and presents
|
||||
their authoritative name, current action and task state, exact target, and
|
||||
carried inventory. A decision reason appears only while it matches the live
|
||||
action; unload clears the note and restore re-derives saved facts while
|
||||
awaiting the next transient reason. The note coexists with the `E` prompt,
|
||||
remains in cinematic mode, and adds no saved state or simulation mutation.
|
||||
|
||||
Next:
|
||||
|
||||
1. Give the player one restrained nearby-villager inspection context backed by
|
||||
the authoritative task, exact target, carried item, and recorded decision
|
||||
reason when it currently exists.
|
||||
2. Prove that the field note follows live task changes and, after restore,
|
||||
re-derives the persistent facts while honestly awaiting the next decision
|
||||
reason. It must not become dialogue, quest, or saved presentation state.
|
||||
1. Let an inspected villager who owns an open opportunity surface that exact
|
||||
village need plus the authoritative direct-help route, capable helper, or
|
||||
honest unavailable state that currently exists.
|
||||
2. Prove that the ordinary finite-resource-to-storage harvest resolves an
|
||||
available player route and updates the context without dialogue branching,
|
||||
acceptance state, rewards, a quest log, or saved presentation state.
|
||||
|
||||
Do not start with GIS data, a full city, a large asset pack, or more NPC
|
||||
mechanics. The next proof is a beautiful stage for the systems that already
|
||||
|
||||
@@ -965,16 +965,33 @@ the losing claim. A post-pickup restore preserves the inventory, exact goat,
|
||||
destination, event count, and final checksum without a new saved phase field
|
||||
or RNG draw.
|
||||
|
||||
The next bounded slice should advance Milestone 9 with player-facing villager
|
||||
inspection. One nearby-person context should show the authoritative name,
|
||||
current action, exact target, carried item, and current recorded decision reason
|
||||
when available. After restore it should honestly say that it is awaiting the
|
||||
next decision while the other facts re-derive. Do not add dialogue, trade,
|
||||
reputation, quest state, breeding, products, herd scheduling, animal indexing,
|
||||
or active/abstract animal LOD in that slice.
|
||||
Player-facing villager inspection now advances Milestone 9 without stealing the
|
||||
existing `E` action. A quiet cardless field note chooses the nearest loaded,
|
||||
living villager with stable-ID tie order, then re-reads that NPC's authoritative
|
||||
name, action, exact target, task state, and carried inventory. It includes the
|
||||
latest decision reason only while that trace still matches the live action.
|
||||
Presentation unload clears the note; restore reacquires the replacement NPC
|
||||
record, re-derives every saved fact, and honestly awaits the next ephemeral
|
||||
decision reason. The query and HUD add no mutation, RNG draw, or saved state.
|
||||
|
||||
The next bounded slice should move from observation to one honest social
|
||||
affordance. When the nearby inspected villager is the interested person for an
|
||||
open village opportunity, surface that exact need. Show the already-proven
|
||||
finite-resource-to-storage route only when the authoritative player-response
|
||||
query can currently derive it; otherwise name the capable helper or honestly
|
||||
say why direct help is unavailable. Let the ordinary finite-resource harvest
|
||||
resolve the need without adding a dialogue tree, quest acceptance, rewards, a
|
||||
quest log, reputation, a generic conversation framework, or saved presentation
|
||||
state in that slice.
|
||||
|
||||
Recently completed:
|
||||
|
||||
- `Jajce Villager Field Note 12`: a separate player-facing note selects the
|
||||
nearest loaded living villager and shows live name, action, task state, exact
|
||||
target, carried inventory, and a currently matching decision reason. It
|
||||
coexists with the `E` prompt, survives cinematic mode, clears on visual
|
||||
unload, and re-derives saved facts after restore without reviving transient
|
||||
reasoning or changing the schema.
|
||||
- `Jajce Care Delivery 11`: NPC `feed_animal` is now one compound, two-leg
|
||||
action from the real pantry through caretaker inventory to the exact reserved
|
||||
goat. Carried presentation, ordered transfer facts, late contention,
|
||||
|
||||
+16
-9
@@ -491,10 +491,11 @@ that receives the command.
|
||||
The UI subscribes to village, task, event, knowledge, relationship, and
|
||||
opportunity changes. It formats aggregate state and resolves known facts,
|
||||
relationship causes, and opportunity lifecycle details through manager queries
|
||||
without owning or recomputing simulation facts. `village_whisper_hud.gd` and
|
||||
`player_interaction_hud.gd` are separate transient player layers that format
|
||||
authoritative signals or the current exact context and remain independent of
|
||||
the development overlays.
|
||||
without owning or recomputing simulation facts. `village_whisper_hud.gd`,
|
||||
`player_interaction_hud.gd`, and `villager_field_note_hud.gd` are separate
|
||||
transient player layers. They format authoritative signals, the current exact
|
||||
action context, or one nearby villager's current facts while remaining
|
||||
independent of the development overlays.
|
||||
|
||||
## Current runtime flow
|
||||
|
||||
@@ -1023,11 +1024,17 @@ Milestone 9 context-action result shared by prompt and execution, with exact
|
||||
pantry feedback and no persistent UI state. NPC care now completes that
|
||||
physical proof with one compound pantry-to-inventory-to-goat action, exact
|
||||
claim continuity, visible carrying, late-contention safety, and post-pickup
|
||||
deterministic continuation. The next bounded proof should let the player
|
||||
inspect one nearby villager's real action, target, carried item, and recorded
|
||||
decision reason when available. After restore it should re-derive persistent
|
||||
facts and honestly await the next decision reason without introducing dialogue
|
||||
or saved UI state.
|
||||
deterministic continuation. Nearby villager inspection now selects one loaded
|
||||
living presentation, reacquires its authoritative simulation record, and shows
|
||||
the live action, task state, exact target, carried inventory, and only a
|
||||
currently matching decision reason. It remains separate from `E`, clears on
|
||||
presentation unload, and after restore re-derives saved facts while awaiting a
|
||||
new transient decision trace. The next bounded proof should let an inspected
|
||||
interested villager surface their exact open opportunity and whichever
|
||||
authoritative response currently exists: a finite-resource-to-storage player
|
||||
route, a capable helper, or an honest unavailable state. An available player
|
||||
route should resolve through the ordinary harvest path without dialogue
|
||||
branching, quest acceptance, rewards, or saved UI state.
|
||||
|
||||
The remaining simulation-garden target still aims for:
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[ext_resource type="PackedScene" path="res://player/PlayerVisual.tscn" id="14_player_visual"]
|
||||
[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"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
|
||||
radius = 0.4
|
||||
@@ -23,11 +24,12 @@ height = 1.7
|
||||
|
||||
[node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
|
||||
|
||||
[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "pantry_storage", "guard_site", "study_site")]
|
||||
[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "world_view_manager", "pantry_storage", "guard_site", "study_site")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
|
||||
script = ExtResource("1_h2yge")
|
||||
camera_rig = NodePath("../CameraRig")
|
||||
simulation_manager = NodePath("../SimulationManager")
|
||||
world_view_manager = NodePath("../WorldViewManager")
|
||||
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry")
|
||||
guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost")
|
||||
study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk")
|
||||
@@ -193,6 +195,101 @@ theme_override_font_sizes/font_size = 13
|
||||
text = "Context"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="VillagerInspectionLayer" type="CanvasLayer" parent="."]
|
||||
layer = 2
|
||||
|
||||
[node name="VillagerFieldNote" type="Control" parent="VillagerInspectionLayer" node_paths=PackedStringArray("player", "simulation_manager")]
|
||||
visible = false
|
||||
anchors_preset = 2
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 34.0
|
||||
offset_top = -194.0
|
||||
offset_right = 430.0
|
||||
offset_bottom = -46.0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("17_villager_note")
|
||||
player = NodePath("../../Player")
|
||||
simulation_manager = NodePath("../../SimulationManager")
|
||||
|
||||
[node name="Accent" type="ColorRect" parent="VillagerInspectionLayer/VillagerFieldNote"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 9
|
||||
anchor_bottom = 1.0
|
||||
offset_right = 2.0
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
color = Color(0.9, 0.63, 0.28, 0.92)
|
||||
|
||||
[node name="Copy" type="VBoxContainer" parent="VillagerInspectionLayer/VillagerFieldNote"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 14.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 1
|
||||
alignment = 1
|
||||
|
||||
[node name="Kicker" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.9, 0.63, 0.28, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.92)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 10
|
||||
text = "NEARBY VILLAGER"
|
||||
|
||||
[node name="Name" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/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 = 24
|
||||
text = "Villager"
|
||||
|
||||
[node name="Activity" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/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 = 5
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Current activity"
|
||||
|
||||
[node name="Target" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.82, 0.75, 0.63, 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 = "Target · None recorded"
|
||||
|
||||
[node name="Carrying" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.82, 0.75, 0.63, 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 = "Carrying · nothing"
|
||||
|
||||
[node name="Reason" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"]
|
||||
custom_minimum_size = Vector2(380, 0)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.88, 0.78, 0.6, 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 = "Why · Awaiting the next decision"
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="VillageWhisperLayer" type="CanvasLayer" parent="."]
|
||||
layer = 3
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
class_name VillagerInspectionResult
|
||||
extends RefCounted
|
||||
|
||||
const AWAITING_REASON := "Awaiting the next decision"
|
||||
|
||||
var npc_id: int
|
||||
var npc_name: String
|
||||
var action_id: StringName
|
||||
var action_name: String
|
||||
var task_state: StringName
|
||||
var target_id: StringName
|
||||
var target_name: String
|
||||
var carried_amounts: Dictionary
|
||||
var decision_reason: String
|
||||
var _has_decision_reason: bool
|
||||
|
||||
|
||||
func _init(
|
||||
inspection_npc_id: int,
|
||||
inspection_npc_name: String,
|
||||
inspection_action_id: StringName,
|
||||
inspection_action_name: String,
|
||||
inspection_task_state: StringName,
|
||||
inspection_target_id: StringName,
|
||||
inspection_target_name: String,
|
||||
inspection_carried_amounts: Dictionary,
|
||||
inspection_decision_reason := ""
|
||||
) -> void:
|
||||
npc_id = inspection_npc_id
|
||||
npc_name = inspection_npc_name
|
||||
action_id = inspection_action_id
|
||||
action_name = inspection_action_name
|
||||
task_state = inspection_task_state
|
||||
target_id = inspection_target_id
|
||||
target_name = inspection_target_name
|
||||
carried_amounts = inspection_carried_amounts.duplicate(true)
|
||||
_has_decision_reason = not inspection_decision_reason.strip_edges().is_empty()
|
||||
decision_reason = (inspection_decision_reason if _has_decision_reason else AWAITING_REASON)
|
||||
|
||||
|
||||
func has_decision_reason() -> bool:
|
||||
return _has_decision_reason
|
||||
|
||||
|
||||
func activity_text() -> String:
|
||||
return "%s · %s" % [action_name, _display_id(task_state)]
|
||||
|
||||
|
||||
func carrying_text() -> String:
|
||||
var item_ids: Array = carried_amounts.keys()
|
||||
item_ids.sort()
|
||||
var entries: Array[String] = []
|
||||
for item_id in item_ids:
|
||||
var amount := float(carried_amounts[item_id])
|
||||
if amount <= 0.0:
|
||||
continue
|
||||
var amount_text := (
|
||||
"%.0f" % amount if is_equal_approx(amount, roundf(amount)) else "%.1f" % amount
|
||||
)
|
||||
entries.append("%s %s" % [amount_text, _display_id(StringName(item_id)).to_lower()])
|
||||
return "nothing" if entries.is_empty() else " · ".join(entries)
|
||||
|
||||
|
||||
func cache_key() -> String:
|
||||
var carried_keys: Array = carried_amounts.keys()
|
||||
carried_keys.sort()
|
||||
var carried_parts: Array[String] = []
|
||||
for item_id in carried_keys:
|
||||
carried_parts.append("%s=%.3f" % [String(item_id), float(carried_amounts[item_id])])
|
||||
return (
|
||||
"|"
|
||||
. join(
|
||||
[
|
||||
str(npc_id),
|
||||
npc_name,
|
||||
String(action_id),
|
||||
action_name,
|
||||
String(task_state),
|
||||
String(target_id),
|
||||
target_name,
|
||||
",".join(carried_parts),
|
||||
decision_reason,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func _display_id(value: StringName) -> String:
|
||||
return String(value).replace("_", " ").capitalize()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dec7j7j08cipj
|
||||
@@ -8,6 +8,7 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool)
|
||||
@export var camera_rig: Node3D
|
||||
|
||||
@export var simulation_manager: Node
|
||||
@export var world_view_manager: Node
|
||||
|
||||
@export var pantry_storage: StorageNode
|
||||
@export var guard_site: ActivitySite
|
||||
@@ -15,6 +16,7 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool)
|
||||
|
||||
@export var stomach_capacity_for_food := 5
|
||||
@export var interaction_range := 3.0
|
||||
@export var villager_inspection_range := 3.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -115,6 +117,44 @@ func get_interaction_context() -> PlayerInteractionResult:
|
||||
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
|
||||
return VillagerInspectionResult.new(
|
||||
npc.id,
|
||||
npc.npc_name,
|
||||
npc.current_task,
|
||||
action_name,
|
||||
npc.task_state,
|
||||
npc.target_id,
|
||||
target_name,
|
||||
carried_amounts,
|
||||
reason
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
@@ -129,6 +169,44 @@ func _find_resource_node() -> ResourceNode:
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
extends SceneTree
|
||||
|
||||
const INSPECTION_REASON := "Inspection proof: pantry stores need a careful hand."
|
||||
const UPDATED_REASON := "Inspection proof: quiet study will help the village."
|
||||
const STUDY_DESK_ID := &"study_desk"
|
||||
|
||||
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 3:
|
||||
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 interaction_hud := main_scene.get_node_or_null(
|
||||
"PlayerInteractionLayer/PlayerInteractionHud"
|
||||
)
|
||||
var field_note := main_scene.get_node_or_null("VillagerInspectionLayer/VillagerFieldNote")
|
||||
manager.set_process(false)
|
||||
player.set_physics_process(false)
|
||||
_freeze_visuals(view)
|
||||
|
||||
_check(
|
||||
(
|
||||
manager.npcs.size() >= 2
|
||||
and view.has_method("find_nearest_active_npc_id")
|
||||
and player.has_method("get_nearby_villager_inspection")
|
||||
and interaction_hud != null
|
||||
and field_note != null
|
||||
and field_note.has_method("refresh_note")
|
||||
),
|
||||
"The real main scene should expose loaded villager inspection beside the E prompt",
|
||||
)
|
||||
if (
|
||||
manager.npcs.size() < 2
|
||||
or not view.has_method("find_nearest_active_npc_id")
|
||||
or not player.has_method("get_nearby_villager_inspection")
|
||||
or interaction_hud == null
|
||||
or field_note == null
|
||||
or not field_note.has_method("refresh_note")
|
||||
):
|
||||
_finish()
|
||||
return
|
||||
|
||||
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
|
||||
_check(pantry_node != null, "The inspection proof needs the authored village pantry")
|
||||
if pantry_node == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var first: SimNPC = manager.npcs[0]
|
||||
var second: SimNPC = manager.npcs[1]
|
||||
var lower_id_npc := first if first.id < second.id else second
|
||||
var higher_id_npc := second if lower_id_npc == first else first
|
||||
var player_position: Vector3 = pantry_node.get_interaction_position()
|
||||
player.global_position = player_position
|
||||
player.set("interaction_range", 3.0)
|
||||
_move_population_away(manager, view, player_position)
|
||||
|
||||
_set_loaded_position(manager, view, lower_id_npc, player_position + Vector3(1.5, 0.0, 0.0))
|
||||
_set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(-0.75, 0.0, 0.0))
|
||||
_check(
|
||||
view.find_nearest_active_npc_id(player_position, 3.0) == higher_id_npc.id,
|
||||
"Loaded villager discovery should choose the nearest active presentation",
|
||||
)
|
||||
|
||||
_set_loaded_position(manager, view, lower_id_npc, player_position + Vector3(1.0, 0.0, 0.0))
|
||||
_set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(-1.0, 0.0, 0.0))
|
||||
_check(
|
||||
view.find_nearest_active_npc_id(player_position, 3.0) == lower_id_npc.id,
|
||||
"Equal-distance loaded villagers should resolve by stable NPC ID",
|
||||
)
|
||||
|
||||
_set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(30.0, 0.0, 0.0))
|
||||
var selected := lower_id_npc
|
||||
_configure_deposit_inspection(manager, selected, pantry_node.get_interaction_position())
|
||||
_set_loaded_position(manager, view, selected, player_position + Vector3(1.0, 0.0, 0.0))
|
||||
|
||||
var pantry_context := player.call("get_interaction_context") as PlayerInteractionResult
|
||||
var inspection := player.call("get_nearby_villager_inspection") as VillagerInspectionResult
|
||||
field_note.call("refresh_note", true)
|
||||
interaction_hud.call("refresh_prompt", true)
|
||||
var labels := _get_note_labels(field_note)
|
||||
var interaction_action := interaction_hud.get_node("Copy/Action") as Label
|
||||
_check(
|
||||
(
|
||||
pantry_context != null
|
||||
and pantry_context.kind == PlayerInteractionResult.KIND_PANTRY
|
||||
and pantry_context.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
and pantry_context.prompt_text in interaction_action.text
|
||||
and inspection != null
|
||||
and field_note.visible
|
||||
),
|
||||
"The automatic villager field note should coexist with the existing pantry E action",
|
||||
)
|
||||
if inspection == null or labels.is_empty():
|
||||
_finish()
|
||||
return
|
||||
|
||||
_check_deposit_facts(inspection, selected)
|
||||
_check_rendered_deposit_facts(labels, inspection, selected)
|
||||
|
||||
var woodpile_node := (
|
||||
StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageNode
|
||||
)
|
||||
_check(woodpile_node != null, "The inspection proof needs the authored village woodpile")
|
||||
if woodpile_node == null:
|
||||
_finish()
|
||||
return
|
||||
selected.set_task(SimulationIds.ACTION_DEPOSIT_WOOD, 1000.0)
|
||||
selected.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
selected.travel_target_position = woodpile_node.get_interaction_position()
|
||||
selected.has_travel_target = true
|
||||
selected.inventory.clear()
|
||||
selected.add_inventory(SimulationIds.RESOURCE_WOOD, 2.0)
|
||||
manager.latest_decisions[selected.id] = (
|
||||
ActionSelectionResult
|
||||
. new(
|
||||
SimulationIds.ACTION_DEPOSIT_WOOD,
|
||||
-1.0,
|
||||
"Inspection proof: carry timber to the authored woodpile.",
|
||||
{SimulationIds.ACTION_DEPOSIT_WOOD: 5.0},
|
||||
)
|
||||
)
|
||||
field_note.call("refresh_note", true)
|
||||
var wood_inspection := player.call("get_nearby_villager_inspection") as VillagerInspectionResult
|
||||
_check(
|
||||
(
|
||||
wood_inspection != null
|
||||
and wood_inspection.target_id == SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
and wood_inspection.target_name == "Village Woodpile"
|
||||
and woodpile_node.display_name == "Village Woodpile"
|
||||
and String(labels["target"].text) == "Target · Village Woodpile"
|
||||
),
|
||||
"Wood delivery inspection should preserve the exact ID and authored woodpile name",
|
||||
)
|
||||
|
||||
selected.set_task(SimulationIds.ACTION_STUDY, 1000.0)
|
||||
selected.target_id = STUDY_DESK_ID
|
||||
selected.travel_target_position = (
|
||||
ActivitySite.get_by_id(STUDY_DESK_ID).get_interaction_position()
|
||||
)
|
||||
selected.has_travel_target = true
|
||||
selected.inventory.clear()
|
||||
selected.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
|
||||
(
|
||||
manager
|
||||
. emit_signal(
|
||||
"npc_inventory_changed",
|
||||
selected,
|
||||
SimulationIds.RESOURCE_WOOD,
|
||||
selected.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
|
||||
)
|
||||
)
|
||||
field_note.call("refresh_note", true)
|
||||
var stale_reason_inspection := (
|
||||
player.call("get_nearby_villager_inspection") as VillagerInspectionResult
|
||||
)
|
||||
_check(
|
||||
(
|
||||
stale_reason_inspection != null
|
||||
and stale_reason_inspection.action_id == SimulationIds.ACTION_STUDY
|
||||
and stale_reason_inspection.action_name == "Study"
|
||||
and stale_reason_inspection.task_state == SimNPC.TASK_STATE_TRAVELING
|
||||
and stale_reason_inspection.target_id == STUDY_DESK_ID
|
||||
and stale_reason_inspection.target_name == "Study Desk"
|
||||
and is_equal_approx(
|
||||
_carried_amount(stale_reason_inspection, SimulationIds.RESOURCE_FOOD), 0.0
|
||||
)
|
||||
and is_equal_approx(
|
||||
_carried_amount(stale_reason_inspection, SimulationIds.RESOURCE_WOOD), 1.0
|
||||
)
|
||||
and not stale_reason_inspection.has_decision_reason()
|
||||
and "await" in String(labels["reason"].text).to_lower()
|
||||
),
|
||||
"Live task, target, and inventory should update while a stale decision reason is withheld",
|
||||
)
|
||||
|
||||
manager.latest_decisions[selected.id] = (
|
||||
ActionSelectionResult
|
||||
. new(
|
||||
SimulationIds.ACTION_STUDY,
|
||||
-1.0,
|
||||
UPDATED_REASON,
|
||||
{SimulationIds.ACTION_STUDY: 4.0},
|
||||
)
|
||||
)
|
||||
field_note.call("refresh_note", true)
|
||||
var updated_inspection := (
|
||||
player.call("get_nearby_villager_inspection") as VillagerInspectionResult
|
||||
)
|
||||
_check(
|
||||
(
|
||||
updated_inspection != null
|
||||
and updated_inspection.has_decision_reason()
|
||||
and updated_inspection.decision_reason == UPDATED_REASON
|
||||
and UPDATED_REASON in labels["reason"].text
|
||||
and "wood" in updated_inspection.carrying_text().to_lower()
|
||||
and "1" in updated_inspection.carrying_text()
|
||||
and "Study" in labels["activity"].text
|
||||
and "Study Desk" in labels["target"].text
|
||||
and "wood" in String(labels["carrying"].text).to_lower()
|
||||
),
|
||||
"A matching live decision should join the updated authoritative field-note facts",
|
||||
)
|
||||
|
||||
var note_position_before_resize: Vector2 = field_note.position
|
||||
root.size += Vector2i(120, 80)
|
||||
await process_frame
|
||||
field_note.call("refresh_note", true)
|
||||
var resize_tween := field_note.get("motion_tween") as Tween
|
||||
if resize_tween != null:
|
||||
await resize_tween.finished
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(field_note.position.x, note_position_before_resize.x)
|
||||
and is_equal_approx(field_note.position.y, note_position_before_resize.y + 80.0)
|
||||
),
|
||||
"Field-note motion should preserve its bottom-anchored position after a window resize",
|
||||
)
|
||||
|
||||
field_note.set("player", null)
|
||||
field_note.call("refresh_note", true)
|
||||
var cancelled_exit := field_note.get("motion_tween") as Tween
|
||||
field_note.set("player", player)
|
||||
field_note.call("refresh_note", true)
|
||||
var reentry_tween := field_note.get("motion_tween") as Tween
|
||||
if reentry_tween != null:
|
||||
await reentry_tween.finished
|
||||
_check(
|
||||
(
|
||||
cancelled_exit != null
|
||||
and not cancelled_exit.is_valid()
|
||||
and field_note.visible
|
||||
and not bool(field_note.get("is_hiding"))
|
||||
and player.call("get_nearby_villager_inspection") != null
|
||||
),
|
||||
"Immediate re-entry should cancel the exit without leaving a suspended hide state",
|
||||
)
|
||||
|
||||
var checksum_before_queries: String = manager.get_state_checksum()
|
||||
var state_before_queries: String = manager.serialize_state()
|
||||
var events_before_queries: int = manager.economic_events.size()
|
||||
for _query in 3:
|
||||
view.find_nearest_active_npc_id(player_position, 3.0)
|
||||
player.call("get_nearby_villager_inspection")
|
||||
field_note.call("refresh_note", true)
|
||||
_check(
|
||||
(
|
||||
manager.get_state_checksum() == checksum_before_queries
|
||||
and manager.serialize_state() == state_before_queries
|
||||
and manager.economic_events.size() == events_before_queries
|
||||
and UPDATED_REASON not in state_before_queries
|
||||
),
|
||||
"Inspection queries and presentation refreshes must not mutate or serialize transient state",
|
||||
)
|
||||
|
||||
var selected_id := selected.id
|
||||
var selected_before_restore := selected
|
||||
var saved_json := state_before_queries
|
||||
_check(view.despawn_npc_visual(selected_id), "The selected villager visual should unload")
|
||||
await process_frame
|
||||
field_note.call("refresh_note", true)
|
||||
var hiding_tween := field_note.get("motion_tween") as Tween
|
||||
if hiding_tween != null:
|
||||
await hiding_tween.finished
|
||||
_check(
|
||||
(
|
||||
view.find_nearest_active_npc_id(player_position, 3.0) == -1
|
||||
and player.call("get_nearby_villager_inspection") == null
|
||||
and not field_note.visible
|
||||
and manager.get_state_checksum() == checksum_before_queries
|
||||
),
|
||||
"Unloading the selected presentation should clear only the ephemeral nearby field note",
|
||||
)
|
||||
|
||||
selected.current_task = SimulationIds.ACTION_IDLE
|
||||
selected.task_state = SimNPC.TASK_STATE_IDLE
|
||||
selected.target_id = &""
|
||||
selected.has_travel_target = false
|
||||
selected.inventory.clear()
|
||||
_check(
|
||||
manager.get_latest_decision(selected_id) != null,
|
||||
"The pre-restore proof should keep a transient decision available to discard",
|
||||
)
|
||||
_check(
|
||||
manager.restore_state_from_json(saved_json),
|
||||
"The authoritative inspected villager facts should restore through the existing schema",
|
||||
)
|
||||
await process_frame
|
||||
await physics_frame
|
||||
_freeze_visuals(view)
|
||||
var restored_npc := _find_npc(manager.npcs, selected_id)
|
||||
field_note.call("refresh_note", true)
|
||||
var restored_inspection := (
|
||||
player.call("get_nearby_villager_inspection") as VillagerInspectionResult
|
||||
)
|
||||
_check(
|
||||
(
|
||||
restored_npc != null
|
||||
and restored_npc != selected_before_restore
|
||||
and view.active_npc_visuals.has(selected_id)
|
||||
and restored_inspection != null
|
||||
and restored_inspection.npc_id == selected_id
|
||||
and restored_inspection.npc_name == restored_npc.npc_name
|
||||
and restored_inspection.action_id == SimulationIds.ACTION_STUDY
|
||||
and restored_inspection.action_name == "Study"
|
||||
and restored_inspection.task_state == SimNPC.TASK_STATE_TRAVELING
|
||||
and restored_inspection.target_id == STUDY_DESK_ID
|
||||
and restored_inspection.target_name == "Study Desk"
|
||||
and is_equal_approx(
|
||||
_carried_amount(restored_inspection, SimulationIds.RESOURCE_WOOD), 1.0
|
||||
)
|
||||
and manager.get_latest_decision(selected_id) == null
|
||||
and not restored_inspection.has_decision_reason()
|
||||
and restored_inspection.decision_reason == VillagerInspectionResult.AWAITING_REASON
|
||||
and (
|
||||
String(labels["reason"].text)
|
||||
== "Why · %s" % VillagerInspectionResult.AWAITING_REASON
|
||||
)
|
||||
),
|
||||
"Restore should reacquire the loaded villager and derive saved facts without reviving a reason",
|
||||
)
|
||||
if restored_npc == null or restored_inspection == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var demo_controller := main_scene.get_node("DemoController")
|
||||
var debug_ui := main_scene.get_node("UI") as CanvasLayer
|
||||
demo_controller.call("set_debug_overlay_visible", false)
|
||||
field_note.call("refresh_note", true)
|
||||
interaction_hud.call("refresh_prompt", true)
|
||||
_check(
|
||||
(
|
||||
not debug_ui.visible
|
||||
and field_note.visible
|
||||
and labels["kicker"].visible
|
||||
and labels["name"].visible
|
||||
and restored_npc.npc_name in labels["name"].text
|
||||
and interaction_hud.visible
|
||||
and pantry_context.prompt_text in interaction_action.text
|
||||
),
|
||||
"Cinematic mode should preserve both player-facing contexts while hiding debug UI",
|
||||
)
|
||||
|
||||
field_note.set("player", null)
|
||||
field_note.call("refresh_note", true)
|
||||
var teardown_tween := field_note.get("motion_tween") as Tween
|
||||
if teardown_tween != null:
|
||||
await teardown_tween.finished
|
||||
_check(
|
||||
not field_note.visible,
|
||||
"A missing player should complete one field-note exit instead of restarting it every frame",
|
||||
)
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _configure_deposit_inspection(manager: Node, npc: SimNPC, pantry_position: Vector3) -> void:
|
||||
manager.release_npc_reservation(npc.id)
|
||||
npc.set_task(SimulationIds.ACTION_DEPOSIT_FOOD, 1000.0)
|
||||
npc.target_id = SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
npc.travel_target_position = pantry_position
|
||||
npc.has_travel_target = true
|
||||
npc.inventory.clear()
|
||||
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
|
||||
manager.latest_decisions[npc.id] = (
|
||||
ActionSelectionResult
|
||||
. new(
|
||||
SimulationIds.ACTION_DEPOSIT_FOOD,
|
||||
-1.0,
|
||||
INSPECTION_REASON,
|
||||
{SimulationIds.ACTION_DEPOSIT_FOOD: 5.0},
|
||||
)
|
||||
)
|
||||
(
|
||||
manager
|
||||
. emit_signal(
|
||||
"npc_inventory_changed",
|
||||
npc,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _check_deposit_facts(inspection: VillagerInspectionResult, npc: SimNPC) -> void:
|
||||
_check(
|
||||
(
|
||||
inspection.npc_id == npc.id
|
||||
and inspection.npc_name == npc.npc_name
|
||||
and inspection.action_id == SimulationIds.ACTION_DEPOSIT_FOOD
|
||||
and inspection.action_name == "Deposit Food"
|
||||
and inspection.task_state == SimNPC.TASK_STATE_TRAVELING
|
||||
and inspection.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
and inspection.target_name == "Village Pantry"
|
||||
and is_equal_approx(_carried_amount(inspection, SimulationIds.RESOURCE_FOOD), 2.0)
|
||||
and inspection.has_decision_reason()
|
||||
and inspection.decision_reason == INSPECTION_REASON
|
||||
and "food" in inspection.carrying_text().to_lower()
|
||||
and "2" in inspection.carrying_text()
|
||||
),
|
||||
"The typed inspection should expose the selected villager's exact authoritative facts",
|
||||
)
|
||||
|
||||
|
||||
func _check_rendered_deposit_facts(
|
||||
labels: Dictionary, inspection: VillagerInspectionResult, npc: SimNPC
|
||||
) -> void:
|
||||
_check(
|
||||
(
|
||||
"nearby" in String(labels["kicker"].text).to_lower()
|
||||
and npc.npc_name in labels["name"].text
|
||||
and inspection.action_name in labels["activity"].text
|
||||
and "Travel" in labels["activity"].text
|
||||
and inspection.target_name in labels["target"].text
|
||||
and "food" in String(labels["carrying"].text).to_lower()
|
||||
and "2" in labels["carrying"].text
|
||||
and inspection.decision_reason in labels["reason"].text
|
||||
),
|
||||
"The restrained field note should render name, activity, target, carrying, and reason",
|
||||
)
|
||||
|
||||
|
||||
func _move_population_away(manager: Node, view: Node, origin: Vector3) -> void:
|
||||
manager.latest_decisions.clear()
|
||||
for index in manager.npcs.size():
|
||||
var npc: SimNPC = manager.npcs[index]
|
||||
_set_loaded_position(
|
||||
manager,
|
||||
view,
|
||||
npc,
|
||||
origin + Vector3(40.0 + float(index) * 2.0, 0.0, 20.0),
|
||||
)
|
||||
|
||||
|
||||
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 _get_note_labels(field_note: Node) -> Dictionary:
|
||||
var paths := {
|
||||
"kicker": "Copy/Kicker",
|
||||
"name": "Copy/Name",
|
||||
"activity": "Copy/Activity",
|
||||
"target": "Copy/Target",
|
||||
"carrying": "Copy/Carrying",
|
||||
"reason": "Copy/Reason",
|
||||
}
|
||||
var labels := {}
|
||||
for key in paths:
|
||||
var label := field_note.get_node_or_null(paths[key]) as Label
|
||||
_check(label != null, "Villager field note should expose %s" % paths[key])
|
||||
if label == null:
|
||||
return {}
|
||||
labels[key] = label
|
||||
return labels
|
||||
|
||||
|
||||
func _carried_amount(inspection: VillagerInspectionResult, item_id: StringName) -> float:
|
||||
if inspection.carried_amounts.has(item_id):
|
||||
return float(inspection.carried_amounts[item_id])
|
||||
return float(inspection.carried_amounts.get(String(item_id), 0.0))
|
||||
|
||||
|
||||
func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC:
|
||||
for npc in npcs:
|
||||
if npc.id == npc_id:
|
||||
return npc
|
||||
return null
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Player villager inspection passed")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://uiedaakmwr2r
|
||||
@@ -638,6 +638,7 @@ visible = false
|
||||
[node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
|
||||
position = Vector3(8, 0, -12)
|
||||
storage_id = &"village_woodpile"
|
||||
display_name = "Village Woodpile"
|
||||
|
||||
[node name="PantrySignPole" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
|
||||
position = Vector3(-1.05, 1.1, 1.25)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
class_name VillagerFieldNoteHud
|
||||
extends Control
|
||||
|
||||
const ENTER_OFFSET := 9.0
|
||||
const EXIT_OFFSET := 4.0
|
||||
|
||||
@export var player: Node
|
||||
@export var simulation_manager: Node
|
||||
|
||||
@onready var accent: ColorRect = $Accent
|
||||
@onready var copy: VBoxContainer = $Copy
|
||||
@onready var name_label: Label = $Copy/Name
|
||||
@onready var activity_label: Label = $Copy/Activity
|
||||
@onready var target_label: Label = $Copy/Target
|
||||
@onready var carrying_label: Label = $Copy/Carrying
|
||||
@onready var reason_label: Label = $Copy/Reason
|
||||
|
||||
var copy_resting_position: Vector2
|
||||
var current_context_key := ""
|
||||
var motion_tween: Tween
|
||||
var motion_version := 0
|
||||
var is_hiding := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
copy_resting_position = copy.position
|
||||
visible = false
|
||||
modulate.a = 0.0
|
||||
if simulation_manager != null and simulation_manager.has_signal("state_restored"):
|
||||
simulation_manager.state_restored.connect(_on_state_restored)
|
||||
call_deferred("refresh_note", true)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
refresh_note()
|
||||
|
||||
|
||||
func refresh_note(force: bool = false) -> void:
|
||||
if player == null or not player.has_method("get_nearby_villager_inspection"):
|
||||
current_context_key = ""
|
||||
_hide_note()
|
||||
return
|
||||
var context: VillagerInspectionResult = player.get_nearby_villager_inspection()
|
||||
if context == null:
|
||||
if force or not current_context_key.is_empty():
|
||||
current_context_key = ""
|
||||
_hide_note()
|
||||
return
|
||||
var next_key := context.cache_key()
|
||||
if not force and next_key == current_context_key:
|
||||
return
|
||||
current_context_key = next_key
|
||||
_present(context, true)
|
||||
|
||||
|
||||
func _present(context: VillagerInspectionResult, animate: bool) -> void:
|
||||
motion_version += 1
|
||||
if motion_tween != null and motion_tween.is_valid():
|
||||
motion_tween.kill()
|
||||
motion_tween = null
|
||||
is_hiding = false
|
||||
name_label.text = context.npc_name
|
||||
activity_label.text = context.activity_text()
|
||||
target_label.text = "Target · %s" % context.target_name
|
||||
carrying_label.text = "Carrying · %s" % context.carrying_text()
|
||||
reason_label.text = "Why · %s" % context.decision_reason
|
||||
visible = true
|
||||
accent.pivot_offset = accent.size * 0.5
|
||||
if not animate:
|
||||
copy.position = copy_resting_position
|
||||
modulate.a = 1.0
|
||||
accent.scale.y = 1.0
|
||||
return
|
||||
copy.position = copy_resting_position + Vector2(-ENTER_OFFSET, 0.0)
|
||||
modulate.a = 0.0
|
||||
accent.scale.y = 0.18
|
||||
motion_tween = create_tween().set_parallel(true)
|
||||
motion_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
motion_tween.tween_property(copy, "position", copy_resting_position, 0.2)
|
||||
motion_tween.tween_property(self, "modulate:a", 1.0, 0.16)
|
||||
motion_tween.tween_property(accent, "scale:y", 1.0, 0.26)
|
||||
|
||||
|
||||
func _hide_note() -> void:
|
||||
if not visible or is_hiding:
|
||||
return
|
||||
is_hiding = true
|
||||
motion_version += 1
|
||||
var hide_version := motion_version
|
||||
if motion_tween != null and motion_tween.is_valid():
|
||||
motion_tween.kill()
|
||||
motion_tween = null
|
||||
motion_tween = create_tween().set_parallel(true)
|
||||
motion_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
||||
motion_tween.tween_property(self, "modulate:a", 0.0, 0.14)
|
||||
motion_tween.tween_property(copy, "position:x", copy_resting_position.x - EXIT_OFFSET, 0.14)
|
||||
motion_tween.finished.connect(_on_hide_finished.bind(hide_version), CONNECT_ONE_SHOT)
|
||||
|
||||
|
||||
func _on_hide_finished(hide_version: int) -> void:
|
||||
if motion_version != hide_version:
|
||||
return
|
||||
motion_tween = null
|
||||
is_hiding = false
|
||||
if current_context_key.is_empty():
|
||||
visible = false
|
||||
|
||||
|
||||
func _on_state_restored() -> void:
|
||||
current_context_key = ""
|
||||
call_deferred("refresh_note", true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4hhfxayuo63l
|
||||
@@ -130,6 +130,26 @@ func reload_npc_visual(npc_id: int) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func find_nearest_active_npc_id(origin: Vector3, max_distance: float) -> int:
|
||||
if not origin.is_finite() or not is_finite(max_distance) or max_distance < 0.0:
|
||||
return -1
|
||||
var nearest_id := -1
|
||||
var nearest_distance_squared := max_distance * max_distance
|
||||
var sorted_ids: Array = active_npc_visuals.keys()
|
||||
sorted_ids.sort()
|
||||
for npc_id in sorted_ids:
|
||||
var visual := active_npc_visuals[npc_id] as Node3D
|
||||
if visual == null or not is_instance_valid(visual) or bool(visual.get("is_dead_visual")):
|
||||
continue
|
||||
var distance_squared := origin.distance_squared_to(visual.global_position)
|
||||
if distance_squared > nearest_distance_squared:
|
||||
continue
|
||||
if nearest_id < 0 or distance_squared < nearest_distance_squared:
|
||||
nearest_id = int(npc_id)
|
||||
nearest_distance_squared = distance_squared
|
||||
return nearest_id
|
||||
|
||||
|
||||
func _on_npc_target_requested(npc: SimNPC) -> void:
|
||||
var visual = active_npc_visuals.get(npc.id) as Node3D
|
||||
if visual == null:
|
||||
|
||||
Reference in New Issue
Block a user