feat: make pantry crises visible in the world

This commit is contained in:
Rijad Zuzo
2026-07-14 23:54:22 +02:00
parent c722e32a88
commit c23f6887c8
21 changed files with 800 additions and 73 deletions
+6 -3
View File
@@ -18,9 +18,10 @@ SimulationClock
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
-> RelationshipSystem applies evidence-gated social consequences -> RelationshipSystem applies evidence-gated social consequences
-> FoodShortageOpportunitySystem projects one known unresolved need -> FoodShortageOpportunitySystem projects one known unresolved need
-> WorldViewManager presents travel and NPC state -> WorldViewManager presents travel, NPC state, and world-state cues
-> ActiveWorldAdapter supplies loaded-world positions/capacity -> ActiveWorldAdapter supplies loaded-world positions/capacity
-> NpcVisual performs local navigation, animation, and transient reactions -> NpcVisual performs local navigation, animation, and transient reactions
-> PantryStockVisual derives physical stock arrangement from storage state
``` ```
`SimulationManager` is the scene-tree façade for the simulation. It owns the `SimulationManager` is the scene-tree façade for the simulation. It owns the
@@ -89,8 +90,10 @@ improving ownership.
opportunity, or reservation state. opportunity, or reservation state.
- Inspector history is a read-only façade query over objective events and - Inspector history is a read-only façade query over objective events and
retained knowledge. Opportunity presentation is likewise query-only. retained knowledge. Opportunity presentation is likewise query-only.
Relationship cues consume state-change signals and are intentionally absent Relationship cues, interested-villager concern, physical pantry stock, and
from saves and checksums. refill feedback consume authoritative state or state-change signals and are
intentionally absent from saves and checksums. Stable visuals are rebuilt
from state after restore; transient reactions are not replayed.
- Resource changes go through `ResourceStateRecord`, NPC inventory, and - Resource changes go through `ResourceStateRecord`, NPC inventory, and
`VillageEconomy`; `village.food` and `village.wood` are synchronized views. `VillageEconomy`; `village.food` and `village.wood` are synchronized views.
- New mutable features define serialization and deterministic continuation at - New mutable features define serialization and deterministic continuation at
+11 -2
View File
@@ -711,11 +711,20 @@ Completed:
NPC or player supply actor. The repeatable debug capture stages this from NPC or player supply actor. The repeatable debug capture stages this from
live simulation state; world framing and the elevated follow camera remain live simulation state; world framing and the elevated follow camera remain
unchanged. unchanged.
26. Physical pantry crisis payoff: the pantry now replaces its inherited
generic crate with empty, low, and stocked arrangements driven by real
storage state. Only the interested villager receives a small state-derived
concern cue; a real deposit clears it and briefly animates the refilled
pantry. The capture tool writes an edit-ready empty/restocked close pair
while leaving gameplay camera defaults and simulation authority unchanged.
Next: Next:
1. Prove the same opportunity lifecycle with one missing-wood `task_blocked` 1. Package one 2030 second, repeatable pantry-crisis sequence that communicates
condition before extracting shared generation rules, as sequenced in shortage, purposeful gathering, visible carrying, deposit, and recovery
without debug UI. Treat any art or timing work as support for that single
readable cause-and-effect loop. Resume the second missing-wood opportunity
proof after this public-demo exit test, as sequenced in
`LEARNING_ROADMAP.md`. `LEARNING_ROADMAP.md`.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
+20 -5
View File
@@ -730,11 +730,26 @@ The first bounded Milestone 7 opportunity proof is complete:
quest-only event, acceptance, reward, dialogue, multi-hop rumour, or generic quest-only event, acceptance, reward, dialogue, multi-hop rumour, or generic
quest framework. quest framework.
Milestone 7 is not complete. The practical next slice is a second bounded The first build-in-public presentation checkpoint for that proof is also
unresolved-condition consumer: surface missing-wood `task_blocked` facts from complete:
patrol or study as a cared-about woodpile-restock need, resolve it through real
NPC/player wood supply, define deterministic interested-party death/staleness - authoritative pantry food selects a physical empty, low, or stocked pantry
handling, and only then extract the lifecycle shared by two proven consumers. arrangement instead of leaving a generic storage crate unchanged;
- only the interested villager shows a restrained empty-bowl concern cue;
- a real NPC deposit changes the pantry props, clears concern, and produces one
brief refill response, while restore derives the stable presentation without
replaying that transient effect;
- the repeatable runtime capture now includes an empty/restocked close pair in
the established elevated third-person visual language.
Milestone 7 is not complete. The immediate outcome-defined slice is now one
short, repeatable pantry-crisis sequence that reads without debug UI: shortage,
interested villager, finite-resource trip, visible carrying, deposit, and
physical recovery. Improve route timing, work feedback, and village composition
only where that sequence exposes a readability problem. Once that public-demo
loop passes, prove a second bounded unresolved-condition consumer from a real
missing-wood `task_blocked` fact, include deterministic interested-party
death/staleness handling, and only then extract shared opportunity machinery.
Recently completed: Recently completed:
+24 -13
View File
@@ -2,7 +2,7 @@
> Agent-facing context for understanding the project quickly. > Agent-facing context for understanding the project quickly.
> >
> Snapshot basis: repository state on July 13, 2026. Treat the code as the > Snapshot basis: repository state on July 14, 2026. Treat the code as the
> source of truth when this document and the implementation differ. > source of truth when this document and the implementation differ.
See the [documentation map](README.md) for the authority and scope of each plan. See the [documentation map](README.md) for the authority and scope of each plan.
@@ -200,8 +200,8 @@ study, and patrol target typed activity sites. The migration is documented in
`docs/baselines/jajce_lookdev_01.png` with `docs/baselines/jajce_lookdev_01.png` with
`tools/capture_jajce_lookdev.gd` `tools/capture_jajce_lookdev.gd`
- **Runtime baseline:** `Simulation Garden 01` is captured as paired debug and - **Runtime baseline:** `Simulation Garden 01` is captured as paired debug and
cinematic `main.tscn` images under `docs/baselines/` with cinematic `main.tscn` images plus an empty/restocked pantry close pair under
`tools/capture_simulation_garden.gd` `docs/baselines/` with `tools/capture_simulation_garden.gd`
- **Terrain compatibility floor:** Godot 4.4 according to the bundled extension - **Terrain compatibility floor:** Godot 4.4 according to the bundled extension
- **Version control:** Git - **Version control:** Git
- **Primary branch:** `main` - **Primary branch:** `main`
@@ -338,8 +338,9 @@ distance with resource `safety_risk`, `comfort_distance`, and
- deterministic shaped terrain data and six game-owned surface layers; - deterministic shaped terrain data and six game-owned surface layers;
- fortress, houses, mill, bridge, shader river/waterfall, mist, and smoke; - fortress, houses, mill, bridge, shader river/waterfall, mist, and smoke;
- authored dirt path strips that make the current village task loop readable; - authored dirt path strips that make the current village task loop readable;
- compact silhouette props for the ridge landmark, pantry, guard, study, and - compact silhouette props for the ridge landmark, guard, study, and rest
rest sites; sites, plus a physical pantry whose empty/low/stocked arrangement follows
authoritative food storage;
- warm sky, fog, shadows, coherent wind-reactive foliage, and breeze-aligned - warm sky, fog, shadows, coherent wind-reactive foliage, and breeze-aligned
chimney smoke; chimney smoke;
- one sparse terrain-aware calligraphic gust field with two or three soft - one sparse terrain-aware calligraphic gust field with two or three soft
@@ -382,7 +383,10 @@ A small village panel displays:
NPC name/profession labels, definition-driven colors and props, carried-food NPC name/profession labels, definition-driven colors and props, carried-food
visuals, compact task glyphs, and a brief amber blossom on real trust gains make visuals, compact task glyphs, and a brief amber blossom on real trust gains make
active simulation state readable in the world. The F10 cinematic mode hides active simulation state readable in the world. The F10 cinematic mode hides
debug labels while preserving task glyphs and transient social reactions. debug labels while preserving task glyphs and transient social reactions. An
open pantry opportunity adds a restrained empty-bowl concern cue only to its
interested villager; a real refill changes the pantry props, clears concern,
and produces one brief pantry response.
## Runtime architecture ## Runtime architecture
@@ -441,7 +445,9 @@ than duplicating these responsibilities across additional manager nodes.
- synchronizes successful visual movement into authoritative NPC position; - synchronizes successful visual movement into authoritative NPC position;
- applies visual death state; - applies visual death state;
- turns authoritative relationship-change signals into observer-only transient - turns authoritative relationship-change signals into observer-only transient
presentation cues without queuing or persisting them. presentation cues without queuing or persisting them;
- derives pantry stock props and interested-villager concern from authoritative
storage/opportunity state, including state-only rebuilding after restore.
### `player/npc/NpcVisual.gd` ### `player/npc/NpcVisual.gd`
@@ -452,7 +458,8 @@ than duplicating these responsibilities across additional manager nodes.
- moves and rotates toward path points; - moves and rotates toward path points;
- reports arrival; - reports arrival;
- presents definition-driven profession silhouettes, carried resources, task - presents definition-driven profession silhouettes, carried resources, task
glyphs, walk motion, brief trust-gain blossoms, and death state. glyphs, walk motion, brief trust-gain blossoms, opportunity concern, and
death state.
### `player/player.gd` and `player/camera_rig.gd` ### `player/player.gd` and `player/camera_rig.gd`
@@ -870,12 +877,16 @@ stable trigger, interested villager, pantry/food target, status, and later
resolution event; active evidence remains lasting. A real later NPC deposit or resolution event; active evidence remains lasting. A real later NPC deposit or
player harvest into the pantry resolves it without quest-only history, economy player harvest into the pantry resolves it without quest-only history, economy
mutation by the generator, or AI assignment. The compact village/inspector UI mutation by the generator, or AI assignment. The compact village/inspector UI
shows this lifecycle without changing the elevated follow camera. shows this lifecycle without changing the elevated follow camera. The same
facts now have a cardless world expression: empty/low/stocked pantry props, one
interested-villager concern cue, and a brief refill response. Save/load rebuilds
the stable state without replaying the response.
Milestone 7 remains in progress. The next systems slice should prove the same Milestone 7 remains in progress. The immediate next slice is an outcome-defined
lifecycle with a missing-wood `task_blocked` condition and deterministic 2030 second pantry-crisis demo loop—shortage, finite-resource travel, visible
interested-party death/staleness handling before extracting common opportunity carrying, deposit, and physical recovery—without debug UI. The second
machinery. missing-wood consumer and deterministic interested-party death/staleness rules
follow that visual exit test, before common opportunity machinery is extracted.
The remaining simulation-garden target still aims for: The remaining simulation-garden target still aims for:
+27 -6
View File
@@ -1,11 +1,13 @@
# Simulation Garden 01 # Simulation Garden 01
Captured: 2026-07-13 Captured: 2026-07-14
## Files ## Files
- `docs/baselines/simulation_garden_01_debug.png` - `docs/baselines/simulation_garden_01_debug.png`
- `docs/baselines/simulation_garden_01_cinematic.png` - `docs/baselines/simulation_garden_01_cinematic.png`
- `docs/baselines/simulation_garden_02_empty_pantry.png`
- `docs/baselines/simulation_garden_02_restocked_pantry.png`
- `tools/capture_simulation_garden.gd` - `tools/capture_simulation_garden.gd`
## Capture Command ## Capture Command
@@ -32,9 +34,11 @@ The script warms up `main.tscn`, verifies that the simulation and active NPC
visuals exist, stages a real food-deposit/trust consequence, a one-food pantry visuals exist, stages a real food-deposit/trust consequence, a one-food pantry
shortage, the hungry interested villager's ordinary gather-food replan, and one shortage, the hungry interested villager's ordinary gather-food replan, and one
wind gust. It captures the F10 debug presentation, then the cinematic wind gust. It captures the F10 debug presentation, then the cinematic
presentation with development labels and UI hidden. The trust blossom is presentation with development labels and UI hidden. It lets the earlier trust
restarted only for the second framing so both captures show the same real reaction finish so the crisis remains the single visual idea, then records a
consequence clearly. tighter empty-pantry view. A real NPC deposit resolves the opportunity and
produces the matching restocked view; the capture-only close framing keeps the
same elevated third-person perspective and does not change gameplay defaults.
## Review ## Review
@@ -124,5 +128,22 @@ find food` instead of a stale pre-shortage decision.
The inspector still fits the 1280×720 frame without adding another panel, and The inspector still fits the 1280×720 frame without adding another panel, and
the cinematic capture remains free of UI while preserving the task glyph, the cinematic capture remains free of UI while preserving the task glyph,
trust blossom, and sparse calligraphic gust. Neither gameplay camera defaults interested-villager concern, and sparse calligraphic gust. Gameplay camera
nor the established capture framing changed. defaults remain unchanged.
## July 14 physical-crisis follow-up
The pantry is no longer a generic crate whose appearance stays constant. Its
presentation reads the authoritative food amount and swaps between a large
empty terracotta bowl, a low basket with berries/sack, and a fuller stocked
arrangement. The active opportunity adds one small empty-bowl concern mark to
its interested villager only. A real NPC deposit clears that concern and emits
one short amber refill ring/mote response; restoring the same state rebuilds
the stock and concern without replaying the transient response.
The two `simulation_garden_02_*` images form a deterministic before/after pair
for editing or public progress posts. They deliberately keep UI panels out and
use a tighter version of the same elevated camera language. The broader
environment remains prototype-grade; the next useful visual proof is the full
2030 second gather/carry/deposit/recovery loop rather than another isolated
ornament pass.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://csdwcuh4q0n08"
path="res://.godot/imported/simulation_garden_02_empty_pantry.png-8afdd59748244e4706aba1dd7c45f6ef.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/simulation_garden_02_empty_pantry.png"
dest_files=["res://.godot/imported/simulation_garden_02_empty_pantry.png-8afdd59748244e4706aba1dd7c45f6ef.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bt0wgkeg8rhbf"
path="res://.godot/imported/simulation_garden_02_restocked_pantry.png-e95422ae67d7940f919886822d5c8ab1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/simulation_garden_02_restocked_pantry.png"
dest_files=["res://.godot/imported/simulation_garden_02_restocked_pantry.png-e95422ae67d7940f919886822d5c8ab1.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+2 -1
View File
@@ -64,11 +64,12 @@ home_positions = Array[Vector3]([Vector3(-15, 2, 8.5), Vector3(-13.5, 2, 6.5), V
script = ExtResource("10_save") script = ExtResource("10_save")
simulation_manager = NodePath("../SimulationManager") simulation_manager = NodePath("../SimulationManager")
[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent")] [node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent", "pantry_visual")]
script = ExtResource("4_1bvp3") script = ExtResource("4_1bvp3")
npc_visual_scene = ExtResource("5_lquwl") npc_visual_scene = ExtResource("5_lquwl")
simulation_manager = NodePath("../SimulationManager") simulation_manager = NodePath("../SimulationManager")
active_npcs_parent = NodePath("../ActiveNPCs") active_npcs_parent = NodePath("../ActiveNPCs")
pantry_visual = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry/PantryPresentation")
[node name="ActiveNPCs" type="Node3D" parent="." unique_id=88374680] [node name="ActiveNPCs" type="Node3D" parent="." unique_id=88374680]
+22
View File
@@ -6,6 +6,7 @@ signal position_changed(sim_id: int, active_position: Vector3)
const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 2.72, 0.0) const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 2.72, 0.0)
const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24) const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24)
const CONCERN_BASE_POSITION := Vector3(-0.4, 2.72, 0.0)
@export var debug_logs := false @export var debug_logs := false
@@ -28,6 +29,7 @@ const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24)
@onready var carried_wood_visual: Node3D = $CarriedWood @onready var carried_wood_visual: Node3D = $CarriedWood
@onready var task_glyph_root: Node3D = $TaskGlyphRoot @onready var task_glyph_root: Node3D = $TaskGlyphRoot
@onready var task_glyph: MeshInstance3D = $TaskGlyphRoot/TaskGlyph @onready var task_glyph: MeshInstance3D = $TaskGlyphRoot/TaskGlyph
@onready var opportunity_concern_root: Node3D = $OpportunityConcernRoot
@onready var trust_reaction_root: Node3D = $TrustReactionRoot @onready var trust_reaction_root: Node3D = $TrustReactionRoot
@onready var trust_reaction_visuals: Array[MeshInstance3D] = [ @onready var trust_reaction_visuals: Array[MeshInstance3D] = [
$TrustReactionRoot/Halo, $TrustReactionRoot/Halo,
@@ -54,6 +56,7 @@ var dead_material := StandardMaterial3D.new()
var debug_overlay_visible := true var debug_overlay_visible := true
var walk_phase := 0.0 var walk_phase := 0.0
var glyph_phase := 0.0 var glyph_phase := 0.0
var concern_phase := 0.0
var trust_reaction_tween: Tween var trust_reaction_tween: Tween
@@ -74,6 +77,12 @@ func _process(delta: float) -> void:
if task_glyph_root.visible: if task_glyph_root.visible:
task_glyph_root.position.y = 2.35 + sin(glyph_phase) * 0.045 task_glyph_root.position.y = 2.35 + sin(glyph_phase) * 0.045
task_glyph_root.rotation.y = glyph_phase * 0.35 task_glyph_root.rotation.y = glyph_phase * 0.35
if opportunity_concern_root.visible:
concern_phase = fmod(concern_phase + delta * 1.65, TAU)
opportunity_concern_root.position.y = (CONCERN_BASE_POSITION.y + sin(concern_phase) * 0.045)
opportunity_concern_root.rotation.y = sin(concern_phase * 0.7) * 0.16
var concern_scale := 0.96 + sin(concern_phase) * 0.04
opportunity_concern_root.scale = Vector3.ONE * concern_scale
var horizontal_speed := Vector2(velocity.x, velocity.z).length() var horizontal_speed := Vector2(velocity.x, velocity.z).length()
var blend := minf(delta * 9.0, 1.0) var blend := minf(delta * 9.0, 1.0)
@@ -104,6 +113,7 @@ func setup_from_sim(npc: SimNPC) -> void:
profession = npc.profession profession = npc.profession
name = "NPC_%s_%s" % [sim_id, npc_name] name = "NPC_%s_%s" % [sim_id, npc_name]
_stop_trust_gain_reaction() _stop_trust_gain_reaction()
set_opportunity_concern_visible(false)
_apply_personal_details() _apply_personal_details()
_apply_profession_presentation() _apply_profession_presentation()
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0) set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
@@ -203,6 +213,17 @@ func set_task_presentation(action_id: StringName, task_state: StringName) -> voi
task_glyph_root.visible = task_glyph.mesh != null task_glyph_root.visible = task_glyph.mesh != null
func set_opportunity_concern_visible(is_visible: bool) -> void:
if opportunity_concern_root == null:
return
opportunity_concern_root.visible = is_visible and not is_dead_visual
opportunity_concern_root.position = CONCERN_BASE_POSITION
opportunity_concern_root.rotation = Vector3.ZERO
opportunity_concern_root.scale = Vector3.ONE
if is_visible:
concern_phase = 0.0
func set_debug_overlay_visible(is_visible: bool) -> void: func set_debug_overlay_visible(is_visible: bool) -> void:
debug_overlay_visible = is_visible debug_overlay_visible = is_visible
if profession_label != null: if profession_label != null:
@@ -399,6 +420,7 @@ func _report_navigation_failure_once() -> void:
func apply_dead_visual_state() -> void: func apply_dead_visual_state() -> void:
is_dead_visual = true is_dead_visual = true
_stop_trust_gain_reaction() _stop_trust_gain_reaction()
set_opportunity_concern_visible(false)
carried_food_visual.visible = false carried_food_visual.visible = false
carried_wood_visual.visible = false carried_wood_visual.visible = false
profession_prop.visible = false profession_prop.visible = false
+44 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=21 format=3 uid="uid://dhxxyprqflotq"] [gd_scene load_steps=24 format=3 uid="uid://dhxxyprqflotq"]
[ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"] [ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"]
@@ -106,6 +106,28 @@ height = 0.14
radial_segments = 8 radial_segments = 8
rings = 4 rings = 4
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_concern"]
transparency = 1
shading_mode = 0
albedo_color = Color(0.74, 0.42, 0.3, 0.82)
emission_enabled = true
emission = Color(0.66, 0.24, 0.18, 1)
emission_energy_multiplier = 0.28
[sub_resource type="TorusMesh" id="TorusMesh_concern"]
material = SubResource("StandardMaterial3D_concern")
inner_radius = 0.13
outer_radius = 0.22
rings = 14
ring_segments = 8
[sub_resource type="SphereMesh" id="SphereMesh_concern"]
material = SubResource("StandardMaterial3D_concern")
radius = 0.06
height = 0.12
radial_segments = 8
rings = 4
[node name="NpcVisual" type="CharacterBody3D"] [node name="NpcVisual" type="CharacterBody3D"]
collision_layer = 2 collision_layer = 2
collision_mask = 1 collision_mask = 1
@@ -205,6 +227,27 @@ position = Vector3(0, 2.35, 0)
[node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"] [node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"]
[node name="OpportunityConcernRoot" type="Node3D" parent="."]
visible = false
position = Vector3(-0.4, 2.72, 0)
[node name="EmptyBowl" type="MeshInstance3D" parent="OpportunityConcernRoot"]
rotation_degrees = Vector3(72, 0, 0)
scale = Vector3(1.15, 0.72, 1.15)
mesh = SubResource("TorusMesh_concern")
[node name="BreathLeft" type="MeshInstance3D" parent="OpportunityConcernRoot"]
position = Vector3(-0.14, 0.18, 0)
rotation_degrees = Vector3(0, 0, -24)
scale = Vector3(0.7, 1.3, 0.7)
mesh = SubResource("SphereMesh_concern")
[node name="BreathRight" type="MeshInstance3D" parent="OpportunityConcernRoot"]
position = Vector3(0.08, 0.26, 0)
rotation_degrees = Vector3(0, 0, 18)
scale = Vector3(0.55, 1.05, 0.55)
mesh = SubResource("SphereMesh_concern")
[node name="TrustReactionRoot" type="Node3D" parent="."] [node name="TrustReactionRoot" type="Node3D" parent="."]
visible = false visible = false
position = Vector3(0, 2.72, 0) position = Vector3(0, 2.72, 0)
+52
View File
@@ -124,6 +124,19 @@ func _run() -> void:
main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label
) )
var pantry_state: StorageStateRecord = simulation_manager.get_pantry() var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
var pantry_visual := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry/PantryPresentation")
as PantryStockVisual
)
_check(
(
pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.STOCKED
and pantry_visual.get_node("LowStock").visible
and pantry_visual.get_node("FullStock").visible
and not pantry_visual.get_node("EmptyStock").visible
),
"A stocked authoritative pantry should show its physical sacks and berry baskets"
)
contributor.hunger = 85.0 contributor.hunger = 85.0
var withdrawn_for_need: float = simulation_manager.economy.withdraw_to_inventory( var withdrawn_for_need: float = simulation_manager.economy.withdraw_to_inventory(
contributor, contributor,
@@ -133,6 +146,18 @@ func _run() -> void:
_check(withdrawn_for_need > 0.0, "Runtime opportunity setup should empty the real pantry") _check(withdrawn_for_need > 0.0, "Runtime opportunity setup should empty the real pantry")
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
_check(opportunity != null, "Emptying the pantry should open one known shortage need") _check(opportunity != null, "Emptying the pantry should open one known shortage need")
if pantry_visual.transition_tween != null:
await pantry_visual.transition_tween.finished
_check(
(
pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.EMPTY
and pantry_visual.get_node("EmptyStock").visible
and not pantry_visual.get_node("LowStock").visible
and contributor_visual.get_node("OpportunityConcernRoot").visible
and not listener_visual.get_node("OpportunityConcernRoot").visible
),
"The real shortage should empty the pantry prop and concern only its interested villager"
)
_check( _check(
( (
"Village need" in village_stats_label.text "Village need" in village_stats_label.text
@@ -169,6 +194,14 @@ func _run() -> void:
), ),
"Opportunity presentation should resolve from a real pantry deposit" "Opportunity presentation should resolve from a real pantry deposit"
) )
_check(
(
pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.STOCKED
and pantry_visual.get_node("RefillResponse").visible
and not contributor_visual.get_node("OpportunityConcernRoot").visible
),
"Real supply should restore visible stock, play one refill response, and clear concern"
)
_check( _check(
"Village need" not in village_stats_label.text, "Village need" not in village_stats_label.text,
"Resolved opportunities should leave the compact village summary" "Resolved opportunities should leave the compact village summary"
@@ -198,6 +231,25 @@ func _run() -> void:
_check(player_need_withdrawal > 0.0, "Player resolution setup should reopen a real need") _check(player_need_withdrawal > 0.0, "Player resolution setup should reopen a real need")
var player_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() var player_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
_check(player_opportunity != null, "A later shortage should open a new opportunity record") _check(player_opportunity != null, "A later shortage should open a new opportunity record")
var active_need_json: String = simulation_manager.serialize_state()
var old_contributor_visual := contributor_visual
_check(
simulation_manager.restore_state_from_json(active_need_json),
"The visible shortage should survive a valid schema-v8 restore"
)
await process_frame
contributor_visual = world_view.active_npc_visuals[contributor.id]
player_opportunity = simulation_manager.get_active_opportunity()
_check(
(
contributor_visual != old_contributor_visual
and contributor_visual.get_node("OpportunityConcernRoot").visible
and pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.EMPTY
and pantry_visual.get_node("EmptyStock").visible
and not pantry_visual.get_node("RefillResponse").visible
),
"Restoration should rebuild concern and empty stock without replaying the refill response"
)
var player := main_scene.get_node("Player") as Node3D var player := main_scene.get_node("Player") as Node3D
var opportunity_bush := ( var opportunity_bush := (
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode
+91 -25
View File
@@ -3,6 +3,8 @@ extends SceneTree
const SCENE_PATH := "res://main.tscn" const SCENE_PATH := "res://main.tscn"
const DEBUG_OUTPUT_PATH := "res://docs/baselines/simulation_garden_01_debug.png" const DEBUG_OUTPUT_PATH := "res://docs/baselines/simulation_garden_01_debug.png"
const CINEMATIC_OUTPUT_PATH := "res://docs/baselines/simulation_garden_01_cinematic.png" const CINEMATIC_OUTPUT_PATH := "res://docs/baselines/simulation_garden_01_cinematic.png"
const EMPTY_PANTRY_OUTPUT_PATH := "res://docs/baselines/simulation_garden_02_empty_pantry.png"
const RESTOCKED_PANTRY_OUTPUT_PATH := "res://docs/baselines/simulation_garden_02_restocked_pantry.png"
const CAPTURE_SIZE := Vector2i(1280, 720) const CAPTURE_SIZE := Vector2i(1280, 720)
const WARMUP_FRAMES := 540 const WARMUP_FRAMES := 540
@@ -45,7 +47,7 @@ func _run() -> void:
quit(1) quit(1)
return return
await _stage_wind_gust(main_scene) await _stage_wind_gust(main_scene)
await _settle_trust_reaction() await _settle_trust_reaction(main_scene)
var debug_analysis := _capture(DEBUG_OUTPUT_PATH) var debug_analysis := _capture(DEBUG_OUTPUT_PATH)
if debug_analysis.is_empty(): if debug_analysis.is_empty():
quit(1) quit(1)
@@ -53,21 +55,38 @@ func _run() -> void:
if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible: if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay() demo_controller.toggle_debug_overlay()
if not _restart_trust_reaction(main_scene):
quit(1)
return
await _stage_wind_gust(main_scene) await _stage_wind_gust(main_scene)
await _settle_trust_reaction()
var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH) var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH)
if cinematic_analysis.is_empty(): if cinematic_analysis.is_empty():
quit(1) quit(1)
return return
_apply_pantry_capture_camera(main_scene)
await process_frame
await process_frame
var empty_pantry_analysis := _capture(EMPTY_PANTRY_OUTPUT_PATH)
if empty_pantry_analysis.is_empty() or not _stage_pantry_restock(main_scene):
quit(1)
return
for _frame in 12:
await process_frame
var restocked_pantry_analysis := _capture(RESTOCKED_PANTRY_OUTPUT_PATH)
if restocked_pantry_analysis.is_empty():
quit(1)
return
print( print(
( (
"[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s" (
% [debug_analysis, cinematic_analysis] "[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s | "
+ "empty pantry %s | restocked pantry %s"
)
% [
debug_analysis,
cinematic_analysis,
empty_pantry_analysis,
restocked_pantry_analysis,
]
) )
) )
quit(0) quit(0)
@@ -145,6 +164,20 @@ func _stage_social_history(main_scene: Node) -> bool:
func _stage_pantry_need(main_scene: Node, actor: SimNPC, interested: SimNPC) -> bool: func _stage_pantry_need(main_scene: Node, actor: SimNPC, interested: SimNPC) -> bool:
var simulation_manager := main_scene.get_node("SimulationManager") var simulation_manager := main_scene.get_node("SimulationManager")
var world_view := main_scene.get_node("WorldViewManager")
var pantry_site := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
)
var actor_visual := world_view.active_npc_visuals.get(actor.id) as Node3D
var interested_visual := world_view.active_npc_visuals.get(interested.id) as Node3D
if actor_visual == null or interested_visual == null:
push_error("Simulation Garden pantry staging needs both active NPC visuals")
return false
var pantry_arrival := pantry_site.get_interaction_position()
actor_visual.global_position = pantry_arrival + Vector3(-0.65, 0.0, 0.15)
interested_visual.global_position = pantry_arrival + Vector3(0.65, 0.0, 0.15)
simulation_manager.synchronize_npc_position(actor.id, actor_visual.global_position)
simulation_manager.synchronize_npc_position(interested.id, interested_visual.global_position)
for npc in simulation_manager.npcs: for npc in simulation_manager.npcs:
npc.hunger = minf(npc.hunger, 70.0) npc.hunger = minf(npc.hunger, 70.0)
actor.hunger = 60.0 actor.hunger = 60.0
@@ -189,21 +222,6 @@ func _stage_pantry_need(main_scene: Node, actor: SimNPC, interested: SimNPC) ->
return true return true
func _restart_trust_reaction(main_scene: Node) -> bool:
var simulation_manager := main_scene.get_node("SimulationManager")
var world_view := main_scene.get_node("WorldViewManager")
if simulation_manager.npcs.size() < 2:
push_error("Simulation Garden trust-reaction staging needs two NPCs")
return false
var observer: SimNPC = simulation_manager.npcs[1]
var observer_visual: Node3D = world_view.active_npc_visuals.get(observer.id)
if observer_visual == null or not observer_visual.has_method("play_trust_gain_reaction"):
push_error("Simulation Garden trust-reaction staging needs an active observer visual")
return false
observer_visual.play_trust_gain_reaction()
return observer_visual.get_node("TrustReactionRoot").visible
func _stage_wind_gust(main_scene: Node) -> void: func _stage_wind_gust(main_scene: Node) -> void:
var gust_field := ( var gust_field := (
main_scene.get_node_or_null("JajceWorld/AtmosphereRoot/WindGustField") as WindGustField main_scene.get_node_or_null("JajceWorld/AtmosphereRoot/WindGustField") as WindGustField
@@ -215,8 +233,44 @@ func _stage_wind_gust(main_scene: Node) -> void:
await process_frame await process_frame
func _settle_trust_reaction() -> void: func _stage_pantry_restock(main_scene: Node) -> bool:
for _frame in 12: var simulation_manager := main_scene.get_node("SimulationManager")
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
if opportunity == null:
push_error("Simulation Garden restock staging needs an active pantry opportunity")
return false
var contributor: SimNPC = simulation_manager.npcs[0]
var deposited: float = simulation_manager.economy.deposit_inventory(
contributor, SimulationIds.RESOURCE_FOOD
)
var pantry_visual := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry/PantryPresentation")
as PantryStockVisual
)
var world_view := main_scene.get_node("WorldViewManager")
var interested_visual: Node3D = world_view.active_npc_visuals.get(
opportunity.get_interested_npc_id()
)
if (
deposited <= 0.0
or simulation_manager.get_active_opportunity() != null
or pantry_visual.get_stock_level() != PantryStockVisual.StockLevel.LOW
or not pantry_visual.get_node("RefillResponse").visible
or interested_visual.get_node("OpportunityConcernRoot").visible
):
push_error("Simulation Garden restock staging did not produce the real visual payoff")
return false
return true
func _settle_trust_reaction(main_scene: Node) -> void:
var simulation_manager := main_scene.get_node("SimulationManager")
var world_view := main_scene.get_node("WorldViewManager")
var observer: SimNPC = simulation_manager.npcs[1]
var observer_visual: Node3D = world_view.active_npc_visuals.get(observer.id)
var reaction_tween: Tween = observer_visual.get("trust_reaction_tween")
if reaction_tween != null and reaction_tween.is_running():
await reaction_tween.finished
await process_frame await process_frame
@@ -268,8 +322,20 @@ func _apply_capture_camera(main_scene: Node) -> void:
var camera_rig := main_scene.get_node("CameraRig") var camera_rig := main_scene.get_node("CameraRig")
if camera_rig.has_method("apply_presentation_preset"): if camera_rig.has_method("apply_presentation_preset"):
camera_rig.pitch_degrees = -42.0 camera_rig.pitch_degrees = -42.0
camera_rig.apply_presentation_preset(Vector3(-2.0, 1.2, -0.5), 128.0, 0.95) camera_rig.apply_presentation_preset(Vector3(-1.5, 1.2, -4.0), 128.0, 0.7)
camera_rig.set_physics_process(false) camera_rig.set_physics_process(false)
var camera := main_scene.get_node("CameraRig/Camera3D") as Camera3D var camera := main_scene.get_node("CameraRig/Camera3D") as Camera3D
camera.fov = 62.0 camera.fov = 62.0
func _apply_pantry_capture_camera(main_scene: Node) -> void:
var pantry := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
)
var camera_rig := main_scene.get_node("CameraRig")
var pantry_focus := pantry.global_position + Vector3(0.0, 0.85, 0.72)
camera_rig.apply_presentation_preset(pantry_focus, 128.0, 0.0)
camera_rig.look_at(pantry_focus, Vector3.UP)
var camera := main_scene.get_node("CameraRig/Camera3D") as Camera3D
camera.fov = 44.0
+200 -7
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=36 format=3] [gd_scene load_steps=50 format=3]
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"] [ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -17,6 +17,7 @@
[ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"] [ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"]
[ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"] [ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"]
[ext_resource type="PackedScene" path="res://world/jajce/vfx/WindGustField.tscn" id="17_wind_gust"] [ext_resource type="PackedScene" path="res://world/jajce/vfx/WindGustField.tscn" id="17_wind_gust"]
[ext_resource type="Script" path="res://world/storage/PantryStockVisual.gd" id="18_pantry_visual"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = { _shader_parameters = {
@@ -74,6 +75,83 @@ size = Vector3(1.0, 0.08, 0.5)
material = SubResource("Material_site_canvas") material = SubResource("Material_site_canvas")
size = Vector3(0.5, 0.62, 0.42) size = Vector3(0.5, 0.62, 0.42)
[sub_resource type="StandardMaterial3D" id="Material_pantry_berry"]
albedo_color = Color(0.56, 0.16, 0.19, 1)
roughness = 0.82
[sub_resource type="StandardMaterial3D" id="Material_pantry_leaf"]
albedo_color = Color(0.35, 0.52, 0.25, 1)
roughness = 0.86
[sub_resource type="StandardMaterial3D" id="Material_pantry_empty"]
albedo_color = Color(0.48, 0.22, 0.09, 1)
roughness = 0.94
[sub_resource type="StandardMaterial3D" id="Material_pantry_glow"]
transparency = 1
shading_mode = 0
albedo_color = Color(0.96, 0.72, 0.3, 0.7)
emission_enabled = true
emission = Color(0.92, 0.5, 0.15, 1)
emission_energy_multiplier = 0.45
[sub_resource type="CylinderMesh" id="Mesh_pantry_basket"]
material = SubResource("Material_site_dark_wood")
top_radius = 0.46
bottom_radius = 0.38
height = 0.3
radial_segments = 12
[sub_resource type="SphereMesh" id="Mesh_pantry_berry"]
material = SubResource("Material_pantry_berry")
radius = 0.15
height = 0.26
radial_segments = 10
rings = 5
[sub_resource type="SphereMesh" id="Mesh_pantry_leaf"]
material = SubResource("Material_pantry_leaf")
radius = 0.12
height = 0.24
radial_segments = 8
rings = 4
[sub_resource type="TorusMesh" id="Mesh_pantry_bowl_rim"]
material = SubResource("Material_pantry_empty")
inner_radius = 0.3
outer_radius = 0.39
rings = 14
ring_segments = 8
[sub_resource type="CylinderMesh" id="Mesh_pantry_bowl_base"]
material = SubResource("Material_pantry_empty")
top_radius = 0.3
bottom_radius = 0.24
height = 0.08
radial_segments = 12
[sub_resource type="BoxMesh" id="Mesh_pantry_shelf"]
material = SubResource("Material_site_dark_wood")
size = Vector3(1.5, 0.12, 0.5)
[sub_resource type="BoxMesh" id="Mesh_pantry_backcloth"]
material = SubResource("Material_site_canvas")
size = Vector3(1.45, 1.05, 0.08)
[sub_resource type="TorusMesh" id="Mesh_pantry_refill_ring"]
material = SubResource("Material_pantry_glow")
inner_radius = 0.55
outer_radius = 0.66
rings = 18
ring_segments = 8
[sub_resource type="SphereMesh" id="Mesh_pantry_refill_mote"]
material = SubResource("Material_pantry_glow")
radius = 0.09
height = 0.17
radial_segments = 8
rings = 4
[sub_resource type="BoxMesh" id="Mesh_study_book"] [sub_resource type="BoxMesh" id="Mesh_study_book"]
material = SubResource("Material_site_book") material = SubResource("Material_site_book")
size = Vector3(0.42, 0.08, 0.3) size = Vector3(0.42, 0.08, 0.3)
@@ -458,6 +536,9 @@ discovery_priority = 0.45
[node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] [node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
position = Vector3(0, 0, -8) position = Vector3(0, 0, -8)
[node name="Visual" parent="WorldObjects/StorageSites/VillagePantry"]
visible = false
[node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] [node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
position = Vector3(8, 0, -12) position = Vector3(8, 0, -12)
storage_id = &"village_woodpile" storage_id = &"village_woodpile"
@@ -471,16 +552,128 @@ position = Vector3(-1.05, 1.85, 1.25)
rotation_degrees = Vector3(0, 90, 0) rotation_degrees = Vector3(0, 90, 0)
mesh = SubResource("Mesh_site_sign") mesh = SubResource("Mesh_site_sign")
[node name="PantrySack_A" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"] [node name="PantryPresentation" type="Node3D" parent="WorldObjects/StorageSites/VillagePantry"]
position = Vector3(-0.95, 0.3, -0.65) position = Vector3(0, 0, -0.48)
scale = Vector3(0.65, 1.0, 0.65) scale = Vector3(1.12, 1.12, 1.12)
script = ExtResource("18_pantry_visual")
[node name="Backcloth" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
position = Vector3(0, 0.68, 0.22)
mesh = SubResource("Mesh_pantry_backcloth")
[node name="ShelfLower" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
position = Vector3(0, 0.18, 0)
mesh = SubResource("Mesh_pantry_shelf")
[node name="ShelfUpper" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
position = Vector3(0, 1.15, 0)
mesh = SubResource("Mesh_pantry_shelf")
[node name="PostLeft" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
position = Vector3(-0.65, 0.66, 0.18)
scale = Vector3(0.08, 8, 0.12)
mesh = SubResource("Mesh_pantry_shelf")
[node name="PostRight" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
position = Vector3(0.65, 0.66, 0.18)
scale = Vector3(0.08, 8, 0.12)
mesh = SubResource("Mesh_pantry_shelf")
[node name="EmptyStock" type="Node3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
position = Vector3(0, 0, -0.38)
[node name="BowlBase" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/EmptyStock"]
position = Vector3(0, 0.27, -0.04)
mesh = SubResource("Mesh_pantry_bowl_base")
[node name="BowlRim" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/EmptyStock"]
position = Vector3(0, 0.34, -0.04)
mesh = SubResource("Mesh_pantry_bowl_rim")
[node name="LowStock" type="Node3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
visible = false
position = Vector3(0, 0, -0.38)
[node name="Sack" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/LowStock"]
position = Vector3(-0.42, 0.49, 0)
scale = Vector3(0.72, 0.9, 0.72)
mesh = SubResource("Mesh_site_sack") mesh = SubResource("Mesh_site_sack")
[node name="PantrySack_B" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"] [node name="Basket" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/LowStock"]
position = Vector3(-1.35, 0.22, -0.25) position = Vector3(0.36, 0.32, -0.02)
scale = Vector3(0.5, 0.75, 0.5) mesh = SubResource("Mesh_pantry_basket")
[node name="BerryLeft" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/LowStock"]
position = Vector3(0.18, 0.52, -0.02)
mesh = SubResource("Mesh_pantry_berry")
[node name="BerryCenter" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/LowStock"]
position = Vector3(0.38, 0.57, 0.02)
mesh = SubResource("Mesh_pantry_berry")
[node name="BerryRight" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/LowStock"]
position = Vector3(0.55, 0.5, -0.03)
mesh = SubResource("Mesh_pantry_berry")
[node name="Leaf" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/LowStock"]
position = Vector3(0.36, 0.67, 0)
rotation_degrees = Vector3(20, 0, 58)
scale = Vector3(0.45, 1, 0.3)
mesh = SubResource("Mesh_pantry_leaf")
[node name="FullStock" type="Node3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
visible = false
position = Vector3(0, 0, -0.38)
[node name="SackLeft" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/FullStock"]
position = Vector3(-0.48, 0.77, 0)
scale = Vector3(0.55, 0.72, 0.55)
mesh = SubResource("Mesh_site_sack") mesh = SubResource("Mesh_site_sack")
[node name="SackRight" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/FullStock"]
position = Vector3(0.48, 0.78, 0)
scale = Vector3(0.5, 0.68, 0.5)
mesh = SubResource("Mesh_site_sack")
[node name="UpperBasket" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/FullStock"]
position = Vector3(0, 1.31, -0.02)
scale = Vector3(0.82, 0.82, 0.82)
mesh = SubResource("Mesh_pantry_basket")
[node name="UpperBerryLeft" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/FullStock"]
position = Vector3(-0.18, 1.48, -0.03)
mesh = SubResource("Mesh_pantry_berry")
[node name="UpperBerryCenter" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/FullStock"]
position = Vector3(0, 1.52, 0.02)
mesh = SubResource("Mesh_pantry_berry")
[node name="UpperBerryRight" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/FullStock"]
position = Vector3(0.18, 1.46, -0.02)
mesh = SubResource("Mesh_pantry_berry")
[node name="RefillResponse" type="Node3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation"]
visible = false
position = Vector3(0, 0.08, -0.5)
scale = Vector3(0.45, 0.45, 0.45)
[node name="Ring" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/RefillResponse"]
position = Vector3(0, 0.78, 0)
mesh = SubResource("Mesh_pantry_refill_ring")
[node name="MoteLeft" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/RefillResponse"]
position = Vector3(-0.48, 1.02, 0)
mesh = SubResource("Mesh_pantry_refill_mote")
[node name="MoteCenter" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/RefillResponse"]
position = Vector3(0, 1.18, 0)
scale = Vector3(0.8, 1.3, 0.8)
mesh = SubResource("Mesh_pantry_refill_mote")
[node name="MoteRight" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/RefillResponse"]
position = Vector3(0.48, 1.02, 0)
mesh = SubResource("Mesh_pantry_refill_mote")
[node name="ActivitySites" type="Node3D" parent="WorldObjects"] [node name="ActivitySites" type="Node3D" parent="WorldObjects"]
[node name="GuardPost" type="Node3D" parent="WorldObjects/ActivitySites"] [node name="GuardPost" type="Node3D" parent="WorldObjects/ActivitySites"]
+149
View File
@@ -0,0 +1,149 @@
class_name PantryStockVisual
extends Node3D
enum StockLevel { EMPTY, LOW, STOCKED }
const LOW_STOCK_THRESHOLD := 4.0
const REFILL_RESPONSE_START := Vector3(0.0, 0.08, -0.5)
const REFILL_RESPONSE_END := Vector3(0.0, 0.36, -0.5)
@onready var empty_root: Node3D = $EmptyStock
@onready var low_stock_root: Node3D = $LowStock
@onready var full_stock_root: Node3D = $FullStock
@onready var refill_response_root: Node3D = $RefillResponse
@onready var refill_response_visuals: Array[MeshInstance3D] = [
$RefillResponse/Ring,
$RefillResponse/MoteLeft,
$RefillResponse/MoteCenter,
$RefillResponse/MoteRight,
]
var displayed_amount := -1.0
var stock_level := StockLevel.EMPTY
var transition_tween: Tween
var refill_tween: Tween
func _ready() -> void:
refill_response_root.visible = false
func set_food_amount(amount: float, animate: bool = true) -> void:
var normalized_amount := maxf(amount, 0.0)
var next_level := _get_stock_level(normalized_amount)
if next_level == StockLevel.EMPTY:
_stop_refill_response()
var was_initialized := displayed_amount >= 0.0
var was_empty := was_initialized and displayed_amount <= 0.001
if was_initialized and next_level == stock_level:
displayed_amount = normalized_amount
return
var should_animate := animate and was_initialized
displayed_amount = normalized_amount
stock_level = next_level
if should_animate:
_transition_to_level(next_level)
else:
_apply_level_immediately(next_level)
if was_empty and normalized_amount > 0.001 and animate:
_play_refill_response()
func get_stock_level() -> StockLevel:
return stock_level
func _get_stock_level(amount: float) -> StockLevel:
if amount <= 0.001:
return StockLevel.EMPTY
if amount < LOW_STOCK_THRESHOLD:
return StockLevel.LOW
return StockLevel.STOCKED
func _apply_level_immediately(level: StockLevel) -> void:
_stop_transition()
_set_root_visible(empty_root, level == StockLevel.EMPTY)
_set_root_visible(low_stock_root, level in [StockLevel.LOW, StockLevel.STOCKED])
_set_root_visible(full_stock_root, level == StockLevel.STOCKED)
func _transition_to_level(level: StockLevel) -> void:
_stop_transition()
transition_tween = create_tween().set_parallel(true)
_transition_root(empty_root, level == StockLevel.EMPTY)
_transition_root(low_stock_root, level in [StockLevel.LOW, StockLevel.STOCKED])
_transition_root(full_stock_root, level == StockLevel.STOCKED)
transition_tween.chain().tween_callback(_finish_transition.bind(level))
func _transition_root(root: Node3D, should_show: bool) -> void:
if should_show:
if not root.visible:
root.visible = true
root.scale = Vector3.ONE * 0.62
(
transition_tween
. tween_property(root, "scale", Vector3.ONE, 0.28)
. set_trans(Tween.TRANS_BACK)
. set_ease(Tween.EASE_OUT)
)
return
if not root.visible:
return
(
transition_tween
. tween_property(root, "scale", Vector3.ONE * 0.18, 0.16)
. set_trans(Tween.TRANS_SINE)
. set_ease(Tween.EASE_IN)
)
func _finish_transition(level: StockLevel) -> void:
transition_tween = null
_set_root_visible(empty_root, level == StockLevel.EMPTY)
_set_root_visible(low_stock_root, level in [StockLevel.LOW, StockLevel.STOCKED])
_set_root_visible(full_stock_root, level == StockLevel.STOCKED)
func _set_root_visible(root: Node3D, is_visible: bool) -> void:
root.visible = is_visible
root.scale = Vector3.ONE
func _stop_transition() -> void:
if transition_tween != null:
transition_tween.kill()
transition_tween = null
func _play_refill_response() -> void:
_stop_refill_response()
refill_response_root.visible = true
refill_response_root.position = REFILL_RESPONSE_START
refill_response_root.scale = Vector3.ONE * 0.45
_set_refill_transparency(0.0)
refill_tween = create_tween()
refill_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
refill_tween.tween_property(refill_response_root, "scale", Vector3.ONE, 0.24)
refill_tween.parallel().tween_method(_set_refill_transparency, 0.0, 1.0, 0.18)
refill_tween.tween_property(refill_response_root, "position", REFILL_RESPONSE_END, 0.5)
refill_tween.parallel().tween_method(_set_refill_transparency, 1.0, 0.0, 0.5)
refill_tween.tween_callback(_stop_refill_response)
func _set_refill_transparency(value: float) -> void:
for visual in refill_response_visuals:
visual.transparency = clampf(1.0 - value, 0.0, 1.0)
func _stop_refill_response() -> void:
if refill_tween != null:
refill_tween.kill()
refill_tween = null
if refill_response_root == null:
return
refill_response_root.visible = false
refill_response_root.position = REFILL_RESPONSE_START
refill_response_root.scale = Vector3.ONE * 0.45
_set_refill_transparency(0.0)
+1
View File
@@ -0,0 +1 @@
uid://bj2aigpu1nug6
+1 -9
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=7 format=3] [gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://world/storage/StorageNode.gd" id="1_storage"] [ext_resource type="Script" path="res://world/storage/StorageNode.gd" id="1_storage"]
@@ -18,10 +18,6 @@ size = Vector3(1.6, 1.1, 1.6)
material = SubResource("Material_cloth") material = SubResource("Material_cloth")
size = Vector3(1.75, 0.12, 1.75) size = Vector3(1.75, 0.12, 1.75)
[sub_resource type="BoxMesh" id="Mesh_sack"]
material = SubResource("Material_cloth")
size = Vector3(0.45, 0.65, 0.45)
[node name="StorageNode" type="Node3D"] [node name="StorageNode" type="Node3D"]
script = ExtResource("1_storage") script = ExtResource("1_storage")
@@ -35,10 +31,6 @@ mesh = SubResource("Mesh_crate")
transform = Transform3D(1, 0, 0, 0, 0.7, 0.7173561, 0, -0.7173561, 0.7, 0, 1.15, -0.06) transform = Transform3D(1, 0, 0, 0, 0.7, 0.7173561, 0, -0.7173561, 0.7, 0, 1.15, -0.06)
mesh = SubResource("Mesh_lid") mesh = SubResource("Mesh_lid")
[node name="FoodSack" type="MeshInstance3D" parent="Visual"]
transform = Transform3D(0.85, 0, 0, 0, 1, 0, 0, 0, 0.85, 0.8, 0.325, 0.55)
mesh = SubResource("Mesh_sack")
[node name="InteractionPoint" type="Marker3D" parent="."] [node name="InteractionPoint" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2.0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2.0)
+69
View File
@@ -5,6 +5,7 @@ extends Node
@export var npc_visual_scene: PackedScene @export var npc_visual_scene: PackedScene
@export var simulation_manager: Node @export var simulation_manager: Node
@export var active_npcs_parent: Node3D @export var active_npcs_parent: Node3D
@export var pantry_visual: Node3D
var active_npc_visuals := {} var active_npc_visuals := {}
@@ -44,11 +45,24 @@ func initialize_world_view() -> void:
simulation_manager.relationship_changed.connect(_on_relationship_changed) simulation_manager.relationship_changed.connect(_on_relationship_changed)
else: else:
push_error("WorldViewManager: SimulationManager has no relationship_changed signal") push_error("WorldViewManager: SimulationManager has no relationship_changed signal")
if simulation_manager.has_signal("economic_event_recorded"):
simulation_manager.economic_event_recorded.connect(_on_economic_event_recorded)
else:
push_error("WorldViewManager: SimulationManager has no economic_event_recorded signal")
if simulation_manager.has_signal("opportunity_opened"):
simulation_manager.opportunity_opened.connect(_on_opportunity_changed)
else:
push_error("WorldViewManager: SimulationManager has no opportunity_opened signal")
if simulation_manager.has_signal("opportunity_resolved"):
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
else:
push_error("WorldViewManager: SimulationManager has no opportunity_resolved signal")
if simulation_manager.has_signal("state_restored"): if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_simulation_state_restored) simulation_manager.state_restored.connect(_on_simulation_state_restored)
else: else:
push_error("WorldViewManager: SimulationManager has no state_restored signal") push_error("WorldViewManager: SimulationManager has no state_restored signal")
spawn_initial_npcs() spawn_initial_npcs()
_refresh_world_state_presentation(false)
func spawn_initial_npcs() -> void: func spawn_initial_npcs() -> void:
@@ -86,6 +100,7 @@ func spawn_npc_visual(npc: SimNPC) -> void:
else: else:
push_error("WorldViewManager: NPCVisual has no position_changed signal") push_error("WorldViewManager: NPCVisual has no position_changed signal")
active_npc_visuals[npc.id] = visual active_npc_visuals[npc.id] = visual
_sync_npc_opportunity_concern(npc.id, visual)
debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position]) debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position])
if npc.is_dead: if npc.is_dead:
visual.apply_dead_visual_state() visual.apply_dead_visual_state()
@@ -200,8 +215,62 @@ func _on_relationship_changed(
push_error("WorldViewManager: NPCVisual has no play_trust_gain_reaction method") push_error("WorldViewManager: NPCVisual has no play_trust_gain_reaction method")
func _on_economic_event_recorded(_event: EconomicEventRecord) -> void:
_refresh_pantry_stock(true)
func _on_opportunity_changed(_opportunity: OpportunityStateRecord) -> void:
_refresh_world_state_presentation(true)
func _on_opportunity_resolved(
_opportunity: OpportunityStateRecord, _cause_event: EconomicEventRecord
) -> void:
_refresh_world_state_presentation(true)
func _on_simulation_state_restored() -> void: func _on_simulation_state_restored() -> void:
for visual in active_npc_visuals.values(): for visual in active_npc_visuals.values():
visual.queue_free() visual.queue_free()
active_npc_visuals.clear() active_npc_visuals.clear()
spawn_initial_npcs() spawn_initial_npcs()
_refresh_world_state_presentation(false)
func _refresh_world_state_presentation(animate: bool) -> void:
_refresh_pantry_stock(animate)
_refresh_opportunity_concern()
func _refresh_pantry_stock(animate: bool) -> void:
if pantry_visual == null or not pantry_visual.has_method("set_food_amount"):
return
var pantry: StorageStateRecord = simulation_manager.get_pantry()
var amount := 0.0
if pantry != null:
amount = pantry.get_amount(SimulationIds.RESOURCE_FOOD)
pantry_visual.set_food_amount(amount, animate)
func _refresh_opportunity_concern() -> void:
for visual in active_npc_visuals.values():
if visual.has_method("set_opportunity_concern_visible"):
visual.set_opportunity_concern_visible(false)
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
if opportunity == null:
return
var interested_visual = active_npc_visuals.get(opportunity.get_interested_npc_id())
if (
interested_visual != null
and interested_visual.has_method("set_opportunity_concern_visible")
):
interested_visual.set_opportunity_concern_visible(true)
func _sync_npc_opportunity_concern(npc_id: int, visual: Node3D) -> void:
if not visual.has_method("set_opportunity_concern_visible"):
return
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
visual.set_opportunity_concern_visible(
opportunity != null and opportunity.get_interested_npc_id() == npc_id
)