diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 5ba42c8..6532315 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -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 diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 2381cf4..60f252c 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -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 diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index fdd505d..98b57d8 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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: diff --git a/docs/RESOURCE_NODE_MIGRATION.md b/docs/RESOURCE_NODE_MIGRATION.md index dec1bc2..092109d 100644 --- a/docs/RESOURCE_NODE_MIGRATION.md +++ b/docs/RESOURCE_NODE_MIGRATION.md @@ -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: diff --git a/docs/baselines/JAJCE_FOREST_EDGE_06.md b/docs/baselines/JAJCE_FOREST_EDGE_06.md new file mode 100644 index 0000000..701ca5e --- /dev/null +++ b/docs/baselines/JAJCE_FOREST_EDGE_06.md @@ -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 +``` diff --git a/docs/baselines/jajce_forest_edge_06_after.png b/docs/baselines/jajce_forest_edge_06_after.png new file mode 100644 index 0000000..ca5804d Binary files /dev/null and b/docs/baselines/jajce_forest_edge_06_after.png differ diff --git a/docs/baselines/jajce_forest_edge_06_after.png.import b/docs/baselines/jajce_forest_edge_06_after.png.import new file mode 100644 index 0000000..3a393f9 --- /dev/null +++ b/docs/baselines/jajce_forest_edge_06_after.png.import @@ -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 diff --git a/docs/baselines/jajce_forest_edge_06_before.png b/docs/baselines/jajce_forest_edge_06_before.png new file mode 100644 index 0000000..ff2e61e Binary files /dev/null and b/docs/baselines/jajce_forest_edge_06_before.png differ diff --git a/docs/baselines/jajce_forest_edge_06_before.png.import b/docs/baselines/jajce_forest_edge_06_before.png.import new file mode 100644 index 0000000..e19c90c --- /dev/null +++ b/docs/baselines/jajce_forest_edge_06_before.png.import @@ -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 diff --git a/docs/baselines/jajce_forest_edge_06_depleted.png b/docs/baselines/jajce_forest_edge_06_depleted.png new file mode 100644 index 0000000..ab4579d Binary files /dev/null and b/docs/baselines/jajce_forest_edge_06_depleted.png differ diff --git a/docs/baselines/jajce_forest_edge_06_depleted.png.import b/docs/baselines/jajce_forest_edge_06_depleted.png.import new file mode 100644 index 0000000..120dde7 --- /dev/null +++ b/docs/baselines/jajce_forest_edge_06_depleted.png.import @@ -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 diff --git a/tests/jajce_forest_edge_cluster_test.gd b/tests/jajce_forest_edge_cluster_test.gd new file mode 100644 index 0000000..0da42b4 --- /dev/null +++ b/tests/jajce_forest_edge_cluster_test.gd @@ -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) diff --git a/tests/jajce_forest_edge_cluster_test.gd.uid b/tests/jajce_forest_edge_cluster_test.gd.uid new file mode 100644 index 0000000..40e0a21 --- /dev/null +++ b/tests/jajce_forest_edge_cluster_test.gd.uid @@ -0,0 +1 @@ +uid://bqt2i8xhxa20l diff --git a/world/jajce/ForestEdgeResourceCluster.gd b/world/jajce/ForestEdgeResourceCluster.gd new file mode 100644 index 0000000..2f5b67b --- /dev/null +++ b/world/jajce/ForestEdgeResourceCluster.gd @@ -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 diff --git a/world/jajce/ForestEdgeResourceCluster.gd.uid b/world/jajce/ForestEdgeResourceCluster.gd.uid new file mode 100644 index 0000000..d114543 --- /dev/null +++ b/world/jajce/ForestEdgeResourceCluster.gd.uid @@ -0,0 +1 @@ +uid://ds44soedhbq3t diff --git a/world/jajce/ForestEdgeResourceCluster.tscn b/world/jajce/ForestEdgeResourceCluster.tscn new file mode 100644 index 0000000..dcac67d --- /dev/null +++ b/world/jajce/ForestEdgeResourceCluster.tscn @@ -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) diff --git a/world/jajce/JajceForestEdgeLookdev.tscn b/world/jajce/JajceForestEdgeLookdev.tscn new file mode 100644 index 0000000..5b158c1 --- /dev/null +++ b/world/jajce/JajceForestEdgeLookdev.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="1_world"] +[ext_resource type="Script" path="res://world/jajce/beauty_camera.gd" id="2_camera"] + +[node name="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 diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 419d537..daaa214 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -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")] diff --git a/world/jajce/RiverbankResourceCluster.gd b/world/jajce/RiverbankResourceCluster.gd index 060b415..07bc118 100644 --- a/world/jajce/RiverbankResourceCluster.gd +++ b/world/jajce/RiverbankResourceCluster.gd @@ -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 diff --git a/world/jajce/TerrainResourceCluster.gd b/world/jajce/TerrainResourceCluster.gd new file mode 100644 index 0000000..a7c9382 --- /dev/null +++ b/world/jajce/TerrainResourceCluster.gd @@ -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 diff --git a/world/jajce/TerrainResourceCluster.gd.uid b/world/jajce/TerrainResourceCluster.gd.uid new file mode 100644 index 0000000..ae1fb04 --- /dev/null +++ b/world/jajce/TerrainResourceCluster.gd.uid @@ -0,0 +1 @@ +uid://bu3x1gejupjqg