feat: add exact player context actions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bw2ng0q0wm030
|
||||
+186
-52
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://caga5h0ri6vei
|
||||
Reference in New Issue
Block a user