diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 3b2eccb..49166ea 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -69,6 +69,10 @@ interaction positions by action and horizontal cell, plus authoritative metadata bounds copied at bind time. It does not own amounts, enabled state, reservations, or persistence. `ResourceNode` enter/exit, bind, and transform notifications keep it synchronized with the loaded presentation lifecycle. +Resource anchors may be nested inside authored presentation clusters: runtime +discovery uses their stable IDs and registration lifecycle, never a parent +path. Decorative siblings without a `ResourceNode` remain outside the index +and simulation state. Outside the runtime lifecycle, `simulation/benchmark/` owns reusable, schema-valid workload fixtures. CLI tools and headless scenarios consume those diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index a6f6792..9542a49 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -780,16 +780,22 @@ Completed: aligned; softened foam/mist and a smaller rock frame make the falls part of the landform. A matched Metal capture records the change, and the rebaked navigation mesh passes terrain-height and runtime route checks. +37. `Jajce Resource Grove 04`: the right riverbank now groups 28 intentionally + instanced fern/flower/stone details and two decorative trees around two + existing finite resources. The berry and tree keep their stable IDs and + metadata, use specific visuals instead of boxes, and remain reachable from + 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. Next: -1. Prove one bounded foliage/resource authoring workflow at a riverbank or - forest edge. Use decorative Terrain3D/intentional instances for tree, fern, - flower, and rock masses, but create simulation-owned finite resources only - through selected stable-ID `ResourceNode` anchors. -2. Validate that villagers discover both nearby and farther authored anchors, - preserve reachable interaction points, and keep the visual batch inside a - measured presentation budget. Add livestock only with a real identity and +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. 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 d675c32..d6abaf3 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -896,17 +896,30 @@ plunge basin, and curved downstream channel. A matched Metal capture records the result, and the Terrain3D-derived navigation mesh was rebaked and validated against required village/resource routes. -The immediate next slice should prove one bounded foliage/resource authoring -workflow in the real world: build a restrained riverbank or forest-edge cluster -with decorative Terrain3D/intentional instances, pair only selected harvestable -trees or berry patches with stable-ID `ResourceNode` anchors, and verify nearby -and farther loaded anchors remain discoverable through `ActiveWorldAdapter`. -Keep decorative instances out of simulation state, measure the bounded visual -cost, and do not generalize the resource grid to people/buildings/events or +The first authored foliage/resource workflow is complete. One riverbank cluster +uses three intentional MultiMesh batches for 28 fern, flower, and stone +instances plus two decorative trees, while only an existing stable-ID berry and +tree are finite `ResourceNode` anchors. Both anchors preserve the save contract +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. Recently completed: +- `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 + replace generic boxes, terrain-snapped approach points remain reachable, and + a real far-source query proves parent-path-independent expanding discovery. - `Jajce River 03`: deterministic Terrain3D generation now carves the upper stream, raised waterfall shelf, plunge basin, and curved downstream channel; shared watercourse geometry keeps both water ribbons aligned, soft masked diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 6f1b2ce..fdd505d 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -354,7 +354,9 @@ but retain far sources whenever their authoritative metadata can still win. transforms, authored conifer clusters, and oversized ambient butterflies; - one sparse terrain-aware calligraphic gust field with two or three soft tapered strokes per burst; -- eighteen ResourceNodes preserving stable food/wood discovery IDs; +- eighteen ResourceNodes preserving stable food/wood discovery IDs, including + one riverbank cluster that nests two finite anchors among 28 decorative + fern/flower/stone instances in three bounded MultiMesh batches; - typed village pantry storage and typed guard, study, and rest activity sites; - a Terrain3D-derived baked navigation mesh covering the current playable loop; - base collision/navigation guardrails that keep Terrain3D collision enabled, @@ -980,13 +982,14 @@ Raw samples and workload exclusions live in [Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md) and [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). -The authored Jajce watercourse and its Terrain3D-derived navigation rebake are -complete. The next slice should prove a bounded stable-ID resource-anchor -placement workflow beside decorative Terrain3D/intentional foliage instances -at one riverbank or forest edge. It should validate nearby and farther loaded -resource discovery, reachable interaction points, and a measured presentation -budget without making decorative instances authoritative. Do not yet -generalize the resource grid or introduce active/abstract simulation LOD. +The authored Jajce watercourse, navigation rebake, and first bounded +foliage/resource placement proof are complete. The next slice should derive +low/depleted berry and tree visuals from authoritative resource amount, cover +real NPC/player extraction and save/restore reconstruction, and keep that +presentation out of serialized simulation state. Apply the cluster workflow to +a second forest edge only after the depletion contract supplies a real second +consumer. Do not yet generalize the resource grid or introduce active/abstract +simulation LOD. The remaining simulation-garden target still aims for: diff --git a/docs/RESOURCE_NODE_MIGRATION.md b/docs/RESOURCE_NODE_MIGRATION.md index c68422b..dec1bc2 100644 --- a/docs/RESOURCE_NODE_MIGRATION.md +++ b/docs/RESOURCE_NODE_MIGRATION.md @@ -567,6 +567,16 @@ existing `ResourceStateRecord`. The reviewed 18/180/1,800-source benchmark and exact-selection contract live in [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). +The first authored placement proof now nests `berry_bush_river_02` and +`tree_river_resource_01` inside one riverbank cluster without changing their +IDs, metadata, or the eighteen-resource save contract. Twenty-eight decorative +understory instances and two silhouette trees remain presentation-only. The +finite anchors have resource-specific visuals and separate terrain-snapped +approach points; recursive navigation checks cover both, while a forced query +selects the farther berry only after expanding beyond the first 24 m range. +The matched review lives in +[Jajce Resource Grove 04](baselines/JAJCE_RESOURCE_GROVE_04.md). + ## Test scenarios At minimum, exercise: diff --git a/docs/baselines/JAJCE_RESOURCE_GROVE_04.md b/docs/baselines/JAJCE_RESOURCE_GROVE_04.md new file mode 100644 index 0000000..fccb0eb --- /dev/null +++ b/docs/baselines/JAJCE_RESOURCE_GROVE_04.md @@ -0,0 +1,76 @@ +# Jajce Resource Grove 04 + +Captured: 2026-07-17 + +## Files + +- [before overview](jajce_resource_grove_04_before.png) +- [after overview](jajce_resource_grove_04_after.png) +- `world/jajce/RiverbankResourceCluster.tscn` +- `tests/jajce_resource_cluster_test.gd` + +The images use the same 1280×720 camera, fixed daylight value, warmup, and +Metal renderer. The new cluster is on the right bank beside and beyond the +bridge; the comparison intentionally keeps the broader citadel/waterfall +composition unchanged. + +## Visual reduction + +The former river edge had one isolated deciduous tree and conifer, while two +generic-box resource anchors sat separately from the foliage language. The +after frame gathers that silhouette into one restrained bank mass: + +- 12 low-poly fern clumps; +- 9 oversized warm flower heads; +- 7 flattened bank stones; +- 2 decorative silhouette trees; and +- 2 finite anchors with distinct berry-patch and harvestable-tree visuals. + +The 28 understory details use three `MultiMeshInstance3D` batches. The berry +visual adds five wind-reactive leaf clumps and eight deliberately readable +berries. This is a bounded authored accent, not a world-wide scatter pass. + +## Authority and authoring contract + +`DecorativeInstances` contains no `ResourceNode` descendants. Its trees, +ferns, flowers, and stones are transient presentation and never enter saves, +checksums, reservations, or resource conservation. + +`ResourceAnchors` contains only `berry_bush_river_02` and +`tree_river_resource_01`. These are the same existing IDs and definitions, now +nested inside the authored cluster. `ResourceNode` registration and +`ActiveWorldAdapter` discovery do not depend on a parent path, so simulation +authority still binds exactly 18 resources. + +The visible plants sit on the raised Terrain3D bank. Each anchor has a separate +approach marker snapped to the lower bank surface, keeping the visual context +honest without asking NPCs to climb an unreachable slope. All required +navigation routes reach those markers and remain close to Terrain3D height. + +## Far-discovery proof + +The berry interaction point is approximately 30.5 m from the village origin, +beyond the adapter's initial 24 m query radius. The focused scenario first +confirms ordinary nearby food remains available, then disables every other food +source in authoritative state. `ActionTargetResolver` expands through more than +one range pass and selects `berry_bush_river_02` by stable ID. The test also +checks that direct and nested anchors produce the same 18-entry index. + +## Remaining work + +The resource-specific visuals currently identify finite sources but do not yet +thin or change shape as their authoritative amount falls. The next vertical +slice should show low/depleted berries and a stump or equivalent tree state +after real NPC/player extraction, then verify save/load reconstructs the same +visual from `ResourceStateRecord` without serializing presentation fields. + +## Capture command + +```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 -- \ + --output=res://docs/baselines/jajce_resource_grove_04_after.png +``` diff --git a/docs/baselines/JAJCE_RIVER_03.md b/docs/baselines/JAJCE_RIVER_03.md index 7d9f8da..513b735 100644 --- a/docs/baselines/JAJCE_RIVER_03.md +++ b/docs/baselines/JAJCE_RIVER_03.md @@ -60,11 +60,11 @@ player resource gathering with all 18 finite resource anchors loaded. ## Next visual/system proof -The next pass should author one bounded riverbank or forest-edge foliage mass, -using decorative Terrain3D/intentional instances for visual density and only a -small selected set of stable-ID `ResourceNode` anchors for finite harvestable -resources. Bank stones, ferns, flowers, and tree masses can improve the channel -edge during that proof. Bridge, buildings, and fortress geometry remain +That proof is complete in +[Jajce Resource Grove 04](JAJCE_RESOURCE_GROVE_04.md): a bounded riverbank mass +keeps decorative density separate from two stable-ID finite anchors. The next +pass should make those berry/tree visuals reflect real depletion before adding +a second forest-edge cluster. Bridge, buildings, and fortress geometry remain prototype blockouts and should be replaced gradually rather than through a broad asset dump. diff --git a/docs/baselines/jajce_resource_grove_04_after.png b/docs/baselines/jajce_resource_grove_04_after.png new file mode 100644 index 0000000..d2aa51f Binary files /dev/null and b/docs/baselines/jajce_resource_grove_04_after.png differ diff --git a/docs/baselines/jajce_resource_grove_04_after.png.import b/docs/baselines/jajce_resource_grove_04_after.png.import new file mode 100644 index 0000000..d694ad4 --- /dev/null +++ b/docs/baselines/jajce_resource_grove_04_after.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drip3uanxmac0" +path="res://.godot/imported/jajce_resource_grove_04_after.png-400fd71bda2bd7202805c1ba66aa8c28.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_resource_grove_04_after.png" +dest_files=["res://.godot/imported/jajce_resource_grove_04_after.png-400fd71bda2bd7202805c1ba66aa8c28.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_grove_04_before.png b/docs/baselines/jajce_resource_grove_04_before.png new file mode 100644 index 0000000..3f1a17e Binary files /dev/null and b/docs/baselines/jajce_resource_grove_04_before.png differ diff --git a/docs/baselines/jajce_resource_grove_04_before.png.import b/docs/baselines/jajce_resource_grove_04_before.png.import new file mode 100644 index 0000000..ef1ddbb --- /dev/null +++ b/docs/baselines/jajce_resource_grove_04_before.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ch6nx7rj4elit" +path="res://.godot/imported/jajce_resource_grove_04_before.png-153834f7053f143a34b090dc21ac0d16.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/baselines/jajce_resource_grove_04_before.png" +dest_files=["res://.godot/imported/jajce_resource_grove_04_before.png-153834f7053f143a34b090dc21ac0d16.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/benchmarks/LOADED_RESOURCE_DISCOVERY_01.md b/docs/benchmarks/LOADED_RESOURCE_DISCOVERY_01.md index 06e6bba..83df50e 100644 --- a/docs/benchmarks/LOADED_RESOURCE_DISCOVERY_01.md +++ b/docs/benchmarks/LOADED_RESOURCE_DISCOVERY_01.md @@ -68,7 +68,10 @@ simulation-LOD framework yet. Terrain3D foliage instances remain decorative unless an intentional stable-ID `ResourceNode` anchor binds simulation-owned state. A later placement workflow can author those anchors alongside visual instancing without making every tree -an authoritative resource. +an authoritative resource. That authored proof is now complete in +[Jajce Resource Grove 04](../baselines/JAJCE_RESOURCE_GROVE_04.md): two nested +finite anchors remain discoverable among three bounded decorative MultiMesh +batches, including a real query that expands beyond its first local range. ## Capture command diff --git a/tests/jajce_resource_cluster_test.gd b/tests/jajce_resource_cluster_test.gd new file mode 100644 index 0000000..9e8d03f --- /dev/null +++ b/tests/jajce_resource_cluster_test.gd @@ -0,0 +1,184 @@ +extends SceneTree + +const FAR_BERRY_ID := &"berry_bush_river_02" +const CLUSTER_TREE_ID := &"tree_river_resource_01" + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var camera := Camera3D.new() + camera.current = true + root.add_child(camera) + var world: Node3D = load("res://world/jajce/JajceWorld.tscn").instantiate() + root.add_child(world) + await process_frame + for _frame in 5: + await physics_frame + + var terrain := world.get_node("TerrainRoot/Terrain3D") as Terrain3D + var cluster := ( + world.get_node("WorldObjects/ResourceClusters/RiverbankResourceCluster") + as RiverbankResourceCluster + ) + _check(cluster != null, "Jajce should expose the authored riverbank resource cluster") + if cluster == null: + _finish() + return + + var stats := cluster.get_presentation_stats() + _check( + int(stats["understory_instance_count"]) == 28, + "Riverbank understory should keep its reviewed 28-instance budget" + ) + _check( + int(stats["multimesh_batch_count"]) == 3, + "Riverbank understory should use exactly three intentional MultiMesh batches" + ) + _check( + int(stats["decorative_tree_count"]) == 2, + "Riverbank cluster should keep two non-authoritative silhouette trees" + ) + _check( + int(stats["resource_anchor_count"]) == 2, + "Riverbank cluster should pair density with only two finite resource anchors" + ) + _check( + _collect_resource_nodes(cluster.get_node("DecorativeInstances")).is_empty(), + "Decorative riverbank instances must not become simulation resources" + ) + + var anchors := cluster.get_resource_anchors() + var anchor_ids: Array[StringName] = [] + for anchor in anchors: + anchor_ids.append(anchor.node_id) + var terrain_height := terrain.data.get_height(anchor.global_position) + _check( + absf(anchor.global_position.y - terrain_height) < 0.05, + "Resource anchor %s should snap to Terrain3D" % anchor.node_id + ) + var channel_distance := absf( + ( + anchor.global_position.x + - JajceWatercourse.downstream_center_x(anchor.global_position.z) + ) + ) + _check( + ( + channel_distance + > JajceWatercourse.downstream_half_width(anchor.global_position.z) + 1.5 + ), + "Resource anchor %s should sit on the riverbank rather than in water" % anchor.node_id + ) + _check( + anchor_ids.size() == 2 and anchor_ids.has(FAR_BERRY_ID) and anchor_ids.has(CLUSTER_TREE_ID), + "Authored cluster should preserve the existing river berry/tree stable IDs" + ) + _check( + ( + cluster.has_node("ResourceAnchors/BerryBush_River_02/Visual/BerryPatchPresentation") + and cluster.has_node("ResourceAnchors/Tree_River_Resource_01/Visual/TreePresentation") + ), + "Finite cluster anchors should have resource-specific visuals instead of boxes" + ) + var berry_presentation := ( + cluster.get_node("ResourceAnchors/BerryBush_River_02/Visual/BerryPatchPresentation") + as StylizedBerryPatch + ) + _check( + berry_presentation.get_presentation_instance_count() == 13, + "Berry resource visual should keep its bounded leaf-and-fruit instance count" + ) + + var resources := _collect_resource_nodes(world.get_node("WorldObjects")) + _check(resources.size() == 18, "Clustering should preserve all eighteen finite resources") + var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode + var woodpile := world.get_node("WorldObjects/StorageSites/VillageWoodpile") as StorageNode + var adapter := ActiveWorldAdapter.new() + adapter.pantry_storage = pantry + adapter.woodpile_storage = woodpile + root.add_child(adapter) + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.debug_logs = false + manager.active_world_adapter = adapter + root.add_child(manager) + manager.set_process(false) + await process_frame + + _check( + int(adapter.get_resource_index_stats()["candidate_count"]) == 18, + "ActiveWorldAdapter should index direct and nested resource anchors equally" + ) + var nearby_food := adapter.get_resource_candidates_in_radius( + SimulationIds.ACTION_GATHER_FOOD, Vector3.ZERO, 24.0 + ) + var farther_food := adapter.get_resource_candidates_in_radius( + SimulationIds.ACTION_GATHER_FOOD, Vector3.ZERO, 64.0, 24.0 + ) + _check(not nearby_food.is_empty(), "The authored world should retain nearby food choices") + _check( + _candidate_ids(farther_food).has(FAR_BERRY_ID), + "The nested riverbank berry should occupy a discoverable farther range" + ) + + for resource in resources: + if resource.resource_id != SimulationIds.RESOURCE_FOOD: + continue + var state: ResourceStateRecord = manager.get_resource_state(resource.node_id) + state.set_enabled(resource.node_id == FAR_BERRY_ID) + var npc: SimNPC = manager.npcs[0] + npc.set_task(SimulationIds.ACTION_GATHER_FOOD) + _check( + manager.resolve_npc_target(npc.id, Vector3.ZERO), + "Resolver should expand from the village to the farther authored berry" + ) + _check( + npc.target_id == FAR_BERRY_ID, + "Farther discovery should return the nested stable-ID berry anchor" + ) + _check( + int(manager.target_resolver.last_resource_query_stats["range_pass_count"]) > 1, + "Farther authored discovery should expand beyond the initial 24 m range" + ) + manager.release_resource(FAR_BERRY_ID, npc.id) + for resource in resources: + if resource.resource_id == SimulationIds.RESOURCE_FOOD: + manager.get_resource_state(resource.node_id).set_enabled(resource.initial_enabled) + + _finish() + + +func _candidate_ids(candidates: Array[Dictionary]) -> Array[StringName]: + var ids: Array[StringName] = [] + for candidate in candidates: + ids.append(StringName(candidate["target_id"])) + return ids + + +func _collect_resource_nodes(parent: Node) -> Array[ResourceNode]: + var resources: Array[ResourceNode] = [] + for child in parent.get_children(): + if child is ResourceNode: + resources.append(child) + else: + resources.append_array(_collect_resource_nodes(child)) + return resources + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Jajce resource cluster passed: bounded density -> far stable-ID discovery") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/jajce_resource_cluster_test.gd.uid b/tests/jajce_resource_cluster_test.gd.uid new file mode 100644 index 0000000..0f1b3b7 --- /dev/null +++ b/tests/jajce_resource_cluster_test.gd.uid @@ -0,0 +1 @@ +uid://bbl3wqsud1gsh diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index fe4f34e..09dcc62 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -519,9 +519,10 @@ func _run() -> void: ), "Runtime should not retain duplicate flat-world objects" ) - var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes") + var runtime_resources := _collect_resource_nodes(main_scene.get_node("JajceWorld/WorldObjects")) _check( - resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes" + runtime_resources.size() == 18, + "Runtime should contain eighteen Jajce ResourceNodes across authored clusters" ) _check( simulation_manager.resource_states.size() == 18, @@ -597,8 +598,8 @@ func _run() -> void: for site in activity_root.get_children(): destinations.append((site as ActivitySite).get_interaction_position()) - for resource in resource_root.get_children(): - destinations.append((resource as ResourceNode).interaction_point.global_position) + for resource in runtime_resources: + destinations.append(resource.interaction_point.global_position) for origin in fixed_origins: for destination in destinations: @@ -673,6 +674,16 @@ func _check(condition: bool, message: String) -> void: failures.append(message) +func _collect_resource_nodes(parent: Node) -> Array[ResourceNode]: + var resources: Array[ResourceNode] = [] + for child in parent.get_children(): + if child is ResourceNode: + resources.append(child) + else: + resources.append_array(_collect_resource_nodes(child)) + return resources + + func _check_path_tracks_terrain( terrain: Terrain3D, path: PackedVector3Array, message: String ) -> void: diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index 5f10ecc..207304a 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -324,7 +324,7 @@ func _run() -> void: _check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera") lookdev.free() - var resources := world.get_node("WorldObjects/ResourceNodes").get_children() + var resources := _collect_resource_nodes(world.get_node("WorldObjects")) var ids: Array[String] = [] for resource in resources: ids.append(String((resource as ResourceNode).node_id)) @@ -561,6 +561,16 @@ func _check(condition: bool, message: String) -> void: failures.append(message) +func _collect_resource_nodes(parent: Node) -> Array[ResourceNode]: + var resources: Array[ResourceNode] = [] + for child in parent.get_children(): + if child is ResourceNode: + resources.append(child) + else: + resources.append_array(_collect_resource_nodes(child)) + return resources + + func _check_path_tracks_terrain( terrain: Terrain3D, path: PackedVector3Array, message: String ) -> void: diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 16c470b..419d537 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=57 format=3] +[gd_scene load_steps=58 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"] @@ -25,6 +25,7 @@ [ext_resource type="PackedScene" path="res://assets/foliage/tree-high.glb" id="23_conifer_high"] [ext_resource type="PackedScene" path="res://world/jajce/CozyButterfly.tscn" id="24_butterfly"] [ext_resource type="Material" path="res://world/jajce/materials/cozy_grass_process_material.tres" id="25_grass_process"] +[ext_resource type="PackedScene" path="res://world/jajce/RiverbankResourceCluster.tscn" id="26_resource_cluster"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] _shader_parameters = { @@ -410,10 +411,6 @@ position = Vector3(-4, 0, -30) position = Vector3(19, 0, 18) scale = Vector3(0.9, 0.9, 0.9) -[node name="Tree_River_02" parent="FoliageRoot" instance=ExtResource("4_tree")] -position = Vector3(31, 0, 14) -scale = Vector3(1.25, 1.25, 1.25) - [node name="Conifer_Citadel_01" parent="FoliageRoot" instance=ExtResource("23_conifer_high")] position = Vector3(-39, 0, 13) rotation_degrees = Vector3(0, 18, 0) @@ -439,11 +436,6 @@ position = Vector3(38, 0, -8) rotation_degrees = Vector3(0, 28, 0) scale = Vector3(2.7, 2.7, 2.7) -[node name="Conifer_River_01" parent="FoliageRoot" instance=ExtResource("22_conifer")] -position = Vector3(38, 0, 20) -rotation_degrees = Vector3(0, -51, 0) -scale = Vector3(2.55, 2.55, 2.55) - [node name="Conifer_South_01" parent="FoliageRoot" instance=ExtResource("23_conifer_high")] position = Vector3(-21, 0, -29) rotation_degrees = Vector3(0, 44, 0) @@ -566,17 +558,6 @@ yield_per_action = 2.0 safety_risk = 0.24 comfort_distance = 26.0 -[node name="Tree_River_Resource_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] -position = Vector3(20, 0, 18) -node_id = &"tree_river_resource_01" -action_id = &"gather_wood" -resource_id = &"wood" -initial_amount = 8.0 -yield_per_action = 2.0 -safety_risk = 0.22 -comfort_distance = 30.0 -discovery_priority = 0.1 - [node name="WoodPile_Village_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(5, 0, -13) node_id = &"wood_pile_village_01" @@ -597,15 +578,6 @@ safety_risk = 0.02 comfort_distance = 16.0 discovery_priority = 0.85 -[node name="BerryBush_River_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] -position = Vector3(28, 0, -2) -node_id = &"berry_bush_river_02" -initial_amount = 5.0 -yield_per_action = 1.5 -safety_risk = 0.22 -comfort_distance = 28.0 -discovery_priority = 0.2 - [node name="Tree_Forest_Grove_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(-8, 0, -22) node_id = &"tree_forest_grove_01" @@ -648,6 +620,11 @@ safety_risk = 0.1 comfort_distance = 20.0 discovery_priority = 0.45 +[node name="ResourceClusters" type="Node3D" parent="WorldObjects"] + +[node name="RiverbankResourceCluster" parent="WorldObjects/ResourceClusters" instance=ExtResource("26_resource_cluster")] +position = Vector3(33, 0, 7) + [node name="StorageSites" type="Node3D" parent="WorldObjects"] [node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] diff --git a/world/jajce/RiverbankResourceCluster.gd b/world/jajce/RiverbankResourceCluster.gd new file mode 100644 index 0000000..060b415 --- /dev/null +++ b/world/jajce/RiverbankResourceCluster.gd @@ -0,0 +1,210 @@ +class_name RiverbankResourceCluster +extends Node3D + +const FERN_OFFSETS := [ + Vector3(-0.8, 0.0, -10.8), + Vector3(1.1, 0.0, -8.7), + Vector3(2.9, 0.0, -6.8), + Vector3(-1.4, 0.0, -4.6), + Vector3(1.8, 0.0, -2.7), + Vector3(3.6, 0.0, -0.5), + Vector3(-0.5, 0.0, 1.2), + Vector3(2.4, 0.0, 3.4), + Vector3(-1.8, 0.0, 5.7), + Vector3(1.4, 0.0, 7.6), + Vector3(3.8, 0.0, 9.4), + Vector3(0.2, 0.0, 11.8), +] +const FLOWER_OFFSETS := [ + Vector3(0.2, 0.0, -10.2), + Vector3(2.2, 0.0, -7.5), + Vector3(-0.4, 0.0, -5.5), + Vector3(3.4, 0.0, -3.2), + Vector3(0.8, 0.0, 0.1), + Vector3(2.8, 0.0, 2.1), + Vector3(-0.7, 0.0, 4.4), + Vector3(2.0, 0.0, 6.4), + Vector3(0.4, 0.0, 9.8), +] +const STONE_OFFSETS := [ + Vector3(3.9, 0.0, -9.6), + Vector3(-1.2, 0.0, -7.2), + Vector3(3.2, 0.0, -4.3), + Vector3(-0.7, 0.0, -1.2), + Vector3(3.7, 0.0, 4.6), + Vector3(-1.1, 0.0, 8.4), + Vector3(2.7, 0.0, 11.2), +] + +@export var terrain_path := NodePath("../../../TerrainRoot/Terrain3D") + +var terrain: Terrain3D + + +func _ready() -> void: + call_deferred("_initialize_cluster") + + +func get_resource_anchors() -> Array[ResourceNode]: + var anchors: Array[ResourceNode] = [] + for child in $ResourceAnchors.get_children(): + if child is ResourceNode: + anchors.append(child) + return anchors + + +func get_presentation_stats() -> Dictionary: + var understory_instances := 0 + var multimesh_batches := 0 + for child in $DecorativeInstances/Understory.get_children(): + var instance := child as MultiMeshInstance3D + if instance == null or instance.multimesh == null: + continue + understory_instances += instance.multimesh.instance_count + multimesh_batches += 1 + return { + "understory_instance_count": understory_instances, + "multimesh_batch_count": multimesh_batches, + "decorative_tree_count": $DecorativeInstances/Trees.get_child_count(), + "resource_anchor_count": get_resource_anchors().size(), + } + + +func _initialize_cluster() -> void: + terrain = get_node_or_null(terrain_path) as Terrain3D + if terrain == null: + push_warning("RiverbankResourceCluster: Terrain3D is unavailable") + return + _snap_authored_children($DecorativeInstances/Trees) + for anchor in get_resource_anchors(): + _snap_resource_anchor(anchor) + _build_understory() + + +func _snap_authored_children(parent: Node) -> void: + for child in parent.get_children(): + if child is Node3D: + _snap_to_terrain(child) + + +func _snap_to_terrain(node: Node3D) -> void: + var position := node.global_position + var height := terrain.data.get_height(position) + if not is_nan(height): + node.global_position.y = height + + +func _snap_resource_anchor(anchor: ResourceNode) -> void: + var anchor_position := anchor.global_position + var anchor_height := terrain.data.get_height(anchor_position) + if is_nan(anchor_height): + return + var interaction_position := anchor.global_transform * anchor.interaction_point.position + var interaction_height := terrain.data.get_height(interaction_position) + if not is_nan(interaction_height): + anchor.interaction_point.position.y = interaction_height - anchor_height + 0.05 + anchor.global_position.y = anchor_height + + +func _build_understory() -> void: + var fern_material := StandardMaterial3D.new() + fern_material.albedo_color = Color("#315f35") + fern_material.roughness = 0.94 + fern_material.cull_mode = BaseMaterial3D.CULL_DISABLED + _assign_multimesh( + $DecorativeInstances/Understory/FernClumps, + _create_fern_mesh(fern_material), + FERN_OFFSETS, + Vector3(0.8, 0.8, 0.8), + 0.03 + ) + + var flower_mesh := SphereMesh.new() + flower_mesh.radius = 0.13 + flower_mesh.height = 0.22 + flower_mesh.radial_segments = 7 + flower_mesh.rings = 4 + var flower_material := StandardMaterial3D.new() + flower_material.albedo_color = Color("#efc55a") + flower_material.roughness = 0.82 + flower_mesh.material = flower_material + _assign_multimesh( + $DecorativeInstances/Understory/FlowerClumps, flower_mesh, FLOWER_OFFSETS, Vector3.ONE, 0.2 + ) + + var stone_mesh := SphereMesh.new() + stone_mesh.radius = 1.0 + stone_mesh.height = 2.0 + stone_mesh.radial_segments = 8 + stone_mesh.rings = 4 + var stone_material := StandardMaterial3D.new() + stone_material.albedo_color = Color("#77786b") + stone_material.roughness = 0.96 + stone_mesh.material = stone_material + _assign_multimesh( + $DecorativeInstances/Understory/BankStones, + stone_mesh, + STONE_OFFSETS, + Vector3(0.62, 0.24, 0.44), + 0.22 + ) + + +func _assign_multimesh( + target: MultiMeshInstance3D, + mesh: Mesh, + offsets: Array, + base_scale: Vector3, + vertical_offset: float +) -> void: + var multimesh := MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + multimesh.mesh = mesh + multimesh.instance_count = offsets.size() + for index in offsets.size(): + var offset: Vector3 = offsets[index] + var world_sample := to_global(offset) + var height := terrain.data.get_height(world_sample) + if is_nan(height): + height = global_position.y + var local_position := to_local(Vector3(world_sample.x, height, world_sample.z)) + local_position.y += vertical_offset + var scale_variation := 0.84 + float((index * 37) % 5) * 0.07 + var rotation := deg_to_rad(float((index * 53) % 360)) + var basis := Basis(Vector3.UP, rotation).scaled(base_scale * scale_variation) + multimesh.set_instance_transform(index, Transform3D(basis, local_position)) + target.multimesh = multimesh + + +func _create_fern_mesh(material: Material) -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var uvs := PackedVector2Array() + for frond_index in 5: + var angle := float(frond_index) / 5.0 * TAU + 0.3 + var direction := Vector3(cos(angle), 0.0, sin(angle)) + var side := Vector3(-direction.z, 0.0, direction.x) * 0.16 + var base := Vector3(0.0, 0.02, 0.0) + var middle := direction * 0.38 + Vector3.UP * 0.14 + var tip := direction * 0.82 + Vector3.UP * 0.24 + vertices.append_array([base, middle + side, tip, base, tip, middle - side]) + for _vertex in 6: + normals.append(Vector3.UP) + for uv in [ + Vector2(0.5, 0.0), + Vector2(1.0, 0.5), + Vector2(0.5, 1.0), + Vector2(0.5, 0.0), + Vector2(0.5, 1.0), + Vector2(0.0, 0.5), + ]: + uvs.append(uv) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_TEX_UV] = uvs + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + mesh.surface_set_material(0, material) + return mesh diff --git a/world/jajce/RiverbankResourceCluster.gd.uid b/world/jajce/RiverbankResourceCluster.gd.uid new file mode 100644 index 0000000..2aa623d --- /dev/null +++ b/world/jajce/RiverbankResourceCluster.gd.uid @@ -0,0 +1 @@ +uid://b20nnq0w3x0co diff --git a/world/jajce/RiverbankResourceCluster.tscn b/world/jajce/RiverbankResourceCluster.tscn new file mode 100644 index 0000000..3d697b2 --- /dev/null +++ b/world/jajce/RiverbankResourceCluster.tscn @@ -0,0 +1,73 @@ +[gd_scene load_steps=6 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"] + +[node name="RiverbankResourceCluster" type="Node3D"] +script = ExtResource("1_script") + +[node name="DecorativeInstances" type="Node3D" parent="."] + +[node name="Understory" type="Node3D" parent="DecorativeInstances"] + +[node name="FernClumps" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"] +cast_shadow = 0 + +[node name="FlowerClumps" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"] +cast_shadow = 0 + +[node name="BankStones" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"] + +[node name="Trees" type="Node3D" parent="DecorativeInstances"] + +[node name="DeciduousBankTree" parent="DecorativeInstances/Trees" instance=ExtResource("3_tree")] +position = Vector3(3, 0, 4) +scale = Vector3(0.92, 0.92, 0.92) + +[node name="ConiferBankTree" parent="DecorativeInstances/Trees" instance=ExtResource("5_conifer")] +position = Vector3(5, 0, 13) +rotation_degrees = Vector3(0, -51, 0) +scale = Vector3(2.55, 2.55, 2.55) + +[node name="ResourceAnchors" type="Node3D" parent="."] + +[node name="BerryBush_River_02" parent="ResourceAnchors" instance=ExtResource("2_resource")] +position = Vector3(0, 0, -9) +node_id = &"berry_bush_river_02" +initial_amount = 5.0 +yield_per_action = 1.5 +safety_risk = 0.22 +comfort_distance = 28.0 +discovery_priority = 0.2 + +[node name="MeshInstance3D" parent="ResourceAnchors/BerryBush_River_02/Visual"] +visible = false + +[node name="BerryPatchPresentation" parent="ResourceAnchors/BerryBush_River_02/Visual" instance=ExtResource("4_berry")] +scale = Vector3(1.15, 1.15, 1.15) + +[node name="InteractionPoint" parent="ResourceAnchors/BerryBush_River_02"] +position = Vector3(-2.5, 0, 2) + +[node name="Tree_River_Resource_01" parent="ResourceAnchors" instance=ExtResource("2_resource")] +position = Vector3(0, 0, 8) +node_id = &"tree_river_resource_01" +action_id = &"gather_wood" +resource_id = &"wood" +initial_amount = 8.0 +yield_per_action = 2.0 +safety_risk = 0.22 +comfort_distance = 30.0 +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")] +scale = Vector3(1.08, 1.08, 1.08) + +[node name="InteractionPoint" parent="ResourceAnchors/Tree_River_Resource_01"] +position = Vector3(-4, 0, 2) diff --git a/world/jajce/StylizedBerryPatch.gd b/world/jajce/StylizedBerryPatch.gd new file mode 100644 index 0000000..efbe0eb --- /dev/null +++ b/world/jajce/StylizedBerryPatch.gd @@ -0,0 +1,85 @@ +class_name StylizedBerryPatch +extends Node3D + +const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader") +const LEAF_POSITIONS := [ + Vector3(-0.52, 0.42, 0.08), + Vector3(0.08, 0.48, -0.14), + Vector3(0.58, 0.4, 0.18), + Vector3(-0.18, 0.7, 0.18), + Vector3(0.38, 0.67, -0.22), +] +const LEAF_SCALES := [ + Vector3(0.72, 0.38, 0.62), + Vector3(0.78, 0.44, 0.68), + Vector3(0.66, 0.36, 0.58), + Vector3(0.56, 0.32, 0.52), + Vector3(0.5, 0.28, 0.46), +] +const BERRY_POSITIONS := [ + Vector3(-0.7, 0.64, 0.2), + Vector3(-0.43, 0.82, -0.34), + Vector3(-0.14, 0.7, 0.46), + Vector3(0.08, 0.88, -0.08), + Vector3(0.34, 0.7, 0.42), + Vector3(0.48, 0.86, -0.3), + Vector3(0.74, 0.6, 0.12), + Vector3(-0.02, 1.02, 0.22), +] + + +func _ready() -> void: + _build_leaves() + _build_berries() + + +func get_presentation_instance_count() -> int: + return LEAF_POSITIONS.size() + BERRY_POSITIONS.size() + + +func _build_leaves() -> void: + var mesh := SphereMesh.new() + mesh.radius = 1.0 + mesh.height = 2.0 + mesh.radial_segments = 9 + mesh.rings = 5 + var material := ShaderMaterial.new() + material.shader = WIND_SHADER + material.set_shader_parameter("albedo", Color("#426c32")) + material.set_shader_parameter("roughness", 0.92) + material.set_shader_parameter("wind_strength", 0.045) + material.set_shader_parameter("wind_speed", 0.48) + material.set_shader_parameter("wind_phase", 1.7) + mesh.material = material + var transforms: Array[Transform3D] = [] + for index in LEAF_POSITIONS.size(): + transforms.append( + Transform3D(Basis.IDENTITY.scaled(LEAF_SCALES[index]), LEAF_POSITIONS[index]) + ) + $Leaves.multimesh = _create_multimesh(mesh, transforms) + + +func _build_berries() -> void: + var mesh := SphereMesh.new() + mesh.radius = 0.12 + mesh.height = 0.21 + mesh.radial_segments = 8 + mesh.rings = 4 + var material := StandardMaterial3D.new() + material.albedo_color = Color("#8f3148") + material.roughness = 0.78 + mesh.material = material + var transforms: Array[Transform3D] = [] + for position in BERRY_POSITIONS: + transforms.append(Transform3D(Basis.IDENTITY, position)) + $Berries.multimesh = _create_multimesh(mesh, transforms) + + +func _create_multimesh(mesh: Mesh, transforms: Array) -> MultiMesh: + var multimesh := MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + multimesh.mesh = mesh + multimesh.instance_count = transforms.size() + for index in transforms.size(): + multimesh.set_instance_transform(index, transforms[index]) + return multimesh diff --git a/world/jajce/StylizedBerryPatch.gd.uid b/world/jajce/StylizedBerryPatch.gd.uid new file mode 100644 index 0000000..296e849 --- /dev/null +++ b/world/jajce/StylizedBerryPatch.gd.uid @@ -0,0 +1 @@ +uid://ctb2v3prn7ovc diff --git a/world/jajce/StylizedBerryPatch.tscn b/world/jajce/StylizedBerryPatch.tscn new file mode 100644 index 0000000..f18e92c --- /dev/null +++ b/world/jajce/StylizedBerryPatch.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://world/jajce/StylizedBerryPatch.gd" id="1_script"] + +[node name="StylizedBerryPatch" type="Node3D"] +script = ExtResource("1_script") + +[node name="Leaves" type="MultiMeshInstance3D" parent="."] +cast_shadow = 0 + +[node name="Berries" type="MultiMeshInstance3D" parent="."] +cast_shadow = 0