feat: author discoverable forest edge

This commit is contained in:
Rijad Zuzo
2026-07-18 19:09:04 +02:00
parent 2719b0398b
commit 1f09ebe6b6
21 changed files with 911 additions and 180 deletions
+12 -5
View File
@@ -792,14 +792,21 @@ Completed:
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.
39. `Jajce Forest Edge 06`: `tree_forest_grove_01` and
`tree_forest_grove_02` now anchor a bounded forest-edge cluster beyond the
first 24 m discovery pass. Two decorative trees plus 24 fern, mushroom, and
stone instances stay presentation-only. Both finite trees remain reachable,
deplete into shared stumps, restore from authoritative amount, and keep the
world/save total at eighteen resources.
Next:
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.
1. Add one named goat as an identity-backed simulation vertical slice: stable
animal ID, authoritative position and hunger/feed state, loaded visual
binding, and an exact feed interaction shared by one NPC and the player.
Feeding must withdraw real pantry food and survive deterministic save/load.
2. Add a small herd, breeding, or animal products only after that single-animal
identity and conservation contract is proven.
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
+24 -7
View File
@@ -912,16 +912,33 @@ 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.
The second authored foliage/resource workflow is complete. The existing
`tree_forest_grove_01` and `tree_forest_grove_02` anchors now form one bounded
forest edge with two decorative silhouette trees and 24 fern, mushroom, and
stone instances in three MultiMesh batches. Both stable IDs and definitions
remain authoritative, while village-facing interaction points sit beyond the
initial 24 m discovery radius and remain reachable on the Terrain3D-derived
navigation mesh. Real NPC depletion leaves the shared stump and restore
rebuilds the sparse standing tree from amount alone. The river and forest
clusters now share only the terrain-snap, bounded-instance, and anchor-layout
code proven by those two consumers.
The immediate next slice should introduce livestock only through one
identity-backed animal vertical slice: one named goat with a stable animal ID,
simulation-owned position and hunger/feed state, a loaded presentation binding,
and one exact feed interaction available to both an NPC and the player. Feeding
must consume real pantry food and save/restore deterministically. Do not make
the animal a `ResourceNode`, add a herd, breeding, products, or a generalized
animal spatial index before that first identity and conservation contract is
honest.
Recently completed:
- `Jajce Forest Edge 06`: two existing deep-forest tree IDs now anchor a
restrained four-tree silhouette with 24 decorative understory instances.
Both targets require expanded loaded-resource discovery, remain reachable,
reuse amount-driven crowns/stumps, and preserve the eighteen-resource save
contract through real NPC depletion and restore.
- `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
+9 -8
View File
@@ -982,14 +982,15 @@ 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, 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 authored Jajce watercourse, navigation rebake, amount-derived resource
visuals, and two bounded foliage/resource placement proofs are complete. The
riverbank and forest edge preserve stable finite-resource IDs, separate
decorative density from simulation authority, support discovery beyond the
first 24 m range, and reconstruct sparse/depleted visuals from authoritative
amount. The next slice should introduce one named goat only through a stable
simulation identity, authoritative position and feed state, loaded visual
binding, exact NPC/player feeding cost, and deterministic save/restore. Do not
yet generalize the resource grid or introduce active/abstract simulation LOD.
The remaining simulation-garden target still aims for:
+9
View File
@@ -577,6 +577,15 @@ 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).
Amount-derived berry/tree presentation and the second placement consumer are
also complete. `ResourceAmountVisual` reconstructs full, low, and depleted
states from authoritative amount without adding save fields. The existing
`tree_forest_grove_01` and `tree_forest_grove_02` records now sit inside one
bounded forest-edge cluster with reachable interaction points beyond 24 m;
forced resolution expands to the stable ID, and real NPC depletion/restore
reuses the same crown/stump contract. The matched review lives in
[Jajce Forest Edge 06](baselines/JAJCE_FOREST_EDGE_06.md).
## Test scenarios
At minimum, exercise:
+98
View File
@@ -0,0 +1,98 @@
# Jajce Forest Edge 06
Captured: 2026-07-18
## Files
- [before forest edge](jajce_forest_edge_06_before.png)
- [authored forest edge](jajce_forest_edge_06_after.png)
- [finite trees depleted](jajce_forest_edge_06_depleted.png)
- `world/jajce/ForestEdgeResourceCluster.tscn`
- `world/jajce/TerrainResourceCluster.gd`
- `tests/jajce_forest_edge_cluster_test.gd`
The images use the same 1280×720 camera, fixed daylight value, warmup, and
Metal renderer. Debug labels are hidden. The before frame shows two generic
resource boxes below disconnected south-edge trees. The authored frame uses
the same finite IDs as the two central harvestable trees; the depleted frame
shows those two as stumps while the decorative forest silhouette remains.
## Visual reduction
The forest edge contains:
- 10 low-poly fern clumps;
- 8 oversized cream-stem, warm-cap mushrooms;
- 6 flattened forest stones;
- 2 decorative deciduous silhouette trees; and
- 2 finite harvestable trees with amount-derived crowns and stumps.
The 24 understory details use three `MultiMeshInstance3D` batches. The two
existing south-edge conifers remain outside the cluster and frame it at a
larger scale. This keeps the grove legible from the village without turning the
whole terrain into dense foliage.
## Authority and shared authoring contract
`ResourceAnchors` contains only `tree_forest_grove_01` and
`tree_forest_grove_02`, preserving their definitions and the existing total of
eighteen finite resources. `DecorativeInstances` contains no `ResourceNode`
descendants and never enters saves, reservations, checksums, or conservation.
The riverbank and forest edge are now the two concrete consumers of
`TerrainResourceCluster`. That small shared base owns only their proven scene
contract: terrain snapping for authored trees and interaction points, bounded
MultiMesh placement, fern mesh construction, resource-anchor enumeration, and
presentation statistics. Resource-specific palettes, offsets, and meshes stay
in each cluster.
Both finite trees reuse `HarvestableTreePresentation`. Depletion therefore
removes only the authoritative standing trees; the two decorative trees and
surrounding conifers remain, giving the living world an honest but not barren
silhouette.
## Discovery, navigation, and restore proof
The trees moved roughly five metres deeper into the forest edge. Their
village-facing interaction points are approximately 26.5 m and 26.2 m from the
village origin, beyond the adapter's initial 24 m query. The focused scenario
confirms both IDs appear only in the farther query, disables every other wood
source, and resolves `tree_forest_grove_01` after more than one range pass.
The same scenario asks the project-owned navigation map for real paths to both
interaction points, verifies each endpoint is reached, and checks every route
point against Terrain3D height. No terrain or navigation rebake was needed.
Finally, the test leaves the first tree at one configured yield, saves that low
state, and completes a real reserved NPC gather. The exact yield enters NPC
inventory and the tree becomes a stump. Restoring the snapshot rebinds the
same stable ID and reconstructs the sparse standing crown from authoritative
amount alone.
## Next slice
Introduce one named goat as an identity-backed simulation entity rather than a
resource prop. Its stable ID, position, and hunger/feed state should be saved;
one NPC and the player should share an exact feed interaction that withdraws
real pantry food. A herd, breeding, products, and animal spatial indexing stay
deferred until that contract is proven.
## 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/JajceForestEdgeLookdev.tscn \
--output=res://docs/baselines/jajce_forest_edge_06_after.png \
--hide-resource-labels
```
For the depleted frame, append:
```text
--resource-amount=tree_forest_grove_01:0
--resource-amount=tree_forest_grove_02:0
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://4tlahsqubofd"
path="res://.godot/imported/jajce_forest_edge_06_after.png-cc336e5231ac96e8d23247a881a0ec1c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/jajce_forest_edge_06_after.png"
dest_files=["res://.godot/imported/jajce_forest_edge_06_after.png-cc336e5231ac96e8d23247a881a0ec1c.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: 518 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://gsie1efiik5"
path="res://.godot/imported/jajce_forest_edge_06_before.png-ecb42151d9a8db85404cc1153afa0bcf.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/jajce_forest_edge_06_before.png"
dest_files=["res://.godot/imported/jajce_forest_edge_06_before.png-ecb42151d9a8db85404cc1153afa0bcf.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: 518 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dnu1owrmly7wf"
path="res://.godot/imported/jajce_forest_edge_06_depleted.png-08c916e5b04639615251b670dcd41f56.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/jajce_forest_edge_06_depleted.png"
dest_files=["res://.godot/imported/jajce_forest_edge_06_depleted.png-08c916e5b04639615251b670dcd41f56.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
+268
View File
@@ -0,0 +1,268 @@
extends SceneTree
const GROVE_TREE_01 := &"tree_forest_grove_01"
const GROVE_TREE_02 := &"tree_forest_grove_02"
const GROVE_IDS := [GROVE_TREE_01, GROVE_TREE_02]
const CLUSTER_PATH := "JajceWorld/WorldObjects/ResourceClusters/ForestEdgeResourceCluster"
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 10:
await physics_frame
var manager = main_scene.get_node("SimulationManager")
manager.set_process(false)
var terrain := main_scene.get_node("JajceWorld/TerrainRoot/Terrain3D") as Terrain3D
var cluster := main_scene.get_node(CLUSTER_PATH) as ForestEdgeResourceCluster
_check(cluster != null, "Jajce should expose the authored forest-edge resource cluster")
if cluster == null:
_finish()
return
var stats := cluster.get_presentation_stats()
_check(
int(stats["understory_instance_count"]) == 24,
"Forest understory should keep its reviewed 24-instance budget"
)
_check(
int(stats["multimesh_batch_count"]) == 3,
"Forest understory should use exactly three intentional MultiMesh batches"
)
_check(
int(stats["decorative_tree_count"]) == 2,
"Forest cluster should reuse two non-authoritative silhouette trees"
)
_check(
int(stats["resource_anchor_count"]) == 2,
"Forest density should contain only the two existing finite tree anchors"
)
_check(
_collect_resource_nodes(cluster.get_node("DecorativeInstances")).is_empty(),
"Decorative forest foliage 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 anchor_height := terrain.data.get_height(anchor.global_position)
var interaction_position := anchor.interaction_point.global_position
var interaction_height := terrain.data.get_height(interaction_position)
_check(
absf(anchor.global_position.y - anchor_height) < 0.05,
"Forest anchor %s should snap to Terrain3D" % anchor.node_id
)
_check(
absf(interaction_position.y - interaction_height) < 0.1,
"Forest interaction %s should follow Terrain3D" % anchor.node_id
)
_check(
interaction_position.distance_to(Vector3.ZERO) > 24.0,
"Forest interaction %s should require expanded discovery" % anchor.node_id
)
var visual := anchor.get_node("Visual/TreePresentation") as HarvestableTreePresentation
_check(
(
visual != null
and visual.get_amount_tier() == ResourceAmountVisual.AmountTier.FULL
and visual.get_visible_canopy_count() == 4
),
"Forest anchor %s should reuse the full harvestable-tree visual" % anchor.node_id
)
_check(
anchor_ids.size() == 2 and anchor_ids.has(GROVE_TREE_01) and anchor_ids.has(GROVE_TREE_02),
"Forest cluster should preserve both existing stable tree IDs"
)
var resources := _collect_resource_nodes(main_scene.get_node("JajceWorld/WorldObjects"))
_check(resources.size() == 18, "Forest clustering should preserve all eighteen resources")
_check(
manager.resource_states.size() == 18,
"Simulation authority should still bind eighteen resource records"
)
var adapter := main_scene.get_node("ActiveWorldAdapter") as ActiveWorldAdapter
var farther_wood := adapter.get_resource_candidates_in_radius(
SimulationIds.ACTION_GATHER_WOOD, Vector3.ZERO, 64.0, 24.0
)
var farther_ids := _candidate_ids(farther_wood)
_check(
farther_ids.has(GROVE_TREE_01) and farther_ids.has(GROVE_TREE_02),
"Both nested forest trees should occupy the farther discovery range"
)
await _check_navigation(main_scene, terrain, anchors)
_check_expanding_resolution(manager, resources)
_check_depletion_restore(manager, cluster)
_finish()
func _check_expanding_resolution(manager, resources: Array[ResourceNode]) -> void:
for resource in resources:
if resource.resource_id != SimulationIds.RESOURCE_WOOD:
continue
var state: ResourceStateRecord = manager.get_resource_state(resource.node_id)
state.set_enabled(resource.node_id == GROVE_TREE_01)
var npc: SimNPC = manager.npcs[0]
npc.set_task(SimulationIds.ACTION_GATHER_WOOD)
_check(
manager.resolve_npc_target(npc.id, Vector3.ZERO),
"Resolver should expand from the village to the authored forest edge"
)
_check(
npc.target_id == GROVE_TREE_01,
"Expanded discovery should return the forced nested forest tree by stable ID"
)
_check(
int(manager.target_resolver.last_resource_query_stats["range_pass_count"]) > 1,
"Forest discovery should expand beyond the initial 24 m range"
)
manager.release_npc_reservation(npc.id)
for resource in resources:
if resource.resource_id == SimulationIds.RESOURCE_WOOD:
manager.get_resource_state(resource.node_id).set_enabled(resource.initial_enabled)
func _check_depletion_restore(manager, cluster: ForestEdgeResourceCluster) -> void:
var tree := cluster.get_node("ResourceAnchors/Tree_Forest_Grove_01") as ResourceNode
var visual := tree.get_node("Visual/TreePresentation") as HarvestableTreePresentation
var state: ResourceStateRecord = manager.get_resource_state(GROVE_TREE_01)
state.set_amount_remaining(tree.yield_per_action)
_check(
(
visual.get_amount_tier() == ResourceAmountVisual.AmountTier.LOW
and visual.get_visible_canopy_count() == 2
),
"A nearly spent forest tree should derive the shared low crown from amount"
)
var low_snapshot: String = manager.serialize_state()
var npc: SimNPC = manager.npcs[0]
var inventory_before := npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
npc.target_id = GROVE_TREE_01
_check(
manager.reserve_resource(GROVE_TREE_01, npc.id),
"NPC should reserve the nested forest tree before gathering"
)
manager.call("_complete_resource_gather", npc, SimulationIds.ACTION_GATHER_WOOD)
_check(
(
state.is_depleted()
and is_equal_approx(
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
inventory_before + tree.yield_per_action
)
),
"Real NPC extraction should conserve the final forest-tree yield"
)
_check(
(
visual.get_amount_tier() == ResourceAmountVisual.AmountTier.DEPLETED
and visual.is_stump_visible()
and not visual.is_standing_visible()
),
"Real NPC depletion should leave the shared readable stump"
)
_check(
manager.restore_state_from_json(low_snapshot), "Low forest-tree authority should restore"
)
state = manager.get_resource_state(GROVE_TREE_01)
_check(
(
is_equal_approx(state.get_amount_remaining(), tree.yield_per_action)
and visual.get_amount_tier() == ResourceAmountVisual.AmountTier.LOW
and visual.is_standing_visible()
and not visual.is_stump_visible()
and visual.get_visible_canopy_count() == 2
),
"Restore should rebuild the sparse standing tree from authoritative amount alone"
)
func _check_navigation(main_scene: Node, terrain: Terrain3D, anchors: Array[ResourceNode]) -> void:
var navigation_map: RID = main_scene.get_world_3d().navigation_map
await _wait_for_navigation_map(navigation_map)
NavigationServer3D.map_force_update(navigation_map)
for anchor in anchors:
var destination := anchor.interaction_point.global_position
var path := NavigationServer3D.map_get_path(navigation_map, Vector3.ZERO, destination, true)
_check(
not path.is_empty(), "Navigation should reach forest interaction %s" % anchor.node_id
)
_check_path_tracks_terrain(terrain, path, anchor.node_id)
if not path.is_empty():
var final_point := path[path.size() - 1]
_check(
(
Vector2(final_point.x, final_point.z).distance_to(
Vector2(destination.x, destination.z)
)
<= 1.5
),
"Navigation should finish beside forest interaction %s" % anchor.node_id
)
func _wait_for_navigation_map(navigation_map: RID) -> void:
for _attempt in 30:
if (
NavigationServer3D.map_get_iteration_id(navigation_map) > 0
and not NavigationServer3D.map_get_regions(navigation_map).is_empty()
):
await physics_frame
return
await physics_frame
_check(false, "Forest-edge navigation map did not synchronize within 30 physics frames")
func _check_path_tracks_terrain(
terrain: Terrain3D, path: PackedVector3Array, node_id: StringName
) -> void:
for point in path:
var terrain_height := terrain.data.get_height(point)
if is_nan(terrain_height) or absf(point.y - terrain_height) > 0.85:
_check(false, "Forest route to %s should track Terrain3D height" % node_id)
return
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 forest edge passed: bounded grove -> far discovery -> restore")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://bqt2i8xhxa20l
+138
View File
@@ -0,0 +1,138 @@
class_name ForestEdgeResourceCluster
extends TerrainResourceCluster
const FERN_OFFSETS := [
Vector3(-5.4, 0.0, 1.8),
Vector3(-3.2, 0.0, -1.7),
Vector3(-1.3, 0.0, 2.9),
Vector3(1.1, 0.0, -2.8),
Vector3(2.8, 0.0, 1.4),
Vector3(4.8, 0.0, -3.1),
Vector3(6.2, 0.0, 2.5),
Vector3(8.5, 0.0, -1.2),
Vector3(10.6, 0.0, 1.0),
Vector3(-7.4, 0.0, -2.9),
]
const MUSHROOM_OFFSETS := [
Vector3(-4.4, 0.0, -0.2),
Vector3(-2.1, 0.0, 1.2),
Vector3(0.3, 0.0, -3.5),
Vector3(2.2, 0.0, 3.2),
Vector3(5.0, 0.0, 0.2),
Vector3(7.1, 0.0, -2.8),
Vector3(9.3, 0.0, 2.6),
Vector3(-6.5, 0.0, 2.9),
]
const STONE_OFFSETS := [
Vector3(-6.8, 0.0, 0.2),
Vector3(-2.7, 0.0, -3.4),
Vector3(0.7, 0.0, 0.4),
Vector3(4.0, 0.0, 3.5),
Vector3(7.8, 0.0, 0.8),
Vector3(10.8, 0.0, -2.2),
]
func _build_understory() -> void:
var fern_material := StandardMaterial3D.new()
fern_material.albedo_color = Color("#2f5935")
fern_material.roughness = 0.95
fern_material.cull_mode = BaseMaterial3D.CULL_DISABLED
_assign_multimesh(
$DecorativeInstances/Understory/FernClumps,
_create_fern_mesh(fern_material),
FERN_OFFSETS,
Vector3(0.86, 0.86, 0.86),
0.03
)
_assign_multimesh(
$DecorativeInstances/Understory/MushroomClumps,
_create_mushroom_mesh(),
MUSHROOM_OFFSETS,
Vector3(1.0, 1.0, 1.0),
0.02
)
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("#68695d")
stone_material.roughness = 0.97
stone_mesh.material = stone_material
_assign_multimesh(
$DecorativeInstances/Understory/ForestStones,
stone_mesh,
STONE_OFFSETS,
Vector3(0.55, 0.22, 0.4),
0.2
)
func _create_mushroom_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var colors := PackedColorArray()
var segment_count := 8
var stem_color := Color("#e8d8ae")
var cap_color := Color("#d98142")
var cap_under_color := Color("#b85f35")
for segment in segment_count:
var angle_a := float(segment) / segment_count * TAU
var angle_b := float(segment + 1) / segment_count * TAU
var normal_a := Vector3(cos(angle_a), 0.0, sin(angle_a))
var normal_b := Vector3(cos(angle_b), 0.0, sin(angle_b))
var stem_bottom_a := normal_a * 0.065
var stem_bottom_b := normal_b * 0.065
var stem_top_a := normal_a * 0.045 + Vector3.UP * 0.3
var stem_top_b := normal_b * 0.045 + Vector3.UP * 0.3
(
vertices
. append_array(
[
stem_bottom_a,
stem_bottom_b,
stem_top_b,
stem_bottom_a,
stem_top_b,
stem_top_a,
]
)
)
normals.append_array([normal_a, normal_b, normal_b, normal_a, normal_b, normal_a])
for _vertex in 6:
colors.append(stem_color)
var cap_ring_a := normal_a * 0.24 + Vector3.UP * 0.3
var cap_ring_b := normal_b * 0.24 + Vector3.UP * 0.3
var cap_top := Vector3.UP * 0.48
var cap_normal := (
Vector3(
cos((angle_a + angle_b) * 0.5) * 0.55, 0.82, sin((angle_a + angle_b) * 0.5) * 0.55
)
. normalized()
)
vertices.append_array([cap_top, cap_ring_b, cap_ring_a])
normals.append_array([cap_normal, cap_normal, cap_normal])
colors.append_array([cap_color, cap_color, cap_color])
vertices.append_array([Vector3.UP * 0.29, cap_ring_a, cap_ring_b])
normals.append_array([Vector3.DOWN, Vector3.DOWN, Vector3.DOWN])
colors.append_array([cap_under_color, cap_under_color, cap_under_color])
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_COLOR] = colors
var material := StandardMaterial3D.new()
material.albedo_color = Color.WHITE
material.vertex_color_use_as_albedo = true
material.roughness = 0.88
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://ds44soedhbq3t
@@ -0,0 +1,73 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://world/jajce/ForestEdgeResourceCluster.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/HarvestableTreePresentation.tscn" id="4_harvestable_tree"]
[node name="ForestEdgeResourceCluster" 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="MushroomClumps" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"]
cast_shadow = 0
[node name="ForestStones" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"]
[node name="Trees" type="Node3D" parent="DecorativeInstances"]
[node name="SouthCanopyWest" parent="DecorativeInstances/Trees" instance=ExtResource("3_tree")]
position = Vector3(-7, 0, -2)
scale = Vector3(1.15, 1.15, 1.15)
[node name="SouthCanopyEast" parent="DecorativeInstances/Trees" instance=ExtResource("3_tree")]
position = Vector3(10, 0, -3)
scale = Vector3(0.92, 0.92, 0.92)
[node name="ResourceAnchors" type="Node3D" parent="."]
[node name="Tree_Forest_Grove_01" parent="ResourceAnchors" instance=ExtResource("2_resource")]
node_id = &"tree_forest_grove_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 14.0
yield_per_action = 3.0
safety_risk = 0.3
comfort_distance = 30.0
discovery_priority = -0.1
[node name="MeshInstance3D" parent="ResourceAnchors/Tree_Forest_Grove_01/Visual"]
visible = false
[node name="TreePresentation" parent="ResourceAnchors/Tree_Forest_Grove_01/Visual" instance=ExtResource("4_harvestable_tree")]
scale = Vector3(1.08, 1.08, 1.08)
[node name="InteractionPoint" parent="ResourceAnchors/Tree_Forest_Grove_01"]
position = Vector3(-0.8, 0, 2)
[node name="Tree_Forest_Grove_02" parent="ResourceAnchors" instance=ExtResource("2_resource")]
position = Vector3(4, 0, -1)
node_id = &"tree_forest_grove_02"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 11.0
yield_per_action = 2.5
safety_risk = 0.32
comfort_distance = 32.0
discovery_priority = -0.2
[node name="MeshInstance3D" parent="ResourceAnchors/Tree_Forest_Grove_02/Visual"]
visible = false
[node name="TreePresentation" parent="ResourceAnchors/Tree_Forest_Grove_02/Visual" instance=ExtResource("4_harvestable_tree")]
rotation_degrees = Vector3(0, 21, 0)
scale = Vector3(0.96, 0.96, 0.96)
[node name="InteractionPoint" parent="ResourceAnchors/Tree_Forest_Grove_02"]
position = Vector3(0.8, 0, 2)
+16
View File
@@ -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="JajceForestEdgeLookdev" type="Node3D"]
[node name="JajceWorld" parent="." instance=ExtResource("1_world")]
[node name="ForestEdgeCamera" type="Camera3D" parent="."]
position = Vector3(12, 10, -10)
current = true
fov = 43.0
script = ExtResource("2_camera")
focus_point = Vector3(-6, 2.5, -27)
animate_orbit = false
+5 -30
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=58 format=3]
[gd_scene load_steps=59 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"]
@@ -26,6 +26,7 @@
[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"]
[ext_resource type="PackedScene" path="res://world/jajce/ForestEdgeResourceCluster.tscn" id="27_forest_cluster"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = {
@@ -400,13 +401,6 @@ scale = Vector3(1.2, 1.2, 1.2)
position = Vector3(15, 0, 24)
scale = Vector3(0.85, 0.85, 0.85)
[node name="Tree_South_01" parent="FoliageRoot" instance=ExtResource("4_tree")]
position = Vector3(-16, 0, -27)
scale = Vector3(1.15, 1.15, 1.15)
[node name="Tree_South_02" parent="FoliageRoot" instance=ExtResource("4_tree")]
position = Vector3(-4, 0, -30)
[node name="Tree_River_01" parent="FoliageRoot" instance=ExtResource("4_tree")]
position = Vector3(19, 0, 18)
scale = Vector3(0.9, 0.9, 0.9)
@@ -578,28 +572,6 @@ safety_risk = 0.02
comfort_distance = 16.0
discovery_priority = 0.85
[node name="Tree_Forest_Grove_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-8, 0, -22)
node_id = &"tree_forest_grove_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 14.0
yield_per_action = 3.0
safety_risk = 0.3
comfort_distance = 30.0
discovery_priority = -0.1
[node name="Tree_Forest_Grove_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-3, 0, -24)
node_id = &"tree_forest_grove_02"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 11.0
yield_per_action = 2.5
safety_risk = 0.32
comfort_distance = 32.0
discovery_priority = -0.2
[node name="WoodPile_Mill_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(21, 0, 1)
node_id = &"wood_pile_mill_01"
@@ -625,6 +597,9 @@ discovery_priority = 0.45
[node name="RiverbankResourceCluster" parent="WorldObjects/ResourceClusters" instance=ExtResource("26_resource_cluster")]
position = Vector3(33, 0, 7)
[node name="ForestEdgeResourceCluster" parent="WorldObjects/ResourceClusters" instance=ExtResource("27_forest_cluster")]
position = Vector3(-8, 0, -27)
[node name="StorageSites" type="Node3D" parent="WorldObjects"]
[node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
+1 -130
View File
@@ -1,5 +1,5 @@
class_name RiverbankResourceCluster
extends Node3D
extends TerrainResourceCluster
const FERN_OFFSETS := [
Vector3(-0.8, 0.0, -10.8),
@@ -36,75 +36,6 @@ const STONE_OFFSETS := [
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()
@@ -148,63 +79,3 @@ func _build_understory() -> void:
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
+135
View File
@@ -0,0 +1,135 @@
class_name TerrainResourceCluster
extends Node3D
@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("%s: Terrain3D is unavailable" % name)
return
_snap_authored_children($DecorativeInstances/Trees)
for anchor in get_resource_anchors():
_snap_resource_anchor(anchor)
_build_understory()
func _build_understory() -> void:
pass
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 _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://bu3x1gejupjqg