diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 02574f9..f1e1176 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -18,9 +18,10 @@ SimulationClock -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> RelationshipSystem applies evidence-gated social consequences -> 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 -> 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 @@ -89,8 +90,10 @@ improving ownership. opportunity, or reservation state. - Inspector history is a read-only façade query over objective events and retained knowledge. Opportunity presentation is likewise query-only. - Relationship cues consume state-change signals and are intentionally absent - from saves and checksums. + Relationship cues, interested-villager concern, physical pantry stock, and + 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 `VillageEconomy`; `village.food` and `village.wood` are synchronized views. - New mutable features define serialization and deterministic continuation at diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 472711b..ca3a403 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -711,11 +711,20 @@ Completed: NPC or player supply actor. The repeatable debug capture stages this from live simulation state; world framing and the elevated follow camera remain 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: -1. Prove the same opportunity lifecycle with one missing-wood `task_blocked` - condition before extracting shared generation rules, as sequenced in +1. Package one 20–30 second, repeatable pantry-crisis sequence that communicates + 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`. Do not start with GIS data, a full city, a large asset pack, or more NPC diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 332706f..513f4a1 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -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 framework. -Milestone 7 is not complete. The practical next slice is a second bounded -unresolved-condition consumer: surface missing-wood `task_blocked` facts from -patrol or study as a cared-about woodpile-restock need, resolve it through real -NPC/player wood supply, define deterministic interested-party death/staleness -handling, and only then extract the lifecycle shared by two proven consumers. +The first build-in-public presentation checkpoint for that proof is also +complete: + +- authoritative pantry food selects a physical empty, low, or stocked pantry + 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: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 764dce2..db4205f 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -2,7 +2,7 @@ > 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. 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 `tools/capture_jajce_lookdev.gd` - **Runtime baseline:** `Simulation Garden 01` is captured as paired debug and - cinematic `main.tscn` images under `docs/baselines/` with - `tools/capture_simulation_garden.gd` + cinematic `main.tscn` images plus an empty/restocked pantry close pair under + `docs/baselines/` with `tools/capture_simulation_garden.gd` - **Terrain compatibility floor:** Godot 4.4 according to the bundled extension - **Version control:** Git - **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; - fortress, houses, mill, bridge, shader river/waterfall, mist, and smoke; - authored dirt path strips that make the current village task loop readable; -- compact silhouette props for the ridge landmark, pantry, guard, study, and - rest sites; +- compact silhouette props for the ridge landmark, guard, study, and rest + 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 chimney smoke; - 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 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 -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 @@ -441,7 +445,9 @@ than duplicating these responsibilities across additional manager nodes. - synchronizes successful visual movement into authoritative NPC position; - applies visual death state; - 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` @@ -452,7 +458,8 @@ than duplicating these responsibilities across additional manager nodes. - moves and rotates toward path points; - reports arrival; - 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` @@ -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 player harvest into the pantry resolves it without quest-only history, economy 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 -lifecycle with a missing-wood `task_blocked` condition and deterministic -interested-party death/staleness handling before extracting common opportunity -machinery. +Milestone 7 remains in progress. The immediate next slice is an outcome-defined +20–30 second pantry-crisis demo loop—shortage, finite-resource travel, visible +carrying, deposit, and physical recovery—without debug UI. The second +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: diff --git a/docs/baselines/SIMULATION_GARDEN_01.md b/docs/baselines/SIMULATION_GARDEN_01.md index 8774db5..31e48ed 100644 --- a/docs/baselines/SIMULATION_GARDEN_01.md +++ b/docs/baselines/SIMULATION_GARDEN_01.md @@ -1,11 +1,13 @@ # Simulation Garden 01 -Captured: 2026-07-13 +Captured: 2026-07-14 ## Files - `docs/baselines/simulation_garden_01_debug.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` ## 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 shortage, the hungry interested villager's ordinary gather-food replan, and one wind gust. It captures the F10 debug presentation, then the cinematic -presentation with development labels and UI hidden. The trust blossom is -restarted only for the second framing so both captures show the same real -consequence clearly. +presentation with development labels and UI hidden. It lets the earlier trust +reaction finish so the crisis remains the single visual idea, then records a +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 @@ -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 cinematic capture remains free of UI while preserving the task glyph, -trust blossom, and sparse calligraphic gust. Neither gameplay camera defaults -nor the established capture framing changed. +interested-villager concern, and sparse calligraphic gust. Gameplay camera +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 +20–30 second gather/carry/deposit/recovery loop rather than another isolated +ornament pass. diff --git a/docs/baselines/simulation_garden_01_cinematic.png b/docs/baselines/simulation_garden_01_cinematic.png index 6687529..ed2831c 100644 Binary files a/docs/baselines/simulation_garden_01_cinematic.png and b/docs/baselines/simulation_garden_01_cinematic.png differ diff --git a/docs/baselines/simulation_garden_01_debug.png b/docs/baselines/simulation_garden_01_debug.png index e07be4d..ccc37e4 100644 Binary files a/docs/baselines/simulation_garden_01_debug.png and b/docs/baselines/simulation_garden_01_debug.png differ diff --git a/docs/baselines/simulation_garden_02_empty_pantry.png b/docs/baselines/simulation_garden_02_empty_pantry.png new file mode 100644 index 0000000..d129ef2 Binary files /dev/null and b/docs/baselines/simulation_garden_02_empty_pantry.png differ diff --git a/docs/baselines/simulation_garden_02_empty_pantry.png.import b/docs/baselines/simulation_garden_02_empty_pantry.png.import new file mode 100644 index 0000000..5bb93fd --- /dev/null +++ b/docs/baselines/simulation_garden_02_empty_pantry.png.import @@ -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 diff --git a/docs/baselines/simulation_garden_02_restocked_pantry.png b/docs/baselines/simulation_garden_02_restocked_pantry.png new file mode 100644 index 0000000..acbe266 Binary files /dev/null and b/docs/baselines/simulation_garden_02_restocked_pantry.png differ diff --git a/docs/baselines/simulation_garden_02_restocked_pantry.png.import b/docs/baselines/simulation_garden_02_restocked_pantry.png.import new file mode 100644 index 0000000..72de0b8 --- /dev/null +++ b/docs/baselines/simulation_garden_02_restocked_pantry.png.import @@ -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 diff --git a/main.tscn b/main.tscn index ac1d0a5..19f5bf2 100644 --- a/main.tscn +++ b/main.tscn @@ -64,11 +64,12 @@ home_positions = Array[Vector3]([Vector3(-15, 2, 8.5), Vector3(-13.5, 2, 6.5), V script = ExtResource("10_save") 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") npc_visual_scene = ExtResource("5_lquwl") simulation_manager = NodePath("../SimulationManager") active_npcs_parent = NodePath("../ActiveNPCs") +pantry_visual = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry/PantryPresentation") [node name="ActiveNPCs" type="Node3D" parent="." unique_id=88374680] diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 6fa4f8c..26b9d0f 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -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_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 @@ -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 task_glyph_root: Node3D = $TaskGlyphRoot @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_visuals: Array[MeshInstance3D] = [ $TrustReactionRoot/Halo, @@ -54,6 +56,7 @@ var dead_material := StandardMaterial3D.new() var debug_overlay_visible := true var walk_phase := 0.0 var glyph_phase := 0.0 +var concern_phase := 0.0 var trust_reaction_tween: Tween @@ -74,6 +77,12 @@ func _process(delta: float) -> void: if task_glyph_root.visible: task_glyph_root.position.y = 2.35 + sin(glyph_phase) * 0.045 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 blend := minf(delta * 9.0, 1.0) @@ -104,6 +113,7 @@ func setup_from_sim(npc: SimNPC) -> void: profession = npc.profession name = "NPC_%s_%s" % [sim_id, npc_name] _stop_trust_gain_reaction() + set_opportunity_concern_visible(false) _apply_personal_details() _apply_profession_presentation() 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 +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: debug_overlay_visible = is_visible if profession_label != null: @@ -399,6 +420,7 @@ func _report_navigation_failure_once() -> void: func apply_dead_visual_state() -> void: is_dead_visual = true _stop_trust_gain_reaction() + set_opportunity_concern_visible(false) carried_food_visual.visible = false carried_wood_visual.visible = false profession_prop.visible = false diff --git a/player/npc/NpcVisual.tscn b/player/npc/NpcVisual.tscn index dbd1377..bf710a0 100644 --- a/player/npc/NpcVisual.tscn +++ b/player/npc/NpcVisual.tscn @@ -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"] @@ -106,6 +106,28 @@ height = 0.14 radial_segments = 8 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"] collision_layer = 2 collision_mask = 1 @@ -205,6 +227,27 @@ position = Vector3(0, 2.35, 0) [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="."] visible = false position = Vector3(0, 2.72, 0) diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 939e04f..1c06e23 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -124,6 +124,19 @@ func _run() -> void: main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label ) 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 var withdrawn_for_need: float = simulation_manager.economy.withdraw_to_inventory( contributor, @@ -133,6 +146,18 @@ func _run() -> void: _check(withdrawn_for_need > 0.0, "Runtime opportunity setup should empty the real pantry") var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() _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( ( "Village need" in village_stats_label.text @@ -169,6 +194,14 @@ func _run() -> void: ), "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( "Village need" not in village_stats_label.text, "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") var player_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() _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 opportunity_bush := ( main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode diff --git a/tools/capture_simulation_garden.gd b/tools/capture_simulation_garden.gd index f119848..fc7259b 100644 --- a/tools/capture_simulation_garden.gd +++ b/tools/capture_simulation_garden.gd @@ -3,6 +3,8 @@ extends SceneTree const SCENE_PATH := "res://main.tscn" 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 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 WARMUP_FRAMES := 540 @@ -45,7 +47,7 @@ func _run() -> void: quit(1) return await _stage_wind_gust(main_scene) - await _settle_trust_reaction() + await _settle_trust_reaction(main_scene) var debug_analysis := _capture(DEBUG_OUTPUT_PATH) if debug_analysis.is_empty(): quit(1) @@ -53,21 +55,38 @@ func _run() -> void: if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible: demo_controller.toggle_debug_overlay() - if not _restart_trust_reaction(main_scene): - quit(1) - return await _stage_wind_gust(main_scene) - await _settle_trust_reaction() var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH) if cinematic_analysis.is_empty(): quit(1) 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( ( - "[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) @@ -145,6 +164,20 @@ func _stage_social_history(main_scene: Node) -> bool: func _stage_pantry_need(main_scene: Node, actor: SimNPC, interested: SimNPC) -> bool: 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: npc.hunger = minf(npc.hunger, 70.0) actor.hunger = 60.0 @@ -189,21 +222,6 @@ func _stage_pantry_need(main_scene: Node, actor: SimNPC, interested: SimNPC) -> 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: var gust_field := ( main_scene.get_node_or_null("JajceWorld/AtmosphereRoot/WindGustField") as WindGustField @@ -215,9 +233,45 @@ func _stage_wind_gust(main_scene: Node) -> void: await process_frame -func _settle_trust_reaction() -> void: - for _frame in 12: - await process_frame +func _stage_pantry_restock(main_scene: Node) -> bool: + 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 func _capture(output_path: String) -> Dictionary: @@ -268,8 +322,20 @@ func _apply_capture_camera(main_scene: Node) -> void: var camera_rig := main_scene.get_node("CameraRig") if camera_rig.has_method("apply_presentation_preset"): 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) var camera := main_scene.get_node("CameraRig/Camera3D") as Camera3D 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 diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index c2967bc..55a7e20 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -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="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="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="Script" path="res://world/storage/PantryStockVisual.gd" id="18_pantry_visual"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] _shader_parameters = { @@ -74,6 +75,83 @@ size = Vector3(1.0, 0.08, 0.5) material = SubResource("Material_site_canvas") 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"] material = SubResource("Material_site_book") 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")] position = Vector3(0, 0, -8) +[node name="Visual" parent="WorldObjects/StorageSites/VillagePantry"] +visible = false + [node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] position = Vector3(8, 0, -12) storage_id = &"village_woodpile" @@ -471,16 +552,128 @@ position = Vector3(-1.05, 1.85, 1.25) rotation_degrees = Vector3(0, 90, 0) mesh = SubResource("Mesh_site_sign") -[node name="PantrySack_A" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"] -position = Vector3(-0.95, 0.3, -0.65) -scale = Vector3(0.65, 1.0, 0.65) +[node name="PantryPresentation" type="Node3D" parent="WorldObjects/StorageSites/VillagePantry"] +position = Vector3(0, 0, -0.48) +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") -[node name="PantrySack_B" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"] -position = Vector3(-1.35, 0.22, -0.25) -scale = Vector3(0.5, 0.75, 0.5) +[node name="Basket" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry/PantryPresentation/LowStock"] +position = Vector3(0.36, 0.32, -0.02) +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") +[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="GuardPost" type="Node3D" parent="WorldObjects/ActivitySites"] diff --git a/world/storage/PantryStockVisual.gd b/world/storage/PantryStockVisual.gd new file mode 100644 index 0000000..f098cdc --- /dev/null +++ b/world/storage/PantryStockVisual.gd @@ -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) diff --git a/world/storage/PantryStockVisual.gd.uid b/world/storage/PantryStockVisual.gd.uid new file mode 100644 index 0000000..b568960 --- /dev/null +++ b/world/storage/PantryStockVisual.gd.uid @@ -0,0 +1 @@ +uid://bj2aigpu1nug6 diff --git a/world/storage/StorageNode.tscn b/world/storage/StorageNode.tscn index ea9dc86..a0af5fe 100644 --- a/world/storage/StorageNode.tscn +++ b/world/storage/StorageNode.tscn @@ -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"] @@ -18,10 +18,6 @@ size = Vector3(1.6, 1.1, 1.6) material = SubResource("Material_cloth") 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"] 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) 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="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2.0) diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index 9b08559..ec12c99 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -5,6 +5,7 @@ extends Node @export var npc_visual_scene: PackedScene @export var simulation_manager: Node @export var active_npcs_parent: Node3D +@export var pantry_visual: Node3D var active_npc_visuals := {} @@ -44,11 +45,24 @@ func initialize_world_view() -> void: simulation_manager.relationship_changed.connect(_on_relationship_changed) else: 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"): simulation_manager.state_restored.connect(_on_simulation_state_restored) else: push_error("WorldViewManager: SimulationManager has no state_restored signal") spawn_initial_npcs() + _refresh_world_state_presentation(false) func spawn_initial_npcs() -> void: @@ -86,6 +100,7 @@ func spawn_npc_visual(npc: SimNPC) -> void: else: push_error("WorldViewManager: NPCVisual has no position_changed signal") 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]) if npc.is_dead: visual.apply_dead_visual_state() @@ -200,8 +215,62 @@ func _on_relationship_changed( 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: for visual in active_npc_visuals.values(): visual.queue_free() active_npc_visuals.clear() 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 + )