feat: author discoverable forest edge
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user