diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 9542a49..5ba42c8 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -787,16 +787,19 @@ Completed: separate terrain-snapped approach points. Matched captures and a focused scenario prove three-batch visual cost, nested discovery, and a forced query beyond the first 24 m range without changing the 18-resource state. +38. `Jajce Resource States 05`: a shared amount-derived presentation contract + now gives that berry and tree honest full, low, and depleted states. Real + player berry harvests thin fruit and leaves; real NPC wood gathers thin the + crown and leave a stump. Low and depleted saves restore the same visuals + using only `amount_remaining`, with matched Metal comparison frames. Next: -1. Give the authored berry/tree resources state-driven low/depleted visuals - sourced from their real `ResourceStateRecord` amount. Prove NPC and player - extraction plus save/restore rebuild the same presentation without adding - visual fields to simulation state. -2. Apply the workflow to a second forest-edge consumer only after that contract - is honest and reusable. Add livestock only with a real identity and - interaction contract. +1. Move the existing `tree_forest_grove_01` and `tree_forest_grove_02` anchors + into one bounded forest-edge authoring cluster. Reuse the proven tree amount + presentation, preserve both stable IDs, and prove reachable expanding + discovery plus depletion/restore without serializing decorative foliage. +2. Add livestock only with a real identity and interaction contract. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index d6abaf3..2381cf4 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -904,17 +904,29 @@ of eighteen resources, sit above the carved channel, and expose separate terrain-snapped approach points. A forced village query expands beyond the initial 24 m range and selects the nested berry exactly by ID. -The immediate next slice should make those two resource-specific visuals -reflect authoritative amount/depletion: thin the berry patch and leave a stump -or equivalent readable tree state after real NPC/player extraction, then prove -save/restore rebuilds the same presentation without persisting visual-only -state. Only after both the berry and tree consume that contract should shared -cluster/depletion tooling be extracted or the workflow copied to a forest -edge. Do not generalize the resource grid to people/buildings/events or -introduce active/abstract LOD before those consumers need it. +The first amount-derived resource presentation slice is complete. A shared +`ResourceAmountVisual` contract reads the loaded `ResourceNode`'s authoritative +amount and derives full, low, or depleted presentation without entering saved +state. Real player berry harvests remove fruit and leaf mass; real NPC wood +gathers thin the crown and ultimately leave a warm-cut stump. Restoring both +low and depleted snapshots rebinds the same nodes and reconstructs the same +visuals from `ResourceStateRecord.amount_remaining` alone. + +The immediate next slice should apply that proven contract to the existing +`tree_forest_grove_01` and `tree_forest_grove_02` anchors as one bounded +forest-edge authoring consumer. Preserve both stable IDs, keep decorative +canopy/understory outside simulation state, and prove reachable expanding +discovery plus depletion/restore with the shared tree presentation. Do not +generalize the resource grid to people/buildings/events or introduce +active/abstract LOD before those consumers need it. Recently completed: +- `Jajce Resource States 05`: the authored river berry/tree now derive full, + low, and depleted visuals from authoritative amounts. Player and NPC + extraction drive the changes, save/restore reconstructs low and depleted + states without visual save fields, and matched Metal frames record the berry + thinning, sparse crown, fruitless remnant, and stump. - `Jajce Resource Grove 04`: two existing river resources now live as nested stable-ID anchors inside a restrained decorative bank cluster. Twenty-eight understory instances use three MultiMesh batches, bespoke berry/tree visuals diff --git a/docs/baselines/JAJCE_RESOURCE_STATES_05.md b/docs/baselines/JAJCE_RESOURCE_STATES_05.md new file mode 100644 index 0000000..2e1b9a2 --- /dev/null +++ b/docs/baselines/JAJCE_RESOURCE_STATES_05.md @@ -0,0 +1,80 @@ +# Jajce Resource States 05 + +Captured: 2026-07-17 + +## Files + +- [full resources](jajce_resource_states_05_full.png) +- [low resources](jajce_resource_states_05_low.png) +- [depleted resources](jajce_resource_states_05_depleted.png) +- `world/resource_nodes/ResourceAmountVisual.gd` +- `world/jajce/HarvestableTreePresentation.tscn` +- `tests/jajce_resource_presentation_state_test.gd` + +The three images use the same 1280×720 cross-river camera, daylight value, +warmup, and Metal renderer. Debug labels are hidden. The lower-left berry patch +and right-hand harvestable tree are shown at their authored full amounts, at +40%/50% respectively, and at zero. + +## Amount-derived visual language + +`ResourceAmountVisual` finds its owning `ResourceNode` and derives one of three +tiers from `amount_remaining / initial_amount`: + +- full keeps all five berry leaf clumps, eight berries, and four tree canopies; +- low keeps three leaf clumps, four berries, and two tree canopies; and +- depleted keeps a two-leaf fruitless remnant and replaces the tree with a + low-poly stump and warm cut face. + +The threshold is deliberately simple and readable: a positive source at or +below half amount is low. The berry keeps using two bounded MultiMeshes, so +thinning changes only `visible_instance_count`; it does not create or destroy +nodes during play. The tree wrapper reuses the existing wind-reactive standing +tree and toggles authored crown/stump parts. + +## Authority and restore proof + +`ResourceStateRecord` remains the only amount authority. `ResourceNode` now +emits its existing `amount_changed` presentation signal when authoritative +state is rebound, including restore. The visual contract listens to that signal +and also refreshes once after scene readiness, covering either bind order. + +The focused scenario performs two real player harvests on +`berry_bush_river_02` and two real NPC gather completions on +`tree_river_resource_01`, checks the low presentation and NPC wood inventory, +then fully depletes both through those same production paths. It restores low +and depleted snapshots in turn and verifies the presentation is reconstructed. +The resource records contain no visual, visibility, tier, or stump fields. + +The capture tool's optional amount overrides also construct and bind ordinary +`ResourceStateRecord`s. They do not directly manipulate visual children, so +the review images exercise the same runtime contract as gameplay. + +## Next slice + +Apply this contract to the existing `tree_forest_grove_01` and +`tree_forest_grove_02` stable IDs in one bounded forest-edge cluster. Decorative +canopy and understory should stay presentation-only while the two finite trees +remain reachable, discoverable, depletable, and restore correctly. + +## Capture commands + +```bash +HOME="$PWD/logs/quality/godot_profile" \ +APPDATA="$PWD/logs/quality/godot_profile" \ +LOCALAPPDATA="$PWD/logs/quality/godot_profile" \ +/Applications/Godot.app/Contents/MacOS/Godot \ + --path "$PWD" --script res://tools/capture_jajce_lookdev.gd -- \ + --scene=res://world/jajce/JajceResourceStateLookdev.tscn \ + --output=res://docs/baselines/jajce_resource_states_05_full.png \ + --hide-resource-labels +``` + +For the low frame, append: + +```text +--resource-amount=berry_bush_river_02:2 +--resource-amount=tree_river_resource_01:4 +``` + +For the depleted frame, use the same arguments with both amounts set to `0`. diff --git a/docs/baselines/jajce_resource_states_05_depleted.png b/docs/baselines/jajce_resource_states_05_depleted.png new file mode 100644 index 0000000..ccbae8f Binary files /dev/null and b/docs/baselines/jajce_resource_states_05_depleted.png differ diff --git a/docs/baselines/jajce_resource_states_05_depleted.png.import b/docs/baselines/jajce_resource_states_05_depleted.png.import new file mode 100644 index 0000000..a00d291 --- /dev/null +++ b/docs/baselines/jajce_resource_states_05_depleted.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cnusf1uofgeam" +path="res://.godot/imported/jajce_resource_states_05_depleted.png-4da1f8d2357fb72267bc5e3167868d55.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_resource_states_05_depleted.png" +dest_files=["res://.godot/imported/jajce_resource_states_05_depleted.png-4da1f8d2357fb72267bc5e3167868d55.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/jajce_resource_states_05_full.png b/docs/baselines/jajce_resource_states_05_full.png new file mode 100644 index 0000000..1ceba39 Binary files /dev/null and b/docs/baselines/jajce_resource_states_05_full.png differ diff --git a/docs/baselines/jajce_resource_states_05_full.png.import b/docs/baselines/jajce_resource_states_05_full.png.import new file mode 100644 index 0000000..4936aa3 --- /dev/null +++ b/docs/baselines/jajce_resource_states_05_full.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6fr86t0glm1p" +path="res://.godot/imported/jajce_resource_states_05_full.png-b7555dd39fe62146f66c1e731a0813d3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_resource_states_05_full.png" +dest_files=["res://.godot/imported/jajce_resource_states_05_full.png-b7555dd39fe62146f66c1e731a0813d3.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/jajce_resource_states_05_low.png b/docs/baselines/jajce_resource_states_05_low.png new file mode 100644 index 0000000..c9fe9c3 Binary files /dev/null and b/docs/baselines/jajce_resource_states_05_low.png differ diff --git a/docs/baselines/jajce_resource_states_05_low.png.import b/docs/baselines/jajce_resource_states_05_low.png.import new file mode 100644 index 0000000..6b7ada0 --- /dev/null +++ b/docs/baselines/jajce_resource_states_05_low.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://lerebtc2de5v" +path="res://.godot/imported/jajce_resource_states_05_low.png-ca9e13189ad1a8dc22e32380ddf38b2f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_resource_states_05_low.png" +dest_files=["res://.godot/imported/jajce_resource_states_05_low.png-ca9e13189ad1a8dc22e32380ddf38b2f.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/tests/jajce_resource_presentation_state_test.gd b/tests/jajce_resource_presentation_state_test.gd new file mode 100644 index 0000000..4a80b03 --- /dev/null +++ b/tests/jajce_resource_presentation_state_test.gd @@ -0,0 +1,211 @@ +extends SceneTree + +const BERRY_ID := &"berry_bush_river_02" +const TREE_ID := &"tree_river_resource_01" +const CLUSTER_PATH := "JajceWorld/WorldObjects/ResourceClusters/RiverbankResourceCluster" + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main_scene: Node = load("res://main.tscn").instantiate() + root.add_child(main_scene) + await process_frame + for _frame in 3: + await physics_frame + + var manager = main_scene.get_node("SimulationManager") + manager.set_process(false) + var cluster := main_scene.get_node(CLUSTER_PATH) as RiverbankResourceCluster + var berry := cluster.get_node("ResourceAnchors/BerryBush_River_02") as ResourceNode + var tree := cluster.get_node("ResourceAnchors/Tree_River_Resource_01") as ResourceNode + var berry_visual := berry.get_node("Visual/BerryPatchPresentation") as StylizedBerryPatch + var tree_visual := tree.get_node("Visual/TreePresentation") as HarvestableTreePresentation + + _check_initial_presentation(berry_visual, tree_visual) + + var berry_state: ResourceStateRecord = manager.get_resource_state(BERRY_ID) + var tree_state: ResourceStateRecord = manager.get_resource_state(TREE_ID) + _check(berry_state != null and tree_state != null, "Authored resources should bind state") + if berry_state == null or tree_state == null: + _finish() + return + + for _action in 2: + _check( + is_equal_approx(manager.harvest_resource_node(berry), berry.yield_per_action), + "Player harvesting should extract the configured berry yield" + ) + var npc: SimNPC = manager.npcs[0] + for _action in 2: + _complete_npc_gather(manager, npc, tree) + + _check( + is_equal_approx(berry_state.get_amount_remaining(), 2.0), + "Two player harvests should leave the authored berry at 40 percent" + ) + _check( + berry_visual.get_amount_tier() == ResourceAmountVisual.AmountTier.LOW, + "The berry should derive its low tier from authoritative amount" + ) + _check( + berry_visual.get_visible_leaf_count() == 3 and berry_visual.get_visible_berry_count() == 4, + "The low berry should visibly thin to three leaves and four berries" + ) + _check( + is_equal_approx(tree_state.get_amount_remaining(), 4.0), + "Two NPC completions should leave the authored tree at half amount" + ) + _check( + tree_visual.get_amount_tier() == ResourceAmountVisual.AmountTier.LOW, + "The tree should derive its low tier from authoritative amount" + ) + _check( + ( + tree_visual.is_standing_visible() + and not tree_visual.is_stump_visible() + and tree_visual.get_visible_canopy_count() == 2 + ), + "The low tree should remain standing with a visibly thinned crown" + ) + _check( + is_equal_approx(npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD), 4.0), + "The low tree state should come from real NPC inventory-producing extraction" + ) + + var low_snapshot: String = manager.serialize_state() + _check_no_visual_state(low_snapshot) + + while berry_state.can_extract(): + manager.harvest_resource_node(berry) + while tree_state.can_extract(): + _complete_npc_gather(manager, npc, tree) + + _check_depleted_presentation(berry_visual, tree_visual) + var depleted_snapshot: String = manager.serialize_state() + _check_no_visual_state(depleted_snapshot) + + _check( + manager.restore_state_from_json(low_snapshot), + "Low authoritative resource state should restore" + ) + berry_state = manager.get_resource_state(BERRY_ID) + tree_state = manager.get_resource_state(TREE_ID) + _check( + ( + is_equal_approx(berry_state.get_amount_remaining(), 2.0) + and berry_visual.get_visible_berry_count() == 4 + and berry_visual.get_amount_tier() == ResourceAmountVisual.AmountTier.LOW + ), + "Restore should rebuild the low berry visual from amount alone" + ) + _check( + ( + is_equal_approx(tree_state.get_amount_remaining(), 4.0) + and tree_visual.get_visible_canopy_count() == 2 + and not tree_visual.is_stump_visible() + ), + "Restore should rebuild the low standing tree from amount alone" + ) + + _check( + manager.restore_state_from_json(depleted_snapshot), + "Depleted authoritative resource state should restore" + ) + _check_depleted_presentation(berry_visual, tree_visual) + _finish() + + +func _check_initial_presentation( + berry_visual: StylizedBerryPatch, tree_visual: HarvestableTreePresentation +) -> void: + _check( + ( + berry_visual.get_amount_tier() == ResourceAmountVisual.AmountTier.FULL + and berry_visual.get_visible_leaf_count() == 5 + and berry_visual.get_visible_berry_count() == 8 + ), + "A full berry source should show all authored leaves and fruit" + ) + _check( + ( + tree_visual.get_amount_tier() == ResourceAmountVisual.AmountTier.FULL + and tree_visual.is_standing_visible() + and not tree_visual.is_stump_visible() + and tree_visual.get_visible_canopy_count() == 4 + ), + "A full tree source should show its complete standing crown" + ) + + +func _complete_npc_gather(manager, npc: SimNPC, resource: ResourceNode) -> void: + npc.target_id = resource.node_id + var reserved: bool = manager.reserve_resource(resource.node_id, npc.id) + _check(reserved, "NPC extraction should reserve the authored tree") + if reserved: + manager.call("_complete_resource_gather", npc, SimulationIds.ACTION_GATHER_WOOD) + + +func _check_depleted_presentation( + berry_visual: StylizedBerryPatch, tree_visual: HarvestableTreePresentation +) -> void: + _check( + ( + berry_visual.get_amount_tier() == ResourceAmountVisual.AmountTier.DEPLETED + and berry_visual.get_visible_leaf_count() == 2 + and berry_visual.get_visible_berry_count() == 0 + ), + "Real player depletion should leave a small fruitless berry remnant" + ) + _check( + ( + tree_visual.get_amount_tier() == ResourceAmountVisual.AmountTier.DEPLETED + and not tree_visual.is_standing_visible() + and tree_visual.is_stump_visible() + and tree_visual.get_visible_canopy_count() == 0 + ), + "Real NPC depletion should replace the standing tree with its stump" + ) + + +func _check_no_visual_state(snapshot: String) -> void: + var parsed = JSON.parse_string(snapshot) + _check(parsed is Dictionary, "Serialized simulation state should remain valid JSON") + if not parsed is Dictionary: + return + var matched_records := 0 + for resource_data in parsed.get("resources", []): + if StringName(resource_data.get("node_id", "")) not in [BERRY_ID, TREE_ID]: + continue + matched_records += 1 + for field in resource_data.keys(): + var field_name := String(field) + _check( + ( + not field_name.contains("visual") + and not field_name.contains("visible") + and not field_name.contains("tier") + and not field_name.contains("stump") + ), + "Resource save fields should contain authority, never visual state" + ) + _check(matched_records == 2, "Both authored resources should remain in simulation saves") + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Jajce resource presentation passed: amount -> low/depleted -> restore") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/jajce_resource_presentation_state_test.gd.uid b/tests/jajce_resource_presentation_state_test.gd.uid new file mode 100644 index 0000000..6d6d339 --- /dev/null +++ b/tests/jajce_resource_presentation_state_test.gd.uid @@ -0,0 +1 @@ +uid://c7yc6j4js8phj diff --git a/tools/capture_jajce_lookdev.gd b/tools/capture_jajce_lookdev.gd index 399c16d..5b843d8 100644 --- a/tools/capture_jajce_lookdev.gd +++ b/tools/capture_jajce_lookdev.gd @@ -23,6 +23,9 @@ func _run() -> void: var lookdev := scene.instantiate() as Node3D root.add_child(lookdev) await process_frame + if not _apply_resource_capture_overrides(): + quit(1) + return _stabilize_lookdev(lookdev) for _frame in WARMUP_FRAMES: await process_frame @@ -73,6 +76,32 @@ func _get_argument_value(prefix: String, default_value: String) -> String: return default_value +func _apply_resource_capture_overrides() -> bool: + var hide_labels := OS.get_cmdline_user_args().has("--hide-resource-labels") + for resource in ResourceNode.get_all(): + if hide_labels: + resource.set_debug_label_enabled(false) + + for argument in OS.get_cmdline_user_args(): + if not argument.begins_with("--resource-amount="): + continue + var override_text := argument.trim_prefix("--resource-amount=") + var parts := override_text.split(":", false, 1) + if parts.size() != 2 or not parts[1].is_valid_float(): + push_error("Invalid resource amount override: %s" % override_text) + return false + var resource := ResourceNode.get_by_id(StringName(parts[0])) + if resource == null: + push_error("Unknown resource amount override ID: %s" % parts[0]) + return false + var state := ResourceStateRecord.create_from_node(resource) + state.set_amount_remaining(parts[1].to_float()) + if not resource.bind_state(state): + push_error("Could not bind capture state for resource: %s" % parts[0]) + return false + return true + + func _analyze_image(image: Image) -> Dictionary: var min_luma := INF var max_luma := -INF diff --git a/world/jajce/HarvestableTreePresentation.gd b/world/jajce/HarvestableTreePresentation.gd new file mode 100644 index 0000000..8d1a6a7 --- /dev/null +++ b/world/jajce/HarvestableTreePresentation.gd @@ -0,0 +1,41 @@ +class_name HarvestableTreePresentation +extends ResourceAmountVisual + +const THINNED_CANOPIES := [&"CanopyLeft", &"CanopyRight"] + +@onready var standing_tree: Node3D = $StandingTree +@onready var stump: Node3D = $Stump + + +func _ready() -> void: + super() + + +func is_standing_visible() -> bool: + return standing_tree.visible + + +func is_stump_visible() -> bool: + return stump.visible + + +func get_visible_canopy_count() -> int: + if not standing_tree.visible: + return 0 + var visible_count := 0 + for child in standing_tree.get_children(): + if String(child.name).begins_with("Canopy") and child.visible: + visible_count += 1 + return visible_count + + +func _apply_resource_amount_visual( + _amount_remaining: float, _remaining_ratio: float, tier: AmountTier +) -> void: + var depleted := tier == AmountTier.DEPLETED + standing_tree.visible = not depleted + stump.visible = depleted + for canopy_name in THINNED_CANOPIES: + var canopy := standing_tree.get_node_or_null(NodePath(canopy_name)) as Node3D + if canopy != null: + canopy.visible = tier == AmountTier.FULL diff --git a/world/jajce/HarvestableTreePresentation.gd.uid b/world/jajce/HarvestableTreePresentation.gd.uid new file mode 100644 index 0000000..6ef8e79 --- /dev/null +++ b/world/jajce/HarvestableTreePresentation.gd.uid @@ -0,0 +1 @@ +uid://cpue05wbg6roj diff --git a/world/jajce/HarvestableTreePresentation.tscn b/world/jajce/HarvestableTreePresentation.tscn new file mode 100644 index 0000000..054f885 --- /dev/null +++ b/world/jajce/HarvestableTreePresentation.tscn @@ -0,0 +1,42 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Script" path="res://world/jajce/HarvestableTreePresentation.gd" id="1_script"] +[ext_resource type="PackedScene" path="res://world/jajce/StylizedTree.tscn" id="2_tree"] + +[sub_resource type="StandardMaterial3D" id="Material_stump_bark"] +albedo_color = Color(0.29, 0.17, 0.09, 1) +roughness = 0.96 + +[sub_resource type="CylinderMesh" id="Mesh_stump"] +material = SubResource("Material_stump_bark") +top_radius = 0.38 +bottom_radius = 0.48 +height = 0.82 +radial_segments = 10 + +[sub_resource type="StandardMaterial3D" id="Material_stump_cut"] +albedo_color = Color(0.69, 0.49, 0.27, 1) +roughness = 0.9 + +[sub_resource type="CylinderMesh" id="Mesh_stump_cut"] +material = SubResource("Material_stump_cut") +top_radius = 0.36 +bottom_radius = 0.37 +height = 0.055 +radial_segments = 10 + +[node name="HarvestableTreePresentation" type="Node3D"] +script = ExtResource("1_script") + +[node name="StandingTree" parent="." instance=ExtResource("2_tree")] + +[node name="Stump" type="Node3D" parent="."] +visible = false + +[node name="Bark" type="MeshInstance3D" parent="Stump"] +position = Vector3(0, 0.41, 0) +mesh = SubResource("Mesh_stump") + +[node name="CutFace" type="MeshInstance3D" parent="Stump"] +position = Vector3(0, 0.835, 0) +mesh = SubResource("Mesh_stump_cut") diff --git a/world/jajce/JajceResourceStateLookdev.tscn b/world/jajce/JajceResourceStateLookdev.tscn new file mode 100644 index 0000000..aa12a79 --- /dev/null +++ b/world/jajce/JajceResourceStateLookdev.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="1_world"] +[ext_resource type="Script" path="res://world/jajce/beauty_camera.gd" id="2_camera"] + +[node name="JajceResourceStateLookdev" type="Node3D"] + +[node name="JajceWorld" parent="." instance=ExtResource("1_world")] + +[node name="ResourceCamera" type="Camera3D" parent="."] +position = Vector3(14, 9, 7) +current = true +fov = 40.0 +script = ExtResource("2_camera") +focus_point = Vector3(34, 3.3, 7) +animate_orbit = false diff --git a/world/jajce/RiverbankResourceCluster.tscn b/world/jajce/RiverbankResourceCluster.tscn index 3d697b2..dcad6b5 100644 --- a/world/jajce/RiverbankResourceCluster.tscn +++ b/world/jajce/RiverbankResourceCluster.tscn @@ -1,10 +1,11 @@ -[gd_scene load_steps=6 format=3] +[gd_scene load_steps=7 format=3] [ext_resource type="Script" path="res://world/jajce/RiverbankResourceCluster.gd" id="1_script"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/jajce/StylizedTree.tscn" id="3_tree"] [ext_resource type="PackedScene" path="res://world/jajce/StylizedBerryPatch.tscn" id="4_berry"] [ext_resource type="PackedScene" path="res://assets/foliage/tree.glb" id="5_conifer"] +[ext_resource type="PackedScene" path="res://world/jajce/HarvestableTreePresentation.tscn" id="6_harvestable_tree"] [node name="RiverbankResourceCluster" type="Node3D"] script = ExtResource("1_script") @@ -66,7 +67,7 @@ discovery_priority = 0.1 [node name="MeshInstance3D" parent="ResourceAnchors/Tree_River_Resource_01/Visual"] visible = false -[node name="TreePresentation" parent="ResourceAnchors/Tree_River_Resource_01/Visual" instance=ExtResource("3_tree")] +[node name="TreePresentation" parent="ResourceAnchors/Tree_River_Resource_01/Visual" instance=ExtResource("6_harvestable_tree")] scale = Vector3(1.08, 1.08, 1.08) [node name="InteractionPoint" parent="ResourceAnchors/Tree_River_Resource_01"] diff --git a/world/jajce/StylizedBerryPatch.gd b/world/jajce/StylizedBerryPatch.gd index efbe0eb..b39d216 100644 --- a/world/jajce/StylizedBerryPatch.gd +++ b/world/jajce/StylizedBerryPatch.gd @@ -1,5 +1,5 @@ class_name StylizedBerryPatch -extends Node3D +extends ResourceAmountVisual const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader") const LEAF_POSITIONS := [ @@ -31,12 +31,37 @@ const BERRY_POSITIONS := [ func _ready() -> void: _build_leaves() _build_berries() + super() func get_presentation_instance_count() -> int: return LEAF_POSITIONS.size() + BERRY_POSITIONS.size() +func get_visible_leaf_count() -> int: + return $Leaves.multimesh.visible_instance_count + + +func get_visible_berry_count() -> int: + return $Berries.multimesh.visible_instance_count + + +func _apply_resource_amount_visual( + _amount_remaining: float, remaining_ratio: float, tier: AmountTier +) -> void: + var visible_leaves := LEAF_POSITIONS.size() + if tier == AmountTier.LOW: + visible_leaves = 3 + elif tier == AmountTier.DEPLETED: + visible_leaves = 2 + $Leaves.multimesh.visible_instance_count = visible_leaves + + var visible_berries := ceili(remaining_ratio * BERRY_POSITIONS.size()) + if tier == AmountTier.DEPLETED: + visible_berries = 0 + $Berries.multimesh.visible_instance_count = visible_berries + + func _build_leaves() -> void: var mesh := SphereMesh.new() mesh.radius = 1.0 diff --git a/world/resource_nodes/ResourceAmountVisual.gd b/world/resource_nodes/ResourceAmountVisual.gd new file mode 100644 index 0000000..82e182a --- /dev/null +++ b/world/resource_nodes/ResourceAmountVisual.gd @@ -0,0 +1,84 @@ +class_name ResourceAmountVisual +extends Node3D + +enum AmountTier { + FULL, + LOW, + DEPLETED, +} + +@export_range(0.0, 1.0, 0.05) var low_amount_ratio := 0.5 + +var resource_node: ResourceNode +var amount_ratio := 1.0 +var amount_tier := AmountTier.FULL + + +func _ready() -> void: + call_deferred("_bind_resource_node") + + +func _exit_tree() -> void: + if ( + resource_node != null + and resource_node.amount_changed.is_connected(_on_resource_amount_changed) + ): + resource_node.amount_changed.disconnect(_on_resource_amount_changed) + resource_node = null + + +func refresh_from_resource() -> void: + if resource_node == null: + return + _apply_amount(resource_node.get_amount_remaining()) + + +func get_amount_tier() -> AmountTier: + return amount_tier + + +func get_amount_ratio() -> float: + return amount_ratio + + +func _bind_resource_node() -> void: + resource_node = _find_resource_node() + if resource_node == null: + return + if not resource_node.amount_changed.is_connected(_on_resource_amount_changed): + resource_node.amount_changed.connect(_on_resource_amount_changed) + refresh_from_resource() + + +func _find_resource_node() -> ResourceNode: + var candidate := get_parent() + while candidate != null: + if candidate is ResourceNode: + return candidate as ResourceNode + candidate = candidate.get_parent() + return null + + +func _on_resource_amount_changed(changed_id: StringName, amount_remaining: float) -> void: + if resource_node != null and changed_id == resource_node.node_id: + _apply_amount(amount_remaining) + + +func _apply_amount(amount_remaining: float) -> void: + var initial_amount := maxf(resource_node.initial_amount, 0.0) + amount_ratio = ( + clampf(amount_remaining / initial_amount, 0.0, 1.0) if initial_amount > 0.0 else 0.0 + ) + if amount_remaining <= 0.0: + amount_tier = AmountTier.DEPLETED + elif amount_ratio <= low_amount_ratio: + amount_tier = AmountTier.LOW + else: + amount_tier = AmountTier.FULL + _apply_resource_amount_visual(amount_remaining, amount_ratio, amount_tier) + + +func _apply_resource_amount_visual( + _amount_remaining: float, _remaining_ratio: float, _tier: AmountTier +) -> void: + pass diff --git a/world/resource_nodes/ResourceAmountVisual.gd.uid b/world/resource_nodes/ResourceAmountVisual.gd.uid new file mode 100644 index 0000000..f8a9464 --- /dev/null +++ b/world/resource_nodes/ResourceAmountVisual.gd.uid @@ -0,0 +1 @@ +uid://bls5qs3s3omgh diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index 64623ee..9359649 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -74,6 +74,7 @@ func bind_state(resource_state: ResourceStateRecord) -> bool: if not state.depleted.is_connected(_on_state_depleted): state.depleted.connect(_on_state_depleted) _update_presentation() + amount_changed.emit(node_id, state.get_amount_remaining()) _notify_world_adapters() return true