feat: author resource-aware riverbank grove

This commit is contained in:
Rijad Zuzo
2026-07-17 13:57:57 +02:00
parent 145b90eb97
commit 04c5371b6b
23 changed files with 823 additions and 63 deletions
+4
View File
@@ -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, metadata bounds copied at bind time. It does not own amounts, enabled state,
reservations, or persistence. `ResourceNode` enter/exit, bind, and transform reservations, or persistence. `ResourceNode` enter/exit, bind, and transform
notifications keep it synchronized with the loaded presentation lifecycle. 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, Outside the runtime lifecycle, `simulation/benchmark/` owns reusable,
schema-valid workload fixtures. CLI tools and headless scenarios consume those schema-valid workload fixtures. CLI tools and headless scenarios consume those
+13 -7
View File
@@ -780,16 +780,22 @@ Completed:
aligned; softened foam/mist and a smaller rock frame make the falls part of 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 the landform. A matched Metal capture records the change, and the rebaked
navigation mesh passes terrain-height and runtime route checks. 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: Next:
1. Prove one bounded foliage/resource authoring workflow at a riverbank or 1. Give the authored berry/tree resources state-driven low/depleted visuals
forest edge. Use decorative Terrain3D/intentional instances for tree, fern, sourced from their real `ResourceStateRecord` amount. Prove NPC and player
flower, and rock masses, but create simulation-owned finite resources only extraction plus save/restore rebuild the same presentation without adding
through selected stable-ID `ResourceNode` anchors. visual fields to simulation state.
2. Validate that villagers discover both nearby and farther authored anchors, 2. Apply the workflow to a second forest-edge consumer only after that contract
preserve reachable interaction points, and keep the visual batch inside a is honest and reusable. Add livestock only with a real identity and
measured presentation budget. Add livestock only with a real identity and
interaction contract. interaction contract.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
+20 -7
View File
@@ -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 the result, and the Terrain3D-derived navigation mesh was rebaked and validated
against required village/resource routes. against required village/resource routes.
The immediate next slice should prove one bounded foliage/resource authoring The first authored foliage/resource workflow is complete. One riverbank cluster
workflow in the real world: build a restrained riverbank or forest-edge cluster uses three intentional MultiMesh batches for 28 fern, flower, and stone
with decorative Terrain3D/intentional instances, pair only selected harvestable instances plus two decorative trees, while only an existing stable-ID berry and
trees or berry patches with stable-ID `ResourceNode` anchors, and verify nearby tree are finite `ResourceNode` anchors. Both anchors preserve the save contract
and farther loaded anchors remain discoverable through `ActiveWorldAdapter`. of eighteen resources, sit above the carved channel, and expose separate
Keep decorative instances out of simulation state, measure the bounded visual terrain-snapped approach points. A forced village query expands beyond the
cost, and do not generalize the resource grid to people/buildings/events or 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. introduce active/abstract LOD before those consumers need it.
Recently completed: 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 - `Jajce River 03`: deterministic Terrain3D generation now carves the upper
stream, raised waterfall shelf, plunge basin, and curved downstream channel; stream, raised waterfall shelf, plunge basin, and curved downstream channel;
shared watercourse geometry keeps both water ribbons aligned, soft masked shared watercourse geometry keeps both water ribbons aligned, soft masked
+11 -8
View File
@@ -354,7 +354,9 @@ but retain far sources whenever their authoritative metadata can still win.
transforms, authored conifer clusters, and oversized ambient butterflies; transforms, authored conifer clusters, and oversized ambient butterflies;
- one sparse terrain-aware calligraphic gust field with two or three soft - one sparse terrain-aware calligraphic gust field with two or three soft
tapered strokes per burst; 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; - typed village pantry storage and typed guard, study, and rest activity sites;
- a Terrain3D-derived baked navigation mesh covering the current playable loop; - a Terrain3D-derived baked navigation mesh covering the current playable loop;
- base collision/navigation guardrails that keep Terrain3D collision enabled, - 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) [Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md)
and [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). and [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md).
The authored Jajce watercourse and its Terrain3D-derived navigation rebake are The authored Jajce watercourse, navigation rebake, and first bounded
complete. The next slice should prove a bounded stable-ID resource-anchor foliage/resource placement proof are complete. The next slice should derive
placement workflow beside decorative Terrain3D/intentional foliage instances low/depleted berry and tree visuals from authoritative resource amount, cover
at one riverbank or forest edge. It should validate nearby and farther loaded real NPC/player extraction and save/restore reconstruction, and keep that
resource discovery, reachable interaction points, and a measured presentation presentation out of serialized simulation state. Apply the cluster workflow to
budget without making decorative instances authoritative. Do not yet a second forest edge only after the depletion contract supplies a real second
generalize the resource grid or introduce active/abstract simulation LOD. consumer. Do not yet generalize the resource grid or introduce active/abstract
simulation LOD.
The remaining simulation-garden target still aims for: The remaining simulation-garden target still aims for:
+10
View File
@@ -567,6 +567,16 @@ existing `ResourceStateRecord`. The reviewed 18/180/1,800-source benchmark and
exact-selection contract live in exact-selection contract live in
[Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). [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 ## Test scenarios
At minimum, exercise: At minimum, exercise:
+76
View File
@@ -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
```
+5 -5
View File
@@ -60,11 +60,11 @@ player resource gathering with all 18 finite resource anchors loaded.
## Next visual/system proof ## Next visual/system proof
The next pass should author one bounded riverbank or forest-edge foliage mass, That proof is complete in
using decorative Terrain3D/intentional instances for visual density and only a [Jajce Resource Grove 04](JAJCE_RESOURCE_GROVE_04.md): a bounded riverbank mass
small selected set of stable-ID `ResourceNode` anchors for finite harvestable keeps decorative density separate from two stable-ID finite anchors. The next
resources. Bank stones, ferns, flowers, and tree masses can improve the channel pass should make those berry/tree visuals reflect real depletion before adding
edge during that proof. Bridge, buildings, and fortress geometry remain a second forest-edge cluster. Bridge, buildings, and fortress geometry remain
prototype blockouts and should be replaced gradually rather than through a prototype blockouts and should be replaced gradually rather than through a
broad asset dump. broad asset dump.
Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

@@ -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
@@ -68,7 +68,10 @@ simulation-LOD framework yet.
Terrain3D foliage instances remain decorative unless an intentional stable-ID Terrain3D foliage instances remain decorative unless an intentional stable-ID
`ResourceNode` anchor binds simulation-owned state. A later placement workflow `ResourceNode` anchor binds simulation-owned state. A later placement workflow
can author those anchors alongside visual instancing without making every tree 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 ## Capture command
+184
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bbl3wqsud1gsh
+15 -4
View File
@@ -519,9 +519,10 @@ func _run() -> void:
), ),
"Runtime should not retain duplicate flat-world objects" "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( _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( _check(
simulation_manager.resource_states.size() == 18, simulation_manager.resource_states.size() == 18,
@@ -597,8 +598,8 @@ func _run() -> void:
for site in activity_root.get_children(): for site in activity_root.get_children():
destinations.append((site as ActivitySite).get_interaction_position()) destinations.append((site as ActivitySite).get_interaction_position())
for resource in resource_root.get_children(): for resource in runtime_resources:
destinations.append((resource as ResourceNode).interaction_point.global_position) destinations.append(resource.interaction_point.global_position)
for origin in fixed_origins: for origin in fixed_origins:
for destination in destinations: for destination in destinations:
@@ -673,6 +674,16 @@ func _check(condition: bool, message: String) -> void:
failures.append(message) 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( func _check_path_tracks_terrain(
terrain: Terrain3D, path: PackedVector3Array, message: String terrain: Terrain3D, path: PackedVector3Array, message: String
) -> void: ) -> void:
+11 -1
View File
@@ -324,7 +324,7 @@ func _run() -> void:
_check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera") _check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera")
lookdev.free() lookdev.free()
var resources := world.get_node("WorldObjects/ResourceNodes").get_children() var resources := _collect_resource_nodes(world.get_node("WorldObjects"))
var ids: Array[String] = [] var ids: Array[String] = []
for resource in resources: for resource in resources:
ids.append(String((resource as ResourceNode).node_id)) ids.append(String((resource as ResourceNode).node_id))
@@ -561,6 +561,16 @@ func _check(condition: bool, message: String) -> void:
failures.append(message) 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( func _check_path_tracks_terrain(
terrain: Terrain3D, path: PackedVector3Array, message: String terrain: Terrain3D, path: PackedVector3Array, message: String
) -> void: ) -> void:
+7 -30
View File
@@ -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="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -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://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="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="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"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = { _shader_parameters = {
@@ -410,10 +411,6 @@ position = Vector3(-4, 0, -30)
position = Vector3(19, 0, 18) position = Vector3(19, 0, 18)
scale = Vector3(0.9, 0.9, 0.9) 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")] [node name="Conifer_Citadel_01" parent="FoliageRoot" instance=ExtResource("23_conifer_high")]
position = Vector3(-39, 0, 13) position = Vector3(-39, 0, 13)
rotation_degrees = Vector3(0, 18, 0) rotation_degrees = Vector3(0, 18, 0)
@@ -439,11 +436,6 @@ position = Vector3(38, 0, -8)
rotation_degrees = Vector3(0, 28, 0) rotation_degrees = Vector3(0, 28, 0)
scale = Vector3(2.7, 2.7, 2.7) 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")] [node name="Conifer_South_01" parent="FoliageRoot" instance=ExtResource("23_conifer_high")]
position = Vector3(-21, 0, -29) position = Vector3(-21, 0, -29)
rotation_degrees = Vector3(0, 44, 0) rotation_degrees = Vector3(0, 44, 0)
@@ -566,17 +558,6 @@ yield_per_action = 2.0
safety_risk = 0.24 safety_risk = 0.24
comfort_distance = 26.0 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")] [node name="WoodPile_Village_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(5, 0, -13) position = Vector3(5, 0, -13)
node_id = &"wood_pile_village_01" node_id = &"wood_pile_village_01"
@@ -597,15 +578,6 @@ safety_risk = 0.02
comfort_distance = 16.0 comfort_distance = 16.0
discovery_priority = 0.85 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")] [node name="Tree_Forest_Grove_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-8, 0, -22) position = Vector3(-8, 0, -22)
node_id = &"tree_forest_grove_01" node_id = &"tree_forest_grove_01"
@@ -648,6 +620,11 @@ safety_risk = 0.1
comfort_distance = 20.0 comfort_distance = 20.0
discovery_priority = 0.45 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="StorageSites" type="Node3D" parent="WorldObjects"]
[node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] [node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
+210
View File
@@ -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
@@ -0,0 +1 @@
uid://b20nnq0w3x0co
+73
View File
@@ -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)
+85
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://ctb2v3prn7ovc
+12
View File
@@ -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