feat: add exact player context actions

This commit is contained in:
Rijad Zuzo
2026-07-30 23:27:46 +02:00
parent d950c3da68
commit 7d410d8193
12 changed files with 762 additions and 67 deletions
+15
View File
@@ -149,6 +149,21 @@ These collaborators are `RefCounted` rule services, not additional scene-tree
managers. Further decomposition should follow measured pressure and a proven
gameplay consumer.
## Player context-interaction contract
`Player.get_interaction_context()` derives one ephemeral
`PlayerInteractionResult` in the same priority order used by `E`: hungry
animal, usable finite resource, guard site, study site, then pantry. The result
contains the exact stable target ID, action, display name, prompt copy,
availability, and loaded target reference. `try_interact()` executes that
single resolved result rather than maintaining a second target map.
`PlayerInteractionHud` formats the result and transient outcome feedback. It
does not select targets, mutate simulation state, or enter the save record.
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.
## Active-position contract
NpcVisual emits active position changes after successful movement.
+11 -5
View File
@@ -817,14 +817,20 @@ Completed:
preserves two exact reservations through save/restore, and feeds only the
named target. Private shelter ownership remains loaded-world context; no
herd system, animal index, or schema bump was introduced.
43. `Jajce Context Action 10`: the existing `E` interactions now derive one
exact ephemeral result shared by prompt and execution. A restrained,
cardless bottom-center HUD names the nearest hungry goat, shows real pantry
availability, reports success or blockage, remains visible in cinematic
mode, and also covers existing resource, pantry, guard, and study actions.
Restore discards transient feedback and re-derives context from live facts.
Next:
1. Playtest the named pair and record one concrete gameplay or scaling need
before extending the animal model.
2. Keep breeding, products, herd scheduling, animal spatial indexing, and
active/abstract simulation LOD deferred unless that evidence requires one
of them; otherwise return to the broader milestone sequence.
1. Make NPC feeding visibly collect one real pantry food into the caretaker's
existing inventory/carried-food presentation before delivery to the exact
reserved goat.
2. Prove conservation, claim continuity, and save/restore during both travel
legs; keep herd, product, indexing, and LOD systems deferred.
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
+19 -5
View File
@@ -947,14 +947,28 @@ Schema-v11 restore preserves both claim pairings and checksum. Loaded routine
sites now distinguish private shelter ownership from shared species context
without adding that authored-world fact to saved simulation state.
The immediate next step is a playtest-and-measure gate, not a herd abstraction.
Keep loaded-animal discovery linear and use the named pair to identify one
concrete gameplay need before adding breeding, products, herd scheduling,
animal indexing, or active/abstract animal LOD. If no such need appears, resume
the broader milestone sequence instead of adding animal framework by momentum.
The named-pair playtest gate exposed and closed its first embodied-use gap.
`PlayerInteractionResult` now derives one exact, ephemeral context from the
same priority and stable target used by `E`: named animal care, finite-resource
harvest, pantry eating, guard work, or study. A cardless bottom-center prompt
shows the action and real availability, remains visible in cinematic mode, and
briefly reports success or blockage. Restore clears transient feedback and
re-derives the current prompt without adding saved state or drawing RNG.
The next bounded slice should make NPC animal care physically honest:
`village_pantry -> NPC inventory -> exact named goat`. A caretaker should keep
the same animal claim, collect one real food into the existing carried-food
presentation, and deliver it without duplication across contention or
mid-route restore. Keep breeding, products, herd scheduling, animal indexing,
and active/abstract animal LOD deferred.
Recently completed:
- `Jajce Context Action 10`: one ephemeral result now keeps the displayed `E`
prompt and executed stable target aligned across existing player
interactions. Named goat care shows exact pantry availability and transient
success/blockage, survives cinematic mode, and re-derives after restore
without persistent UI state.
- `Jajce Goat Pair 09`: named goat Zora adds a second independent animal
record and private shelter while sharing Dunja's pasture. Stable nearest
targeting, simultaneous exact reservations, isolated feed mutation and
+15 -6
View File
@@ -228,6 +228,10 @@ plugin content, not game architecture.
- Pressing `E` near a berry bush or tree extracts its configured yield into the
village through the same `ResourceNode` contract used by NPCs.
- Guard, study, rest, and food interactions now use typed world sites.
- A cardless bottom-center `E` prompt derives the exact current animal,
resource, storage, or activity target and its availability. Named goat care
reports pantry cost plus transient success or blockage, remains in cinematic
mode, and never enters saved state.
- `Escape` releases captured mouse input.
### Village simulation
@@ -478,16 +482,19 @@ than duplicating these responsibilities across additional manager nodes.
These implement camera-relative character movement, physical interaction
proximity checks, ResourceNode harvesting, mouse capture, and the elevated
follow camera.
follow camera. `PlayerInteractionResult` is the ephemeral boundary shared by
the current `E` prompt and execution, so UI copy names the exact stable target
that receives the command.
### `world/ui/ui.gd`
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` is a
separate transient player layer that formats the same authoritative signals and
remains independent of the development overlays.
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.
## Current runtime flow
@@ -1005,8 +1012,10 @@ Dunja's pasture, nearest loaded care targeting supports two simultaneous exact
claims, and feeding one goat cannot mutate the other. World-schema v11
preserves both claim pairings without serializing shelter ownership. Do not
generalize animal discovery or introduce herd scheduling, products, breeding,
or active/abstract animal LOD until playtest or measurement exposes a concrete
need.
or active/abstract animal LOD. The named-pair gate instead produced one
Milestone 9 context-action result shared by prompt and execution, with exact
pantry feedback and no persistent UI state. The next animal-facing proof should
make NPC care visibly carry one real pantry food to its exact reserved goat.
The remaining simulation-garden target still aims for:
+66
View File
@@ -13,6 +13,7 @@
[ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"]
[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"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
radius = 0.4
@@ -127,6 +128,71 @@ offset_right = 940.0
offset_bottom = 66.0
script = ExtResource("12_timedial")
[node name="PlayerInteractionLayer" type="CanvasLayer" parent="."]
layer = 2
[node name="PlayerInteractionHud" type="Control" parent="PlayerInteractionLayer" node_paths=PackedStringArray("player", "simulation_manager")]
visible = false
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -290.0
offset_top = -108.0
offset_right = 290.0
offset_bottom = -42.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
script = ExtResource("16_interaction")
player = NodePath("../../Player")
simulation_manager = NodePath("../../SimulationManager")
[node name="Accent" type="ColorRect" parent="PlayerInteractionLayer/PlayerInteractionHud"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 2.0
grow_horizontal = 2
mouse_filter = 2
color = Color(0.9, 0.63, 0.28, 0.95)
[node name="Copy" type="VBoxContainer" parent="PlayerInteractionLayer/PlayerInteractionHud"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 8.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_constants/separation = 2
alignment = 1
[node name="Action" type="Label" parent="PlayerInteractionLayer/PlayerInteractionHud/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_colors/font_shadow_color = Color(0.02, 0.01, 0.005, 0.7)
theme_override_constants/outline_size = 7
theme_override_constants/shadow_offset_x = 1
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 20
text = "E · Interact"
horizontal_alignment = 1
[node name="Detail" type="Label" parent="PlayerInteractionLayer/PlayerInteractionHud/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.85, 0.77, 0.64, 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 = 13
text = "Context"
horizontal_alignment = 1
[node name="VillageWhisperLayer" type="CanvasLayer" parent="."]
layer = 3
+57
View File
@@ -0,0 +1,57 @@
class_name PlayerInteractionResult
extends RefCounted
const KIND_ANIMAL := &"animal"
const KIND_RESOURCE := &"resource"
const KIND_PANTRY := &"pantry"
const KIND_GUARD := &"guard"
const KIND_STUDY := &"study"
var kind: StringName
var action_id: StringName
var target_id: StringName
var display_name: String
var prompt_text: String
var detail_text: String
var blocked_reason: String
var target_node: Node
func _init(
interaction_kind: StringName,
interaction_action_id: StringName,
interaction_target_id: StringName,
interaction_display_name: String,
interaction_prompt_text: String,
interaction_detail_text: String,
interaction_target_node: Node,
interaction_blocked_reason := ""
) -> void:
kind = interaction_kind
action_id = interaction_action_id
target_id = interaction_target_id
display_name = interaction_display_name
prompt_text = interaction_prompt_text
detail_text = interaction_detail_text
target_node = interaction_target_node
blocked_reason = interaction_blocked_reason
func is_available() -> bool:
return blocked_reason.is_empty()
func cache_key() -> String:
return (
"|"
. join(
[
String(kind),
String(action_id),
String(target_id),
prompt_text,
detail_text,
blocked_reason,
]
)
)
+1
View File
@@ -0,0 +1 @@
uid://bw2ng0q0wm030
+186 -52
View File
@@ -1,5 +1,7 @@
extends CharacterBody3D
signal interaction_feedback(heading: String, message: String, succeeded: bool)
@export var move_speed := 7.0
@export var acceleration := 18.0
@export var rotation_speed := 12.0
@@ -56,73 +58,205 @@ func _unhandled_input(event: InputEvent) -> void:
func try_interact() -> void:
if simulation_manager == null:
var context := get_interaction_context()
if context == null:
return
if try_feed_animal():
return
if try_harvest_resource_node():
return
if is_near_activity_site(guard_site):
match context.kind:
PlayerInteractionResult.KIND_ANIMAL:
_execute_animal_interaction(context)
PlayerInteractionResult.KIND_RESOURCE:
_execute_resource_interaction(context)
PlayerInteractionResult.KIND_GUARD:
simulation_manager.add_safety(3.0)
print("Player helped guard the village.")
elif is_near_activity_site(study_site):
interaction_feedback.emit(
"Village guarded", "Safety increased through real guard-post work.", true
)
PlayerInteractionResult.KIND_STUDY:
simulation_manager.add_knowledge(2.0)
print("Player shared knowledge.")
elif is_near_storage(pantry_storage):
simulation_manager.eat_food(stomach_capacity_for_food)
print("Player ate food from the village supply.")
interaction_feedback.emit(
"Knowledge shared", "Village knowledge increased at the study desk.", true
)
PlayerInteractionResult.KIND_PANTRY:
_execute_pantry_interaction(context)
func try_feed_animal() -> bool:
func get_interaction_context() -> PlayerInteractionResult:
if simulation_manager == null:
return null
var animal := _find_feed_animal()
if animal != null:
return _build_animal_context(animal)
var resource := _find_resource_node()
if resource != null:
return _build_resource_context(resource)
if is_near_activity_site(guard_site):
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_GUARD,
SimulationIds.ACTION_PATROL,
guard_site.site_id,
guard_site.display_name,
"Help guard the village",
"Work here · +3 safety",
guard_site
)
if is_near_activity_site(study_site):
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_STUDY,
SimulationIds.ACTION_STUDY,
study_site.site_id,
study_site.display_name,
"Share knowledge",
"Work here · +2 knowledge",
study_site
)
if is_near_storage(pantry_storage):
return _build_pantry_context()
return null
func _find_feed_animal() -> AnimalNode:
if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null:
push_error("Player: SimulationManager has no animal-care service")
return false
var node: AnimalNode = simulation_manager.animal_care.find_node_for_player(
global_position, interaction_range
)
if node == null:
return false
if not simulation_manager.has_method("feed_animal"):
push_error("Player: SimulationManager cannot feed AnimalNodes")
return true
if simulation_manager.feed_animal(node.animal_id, -1):
print("Player fed %s one food from the village pantry." % node.display_name)
else:
print("%s is hungry, but the pantry has no food to spare." % node.display_name)
return true
return null
return simulation_manager.animal_care.find_node_for_player(global_position, interaction_range)
func try_harvest_resource_node() -> bool:
func _find_resource_node() -> ResourceNode:
if not simulation_manager.has_method("find_resource_node_for_player"):
push_error("Player: SimulationManager cannot find ResourceNodes")
return false
var node: ResourceNode = simulation_manager.find_resource_node_for_player(
global_position, interaction_range
)
if node == null:
return false
return null
return simulation_manager.find_resource_node_for_player(global_position, interaction_range)
if not node.is_enabled():
print("%s is unavailable." % node.node_id)
return true
if node.is_depleted():
print("%s is depleted." % node.node_id)
return true
if not simulation_manager.has_method("harvest_resource_node"):
push_error("Player: SimulationManager cannot harvest ResourceNodes")
return true
var extracted: float = simulation_manager.harvest_resource_node(node)
if extracted > 0.0:
print("Player gathered %s %s from %s." % [extracted, node.resource_id, node.node_id])
func _build_animal_context(node: AnimalNode) -> PlayerInteractionResult:
var cost := _get_animal_feed_cost()
var pantry_food := _get_pantry_food()
var blocked_reason := ""
var detail := "%.0f pantry food · %.0f available" % [cost, pantry_food]
if pantry_food < cost:
if pantry_food <= 0.0:
blocked_reason = "The village pantry is empty."
detail = "Pantry empty · needs %.0f food" % cost
else:
print("%s could not be harvested." % node.node_id)
return true
blocked_reason = "The village pantry does not have enough food."
detail = "Needs %.0f pantry food · %.1f available" % [cost, pantry_food]
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_ANIMAL,
SimulationIds.ACTION_FEED_ANIMAL,
node.animal_id,
node.display_name,
"Feed %s" % node.display_name,
detail,
node,
blocked_reason
)
func _build_resource_context(node: ResourceNode) -> PlayerInteractionResult:
var definition := SimulationDefinitions.get_action(node.action_id)
var prompt := (
definition.display_name if definition != null else String(node.action_id).capitalize()
)
var display_name := String(node.node_id).replace("_", " ").capitalize()
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_RESOURCE,
node.action_id,
node.node_id,
display_name,
prompt,
"%s · %.1f remaining" % [display_name, node.get_amount_remaining()],
node
)
func _build_pantry_context() -> PlayerInteractionResult:
var pantry_food := _get_pantry_food()
var blocked_reason := "" if pantry_food > 0.0 else "The village pantry is empty."
var detail := "%.0f food available" % pantry_food
if not blocked_reason.is_empty():
detail = "Pantry empty"
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_PANTRY,
SimulationIds.ACTION_EAT,
SimulationIds.STORAGE_VILLAGE_PANTRY,
"Village pantry",
"Eat from the pantry",
detail,
pantry_storage,
blocked_reason
)
func _execute_animal_interaction(context: PlayerInteractionResult) -> void:
var node := context.target_node as AnimalNode
if node == null or not context.is_available():
interaction_feedback.emit(
"%s is still hungry" % context.display_name,
(
context.blocked_reason
if not context.blocked_reason.is_empty()
else "Animal care is unavailable."
),
false
)
return
if not simulation_manager.has_method("feed_animal"):
push_error("Player: SimulationManager cannot feed AnimalNodes")
return
if simulation_manager.feed_animal(context.target_id, -1):
interaction_feedback.emit(
"%s is fed" % context.display_name,
"%.0f food moved from the village pantry." % _get_animal_feed_cost(),
true
)
return
interaction_feedback.emit(
"%s is still hungry" % context.display_name, "Animal care could not be completed.", false
)
func _execute_resource_interaction(context: PlayerInteractionResult) -> void:
var node := context.target_node as ResourceNode
if node == null or not simulation_manager.has_method("harvest_resource_node"):
push_error("Player: SimulationManager cannot harvest the resolved ResourceNode")
return
var extracted: float = simulation_manager.harvest_resource_node(node)
if extracted <= 0.0:
interaction_feedback.emit(
"%s is unavailable" % context.display_name, "Nothing could be gathered.", false
)
return
var destination := "pantry" if node.resource_id == SimulationIds.RESOURCE_FOOD else "woodpile"
interaction_feedback.emit(
"%s gathered" % String(node.resource_id).capitalize(),
"%.1f moved to the village %s." % [extracted, destination],
true
)
func _execute_pantry_interaction(context: PlayerInteractionResult) -> void:
if not context.is_available():
interaction_feedback.emit("The pantry is empty", context.blocked_reason, false)
return
var food_before := _get_pantry_food()
simulation_manager.eat_food(stomach_capacity_for_food)
var consumed := food_before - _get_pantry_food()
interaction_feedback.emit(
"Ate from the pantry", "%.0f village food consumed." % consumed, consumed > 0.0
)
func _get_animal_feed_cost() -> float:
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
return definition.completion_cost_amount if definition != null else 1.0
func _get_pantry_food() -> float:
if not simulation_manager.has_method("get_pantry"):
return 0.0
var pantry: StorageStateRecord = simulation_manager.get_pantry()
return pantry.get_amount(SimulationIds.RESOURCE_FOOD) if pantry != null else 0.0
func is_near_storage(storage_node: StorageNode) -> bool:
+254
View File
@@ -0,0 +1,254 @@
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
var manager: Node = main_scene.get_node("SimulationManager")
manager.set_process(false)
var player := main_scene.get_node("Player")
player.set_physics_process(false)
for _frame in 3:
await physics_frame
var dunja_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_DUNJA)
var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA)
var dunja_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
var hud := main_scene.get_node_or_null("PlayerInteractionLayer/PlayerInteractionHud")
_check(
(
dunja_node != null
and zora_node != null
and dunja_state != null
and zora_state != null
and hud != null
),
"The real main scene should expose both named goats and the player interaction HUD",
)
if (
dunja_node == null
or zora_node == null
or dunja_state == null
or zora_state == null
or hud == null
):
_finish()
return
dunja_node.set_physics_process(false)
zora_node.set_physics_process(false)
dunja_state.set_hunger(72.0)
zora_state.set_hunger(72.0)
var dunja_interaction_position := dunja_node.get_interaction_position()
var zora_interaction_position := zora_node.get_interaction_position()
var tie_position := (dunja_interaction_position + zora_interaction_position) * 0.5
var pair_range := dunja_interaction_position.distance_to(zora_interaction_position) + 0.5
player.set("interaction_range", pair_range)
player.global_position = zora_interaction_position
var nearest_context := player.call("get_interaction_context") as PlayerInteractionResult
_check(
(
nearest_context != null
and nearest_context.kind == PlayerInteractionResult.KIND_ANIMAL
and nearest_context.action_id == SimulationIds.ACTION_FEED_ANIMAL
and nearest_context.target_id == SimulationIds.ANIMAL_ZORA
and nearest_context.display_name == "Zora"
and nearest_context.target_node == zora_node
and nearest_context.is_available()
and "Zora" in nearest_context.prompt_text
),
"With both hungry goats in range, the context should name exact nearest goat Zora",
)
hud.call("refresh_prompt")
var action_label := hud.get_node("Copy/Action") as Label
var detail_label := hud.get_node("Copy/Detail") as Label
_check(
nearest_context != null and action_label != null and detail_label != null,
"The prompt proof needs a resolved context and both public HUD labels",
)
if nearest_context == null or action_label == null or detail_label == null:
_finish()
return
_check(
(
nearest_context.prompt_text in action_label.text
and detail_label.text == nearest_context.detail_text
),
"The HUD prompt should render the same exact context that execution will use",
)
player.global_position = tie_position
var tie_context := player.call("get_interaction_context") as PlayerInteractionResult
_check(
(
tie_context != null
and tie_position.distance_to(dunja_interaction_position) <= pair_range
and tie_position.distance_to(zora_interaction_position) <= pair_range
and tie_context.target_id == SimulationIds.ANIMAL_DUNJA
and tie_context.display_name == "Dunja"
and tie_context.target_node == dunja_node
),
"An equal-distance pair should resolve by stable animal ID, choosing Dunja",
)
player.global_position = zora_interaction_position
var execution_context := player.call("get_interaction_context") as PlayerInteractionResult
var pantry: StorageStateRecord = manager.get_pantry()
var pantry_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
var dunja_hunger_before := dunja_state.get_hunger()
var zora_hunger_before := zora_state.get_hunger()
var events_before: int = manager.economic_events.size()
player.call("try_interact")
var feed_event := _latest_feed_event(manager)
_check(
(
execution_context != null
and execution_context.target_id == SimulationIds.ANIMAL_ZORA
and is_equal_approx(dunja_state.get_hunger(), dunja_hunger_before)
and is_equal_approx(
zora_state.get_hunger(), zora_hunger_before - AnimalStateRecord.HUNGER_RELIEF
)
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before - 1.0)
and manager.economic_events.size() == events_before + 1
),
"Interaction should feed the prompted Zora only and consume one real pantry food",
)
_check(
(
feed_event != null
and int(feed_event.data["actor_id"]) == -1
and StringName(feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
and StringName(feed_event.data["destination_id"]) == SimulationIds.ANIMAL_ZORA
and StringName(feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and is_equal_approx(float(feed_event.data["amount"]), 1.0)
and feed_event.get_world_position().is_equal_approx(zora_state.get_position())
),
"The player feed event should preserve its exact pantry, named target, amount, and position",
)
_check(
bool(hud.get("feedback_active")) and bool(hud.get("feedback_succeeded")),
"A successful exact interaction should surface transient positive HUD feedback",
)
dunja_state.set_hunger(72.0)
zora_state.set_hunger(72.0)
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
player.set("interaction_range", 100.0)
player.global_position = zora_node.get_interaction_position()
var blocked_context := player.call("get_interaction_context") as PlayerInteractionResult
hud.call("refresh_prompt")
_check(
(
blocked_context != null
and blocked_context.target_id == SimulationIds.ANIMAL_ZORA
and blocked_context.display_name == "Zora"
and not blocked_context.is_available()
and not blocked_context.blocked_reason.is_empty()
and "Zora" in (blocked_context.prompt_text + blocked_context.detail_text)
and (
"pantry"
in (blocked_context.blocked_reason + blocked_context.detail_text).to_lower()
)
and blocked_context.prompt_text in action_label.text
and detail_label.text == blocked_context.detail_text
),
"An empty pantry should show a named blocked Zora prompt before input",
)
var blocked_dunja_hunger := dunja_state.get_hunger()
var blocked_zora_hunger := zora_state.get_hunger()
var blocked_event_count: int = manager.economic_events.size()
var blocked_safety: float = manager.village.safety
var blocked_knowledge: float = manager.village.knowledge
player.call("try_interact")
_check(
(
is_equal_approx(dunja_state.get_hunger(), blocked_dunja_hunger)
and is_equal_approx(zora_state.get_hunger(), blocked_zora_hunger)
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and manager.economic_events.size() == blocked_event_count
and is_equal_approx(manager.village.safety, blocked_safety)
and is_equal_approx(manager.village.knowledge, blocked_knowledge)
),
"A blocked animal interaction should consume input without mutation, events, or fallthrough",
)
_check(
bool(hud.get("feedback_active")) and not bool(hud.get("feedback_succeeded")),
"The blocked exact interaction should surface transient negative HUD feedback",
)
var blocked_json: String = manager.serialize_state()
dunja_state.set_hunger(20.0)
zora_state.set_hunger(20.0)
pantry.deposit(SimulationIds.RESOURCE_FOOD, 2.0)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
_check(
manager.restore_state_from_json(blocked_json),
"The blocked two-goat context should restore through the complete state schema",
)
dunja_state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
zora_state = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
pantry = manager.get_pantry()
var restored_context := player.call("get_interaction_context") as PlayerInteractionResult
_check(
(
not bool(hud.get("feedback_active"))
and restored_context != null
and restored_context.target_id == SimulationIds.ANIMAL_ZORA
and not restored_context.is_available()
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and is_equal_approx(dunja_state.get_hunger(), blocked_dunja_hunger)
and is_equal_approx(zora_state.get_hunger(), blocked_zora_hunger)
and restored_context.prompt_text in action_label.text
and detail_label.text == restored_context.detail_text
),
"Restore should clear transient feedback and re-derive the named blocked prompt",
)
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)
_check(
(
not debug_ui.visible
and bool(hud.get("visible"))
and action_label.visible
and not action_label.text.is_empty()
),
"The player interaction HUD should remain visible when development overlays are hidden",
)
_finish()
func _latest_feed_event(manager: Node) -> EconomicEventRecord:
for index in range(manager.economic_events.size() - 1, -1, -1):
var event := manager.economic_events[index] as EconomicEventRecord
if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED:
return event
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 context interaction passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://47l78j2djkv0
+137
View File
@@ -0,0 +1,137 @@
class_name PlayerInteractionHud
extends Control
const FEEDBACK_SECONDS := 2.0
const ENTER_OFFSET := 7.0
const ACCENT_READY := Color(0.9, 0.63, 0.28, 0.95)
const ACCENT_SUCCESS := Color(0.55, 0.78, 0.38, 0.95)
const ACCENT_BLOCKED := Color(0.82, 0.4, 0.24, 0.95)
@export var player: Node
@export var simulation_manager: Node
@onready var accent: ColorRect = $Accent
@onready var action_label: Label = $Copy/Action
@onready var detail_label: Label = $Copy/Detail
var resting_position: Vector2
var motion_tween: Tween
var current_context_key := ""
var feedback_version := 0
var feedback_active := false
var feedback_succeeded := false
func _ready() -> void:
resting_position = position
_reset_transient_state()
if player == null or not player.has_signal("interaction_feedback"):
push_error("PlayerInteractionHud: player interaction feedback is unavailable")
return
player.interaction_feedback.connect(_on_interaction_feedback)
if simulation_manager != null and simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_state_restored)
call_deferred("refresh_prompt", true)
func _process(_delta: float) -> void:
if not feedback_active:
refresh_prompt()
func refresh_prompt(force: bool = false) -> void:
if player == null or not player.has_method("get_interaction_context"):
_hide_prompt()
return
var context: PlayerInteractionResult = player.get_interaction_context()
if context == null:
if force or not current_context_key.is_empty():
current_context_key = ""
_hide_prompt()
return
var next_key := context.cache_key()
if not force and next_key == current_context_key:
return
current_context_key = next_key
feedback_succeeded = false
_present(
"E · %s" % context.prompt_text,
context.detail_text,
ACCENT_READY if context.is_available() else ACCENT_BLOCKED,
true
)
func _on_interaction_feedback(heading: String, message: String, succeeded: bool) -> void:
feedback_version += 1
var version := feedback_version
feedback_active = true
feedback_succeeded = succeeded
current_context_key = ""
_present(heading, message, ACCENT_SUCCESS if succeeded else ACCENT_BLOCKED, true)
_resume_prompt_after_feedback(version)
func _resume_prompt_after_feedback(version: int) -> void:
await get_tree().create_timer(FEEDBACK_SECONDS).timeout
if version != feedback_version or not is_inside_tree():
return
feedback_active = false
refresh_prompt(true)
func _on_state_restored() -> void:
_reset_transient_state()
refresh_prompt(true)
func _present(heading: String, detail: String, accent_color: Color, animate: bool) -> void:
if motion_tween != null:
motion_tween.kill()
action_label.text = heading
detail_label.text = detail
accent.color = accent_color
visible = true
accent.pivot_offset = accent.size * 0.5
if not animate:
position = resting_position
modulate.a = 1.0
accent.scale.x = 1.0
return
position = resting_position + Vector2(0.0, ENTER_OFFSET)
modulate.a = 0.0
accent.scale.x = 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(self, "position", resting_position, 0.22)
motion_tween.tween_property(self, "modulate:a", 1.0, 0.18)
motion_tween.tween_property(accent, "scale:x", 1.0, 0.28)
func _hide_prompt() -> void:
if not visible:
return
if motion_tween != null:
motion_tween.kill()
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.16)
motion_tween.tween_property(self, "position:y", resting_position.y - 3.0, 0.16)
var hiding_tween := motion_tween
await hiding_tween.finished
if motion_tween == hiding_tween and current_context_key.is_empty() and not feedback_active:
visible = false
func _reset_transient_state() -> void:
feedback_version += 1
feedback_active = false
feedback_succeeded = false
current_context_key = ""
if motion_tween != null:
motion_tween.kill()
visible = false
position = resting_position
modulate.a = 0.0
if accent != null:
accent.scale.x = 1.0
+1
View File
@@ -0,0 +1 @@
uid://caga5h0ri6vei