feat: author discoverable forest edge

This commit is contained in:
Rijad Zuzo
2026-07-18 19:09:04 +02:00
parent 2719b0398b
commit 1f09ebe6b6
21 changed files with 911 additions and 180 deletions
+138
View File
@@ -0,0 +1,138 @@
class_name ForestEdgeResourceCluster
extends TerrainResourceCluster
const FERN_OFFSETS := [
Vector3(-5.4, 0.0, 1.8),
Vector3(-3.2, 0.0, -1.7),
Vector3(-1.3, 0.0, 2.9),
Vector3(1.1, 0.0, -2.8),
Vector3(2.8, 0.0, 1.4),
Vector3(4.8, 0.0, -3.1),
Vector3(6.2, 0.0, 2.5),
Vector3(8.5, 0.0, -1.2),
Vector3(10.6, 0.0, 1.0),
Vector3(-7.4, 0.0, -2.9),
]
const MUSHROOM_OFFSETS := [
Vector3(-4.4, 0.0, -0.2),
Vector3(-2.1, 0.0, 1.2),
Vector3(0.3, 0.0, -3.5),
Vector3(2.2, 0.0, 3.2),
Vector3(5.0, 0.0, 0.2),
Vector3(7.1, 0.0, -2.8),
Vector3(9.3, 0.0, 2.6),
Vector3(-6.5, 0.0, 2.9),
]
const STONE_OFFSETS := [
Vector3(-6.8, 0.0, 0.2),
Vector3(-2.7, 0.0, -3.4),
Vector3(0.7, 0.0, 0.4),
Vector3(4.0, 0.0, 3.5),
Vector3(7.8, 0.0, 0.8),
Vector3(10.8, 0.0, -2.2),
]
func _build_understory() -> void:
var fern_material := StandardMaterial3D.new()
fern_material.albedo_color = Color("#2f5935")
fern_material.roughness = 0.95
fern_material.cull_mode = BaseMaterial3D.CULL_DISABLED
_assign_multimesh(
$DecorativeInstances/Understory/FernClumps,
_create_fern_mesh(fern_material),
FERN_OFFSETS,
Vector3(0.86, 0.86, 0.86),
0.03
)
_assign_multimesh(
$DecorativeInstances/Understory/MushroomClumps,
_create_mushroom_mesh(),
MUSHROOM_OFFSETS,
Vector3(1.0, 1.0, 1.0),
0.02
)
var stone_mesh := SphereMesh.new()
stone_mesh.radius = 1.0
stone_mesh.height = 2.0
stone_mesh.radial_segments = 8
stone_mesh.rings = 4
var stone_material := StandardMaterial3D.new()
stone_material.albedo_color = Color("#68695d")
stone_material.roughness = 0.97
stone_mesh.material = stone_material
_assign_multimesh(
$DecorativeInstances/Understory/ForestStones,
stone_mesh,
STONE_OFFSETS,
Vector3(0.55, 0.22, 0.4),
0.2
)
func _create_mushroom_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var colors := PackedColorArray()
var segment_count := 8
var stem_color := Color("#e8d8ae")
var cap_color := Color("#d98142")
var cap_under_color := Color("#b85f35")
for segment in segment_count:
var angle_a := float(segment) / segment_count * TAU
var angle_b := float(segment + 1) / segment_count * TAU
var normal_a := Vector3(cos(angle_a), 0.0, sin(angle_a))
var normal_b := Vector3(cos(angle_b), 0.0, sin(angle_b))
var stem_bottom_a := normal_a * 0.065
var stem_bottom_b := normal_b * 0.065
var stem_top_a := normal_a * 0.045 + Vector3.UP * 0.3
var stem_top_b := normal_b * 0.045 + Vector3.UP * 0.3
(
vertices
. append_array(
[
stem_bottom_a,
stem_bottom_b,
stem_top_b,
stem_bottom_a,
stem_top_b,
stem_top_a,
]
)
)
normals.append_array([normal_a, normal_b, normal_b, normal_a, normal_b, normal_a])
for _vertex in 6:
colors.append(stem_color)
var cap_ring_a := normal_a * 0.24 + Vector3.UP * 0.3
var cap_ring_b := normal_b * 0.24 + Vector3.UP * 0.3
var cap_top := Vector3.UP * 0.48
var cap_normal := (
Vector3(
cos((angle_a + angle_b) * 0.5) * 0.55, 0.82, sin((angle_a + angle_b) * 0.5) * 0.55
)
. normalized()
)
vertices.append_array([cap_top, cap_ring_b, cap_ring_a])
normals.append_array([cap_normal, cap_normal, cap_normal])
colors.append_array([cap_color, cap_color, cap_color])
vertices.append_array([Vector3.UP * 0.29, cap_ring_a, cap_ring_b])
normals.append_array([Vector3.DOWN, Vector3.DOWN, Vector3.DOWN])
colors.append_array([cap_under_color, cap_under_color, cap_under_color])
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_COLOR] = colors
var material := StandardMaterial3D.new()
material.albedo_color = Color.WHITE
material.vertex_color_use_as_albedo = true
material.roughness = 0.88
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
mesh.surface_set_material(0, material)
return mesh
@@ -0,0 +1 @@
uid://ds44soedhbq3t
@@ -0,0 +1,73 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://world/jajce/ForestEdgeResourceCluster.gd" id="1_script"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
[ext_resource type="PackedScene" path="res://world/jajce/StylizedTree.tscn" id="3_tree"]
[ext_resource type="PackedScene" path="res://world/jajce/HarvestableTreePresentation.tscn" id="4_harvestable_tree"]
[node name="ForestEdgeResourceCluster" type="Node3D"]
script = ExtResource("1_script")
[node name="DecorativeInstances" type="Node3D" parent="."]
[node name="Understory" type="Node3D" parent="DecorativeInstances"]
[node name="FernClumps" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"]
cast_shadow = 0
[node name="MushroomClumps" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"]
cast_shadow = 0
[node name="ForestStones" type="MultiMeshInstance3D" parent="DecorativeInstances/Understory"]
[node name="Trees" type="Node3D" parent="DecorativeInstances"]
[node name="SouthCanopyWest" parent="DecorativeInstances/Trees" instance=ExtResource("3_tree")]
position = Vector3(-7, 0, -2)
scale = Vector3(1.15, 1.15, 1.15)
[node name="SouthCanopyEast" parent="DecorativeInstances/Trees" instance=ExtResource("3_tree")]
position = Vector3(10, 0, -3)
scale = Vector3(0.92, 0.92, 0.92)
[node name="ResourceAnchors" type="Node3D" parent="."]
[node name="Tree_Forest_Grove_01" parent="ResourceAnchors" instance=ExtResource("2_resource")]
node_id = &"tree_forest_grove_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 14.0
yield_per_action = 3.0
safety_risk = 0.3
comfort_distance = 30.0
discovery_priority = -0.1
[node name="MeshInstance3D" parent="ResourceAnchors/Tree_Forest_Grove_01/Visual"]
visible = false
[node name="TreePresentation" parent="ResourceAnchors/Tree_Forest_Grove_01/Visual" instance=ExtResource("4_harvestable_tree")]
scale = Vector3(1.08, 1.08, 1.08)
[node name="InteractionPoint" parent="ResourceAnchors/Tree_Forest_Grove_01"]
position = Vector3(-0.8, 0, 2)
[node name="Tree_Forest_Grove_02" parent="ResourceAnchors" instance=ExtResource("2_resource")]
position = Vector3(4, 0, -1)
node_id = &"tree_forest_grove_02"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 11.0
yield_per_action = 2.5
safety_risk = 0.32
comfort_distance = 32.0
discovery_priority = -0.2
[node name="MeshInstance3D" parent="ResourceAnchors/Tree_Forest_Grove_02/Visual"]
visible = false
[node name="TreePresentation" parent="ResourceAnchors/Tree_Forest_Grove_02/Visual" instance=ExtResource("4_harvestable_tree")]
rotation_degrees = Vector3(0, 21, 0)
scale = Vector3(0.96, 0.96, 0.96)
[node name="InteractionPoint" parent="ResourceAnchors/Tree_Forest_Grove_02"]
position = Vector3(0.8, 0, 2)
+16
View File
@@ -0,0 +1,16 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="1_world"]
[ext_resource type="Script" path="res://world/jajce/beauty_camera.gd" id="2_camera"]
[node name="JajceForestEdgeLookdev" type="Node3D"]
[node name="JajceWorld" parent="." instance=ExtResource("1_world")]
[node name="ForestEdgeCamera" type="Camera3D" parent="."]
position = Vector3(12, 10, -10)
current = true
fov = 43.0
script = ExtResource("2_camera")
focus_point = Vector3(-6, 2.5, -27)
animate_orbit = false
+5 -30
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=58 format=3]
[gd_scene load_steps=59 format=3]
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -26,6 +26,7 @@
[ext_resource type="PackedScene" path="res://world/jajce/CozyButterfly.tscn" id="24_butterfly"]
[ext_resource type="Material" path="res://world/jajce/materials/cozy_grass_process_material.tres" id="25_grass_process"]
[ext_resource type="PackedScene" path="res://world/jajce/RiverbankResourceCluster.tscn" id="26_resource_cluster"]
[ext_resource type="PackedScene" path="res://world/jajce/ForestEdgeResourceCluster.tscn" id="27_forest_cluster"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = {
@@ -400,13 +401,6 @@ scale = Vector3(1.2, 1.2, 1.2)
position = Vector3(15, 0, 24)
scale = Vector3(0.85, 0.85, 0.85)
[node name="Tree_South_01" parent="FoliageRoot" instance=ExtResource("4_tree")]
position = Vector3(-16, 0, -27)
scale = Vector3(1.15, 1.15, 1.15)
[node name="Tree_South_02" parent="FoliageRoot" instance=ExtResource("4_tree")]
position = Vector3(-4, 0, -30)
[node name="Tree_River_01" parent="FoliageRoot" instance=ExtResource("4_tree")]
position = Vector3(19, 0, 18)
scale = Vector3(0.9, 0.9, 0.9)
@@ -578,28 +572,6 @@ safety_risk = 0.02
comfort_distance = 16.0
discovery_priority = 0.85
[node name="Tree_Forest_Grove_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-8, 0, -22)
node_id = &"tree_forest_grove_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 14.0
yield_per_action = 3.0
safety_risk = 0.3
comfort_distance = 30.0
discovery_priority = -0.1
[node name="Tree_Forest_Grove_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-3, 0, -24)
node_id = &"tree_forest_grove_02"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 11.0
yield_per_action = 2.5
safety_risk = 0.32
comfort_distance = 32.0
discovery_priority = -0.2
[node name="WoodPile_Mill_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(21, 0, 1)
node_id = &"wood_pile_mill_01"
@@ -625,6 +597,9 @@ discovery_priority = 0.45
[node name="RiverbankResourceCluster" parent="WorldObjects/ResourceClusters" instance=ExtResource("26_resource_cluster")]
position = Vector3(33, 0, 7)
[node name="ForestEdgeResourceCluster" parent="WorldObjects/ResourceClusters" instance=ExtResource("27_forest_cluster")]
position = Vector3(-8, 0, -27)
[node name="StorageSites" type="Node3D" parent="WorldObjects"]
[node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
+1 -130
View File
@@ -1,5 +1,5 @@
class_name RiverbankResourceCluster
extends Node3D
extends TerrainResourceCluster
const FERN_OFFSETS := [
Vector3(-0.8, 0.0, -10.8),
@@ -36,75 +36,6 @@ const STONE_OFFSETS := [
Vector3(2.7, 0.0, 11.2),
]
@export var terrain_path := NodePath("../../../TerrainRoot/Terrain3D")
var terrain: Terrain3D
func _ready() -> void:
call_deferred("_initialize_cluster")
func get_resource_anchors() -> Array[ResourceNode]:
var anchors: Array[ResourceNode] = []
for child in $ResourceAnchors.get_children():
if child is ResourceNode:
anchors.append(child)
return anchors
func get_presentation_stats() -> Dictionary:
var understory_instances := 0
var multimesh_batches := 0
for child in $DecorativeInstances/Understory.get_children():
var instance := child as MultiMeshInstance3D
if instance == null or instance.multimesh == null:
continue
understory_instances += instance.multimesh.instance_count
multimesh_batches += 1
return {
"understory_instance_count": understory_instances,
"multimesh_batch_count": multimesh_batches,
"decorative_tree_count": $DecorativeInstances/Trees.get_child_count(),
"resource_anchor_count": get_resource_anchors().size(),
}
func _initialize_cluster() -> void:
terrain = get_node_or_null(terrain_path) as Terrain3D
if terrain == null:
push_warning("RiverbankResourceCluster: Terrain3D is unavailable")
return
_snap_authored_children($DecorativeInstances/Trees)
for anchor in get_resource_anchors():
_snap_resource_anchor(anchor)
_build_understory()
func _snap_authored_children(parent: Node) -> void:
for child in parent.get_children():
if child is Node3D:
_snap_to_terrain(child)
func _snap_to_terrain(node: Node3D) -> void:
var position := node.global_position
var height := terrain.data.get_height(position)
if not is_nan(height):
node.global_position.y = height
func _snap_resource_anchor(anchor: ResourceNode) -> void:
var anchor_position := anchor.global_position
var anchor_height := terrain.data.get_height(anchor_position)
if is_nan(anchor_height):
return
var interaction_position := anchor.global_transform * anchor.interaction_point.position
var interaction_height := terrain.data.get_height(interaction_position)
if not is_nan(interaction_height):
anchor.interaction_point.position.y = interaction_height - anchor_height + 0.05
anchor.global_position.y = anchor_height
func _build_understory() -> void:
var fern_material := StandardMaterial3D.new()
@@ -148,63 +79,3 @@ func _build_understory() -> void:
Vector3(0.62, 0.24, 0.44),
0.22
)
func _assign_multimesh(
target: MultiMeshInstance3D,
mesh: Mesh,
offsets: Array,
base_scale: Vector3,
vertical_offset: float
) -> void:
var multimesh := MultiMesh.new()
multimesh.transform_format = MultiMesh.TRANSFORM_3D
multimesh.mesh = mesh
multimesh.instance_count = offsets.size()
for index in offsets.size():
var offset: Vector3 = offsets[index]
var world_sample := to_global(offset)
var height := terrain.data.get_height(world_sample)
if is_nan(height):
height = global_position.y
var local_position := to_local(Vector3(world_sample.x, height, world_sample.z))
local_position.y += vertical_offset
var scale_variation := 0.84 + float((index * 37) % 5) * 0.07
var rotation := deg_to_rad(float((index * 53) % 360))
var basis := Basis(Vector3.UP, rotation).scaled(base_scale * scale_variation)
multimesh.set_instance_transform(index, Transform3D(basis, local_position))
target.multimesh = multimesh
func _create_fern_mesh(material: Material) -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var uvs := PackedVector2Array()
for frond_index in 5:
var angle := float(frond_index) / 5.0 * TAU + 0.3
var direction := Vector3(cos(angle), 0.0, sin(angle))
var side := Vector3(-direction.z, 0.0, direction.x) * 0.16
var base := Vector3(0.0, 0.02, 0.0)
var middle := direction * 0.38 + Vector3.UP * 0.14
var tip := direction * 0.82 + Vector3.UP * 0.24
vertices.append_array([base, middle + side, tip, base, tip, middle - side])
for _vertex in 6:
normals.append(Vector3.UP)
for uv in [
Vector2(0.5, 0.0),
Vector2(1.0, 0.5),
Vector2(0.5, 1.0),
Vector2(0.5, 0.0),
Vector2(0.5, 1.0),
Vector2(0.0, 0.5),
]:
uvs.append(uv)
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_TEX_UV] = uvs
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
mesh.surface_set_material(0, material)
return mesh
+135
View File
@@ -0,0 +1,135 @@
class_name TerrainResourceCluster
extends Node3D
@export var terrain_path := NodePath("../../../TerrainRoot/Terrain3D")
var terrain: Terrain3D
func _ready() -> void:
call_deferred("_initialize_cluster")
func get_resource_anchors() -> Array[ResourceNode]:
var anchors: Array[ResourceNode] = []
for child in $ResourceAnchors.get_children():
if child is ResourceNode:
anchors.append(child)
return anchors
func get_presentation_stats() -> Dictionary:
var understory_instances := 0
var multimesh_batches := 0
for child in $DecorativeInstances/Understory.get_children():
var instance := child as MultiMeshInstance3D
if instance == null or instance.multimesh == null:
continue
understory_instances += instance.multimesh.instance_count
multimesh_batches += 1
return {
"understory_instance_count": understory_instances,
"multimesh_batch_count": multimesh_batches,
"decorative_tree_count": $DecorativeInstances/Trees.get_child_count(),
"resource_anchor_count": get_resource_anchors().size(),
}
func _initialize_cluster() -> void:
terrain = get_node_or_null(terrain_path) as Terrain3D
if terrain == null:
push_warning("%s: Terrain3D is unavailable" % name)
return
_snap_authored_children($DecorativeInstances/Trees)
for anchor in get_resource_anchors():
_snap_resource_anchor(anchor)
_build_understory()
func _build_understory() -> void:
pass
func _snap_authored_children(parent: Node) -> void:
for child in parent.get_children():
if child is Node3D:
_snap_to_terrain(child)
func _snap_to_terrain(node: Node3D) -> void:
var position := node.global_position
var height := terrain.data.get_height(position)
if not is_nan(height):
node.global_position.y = height
func _snap_resource_anchor(anchor: ResourceNode) -> void:
var anchor_position := anchor.global_position
var anchor_height := terrain.data.get_height(anchor_position)
if is_nan(anchor_height):
return
var interaction_position := anchor.global_transform * anchor.interaction_point.position
var interaction_height := terrain.data.get_height(interaction_position)
if not is_nan(interaction_height):
anchor.interaction_point.position.y = interaction_height - anchor_height + 0.05
anchor.global_position.y = anchor_height
func _assign_multimesh(
target: MultiMeshInstance3D,
mesh: Mesh,
offsets: Array,
base_scale: Vector3,
vertical_offset: float
) -> void:
var multimesh := MultiMesh.new()
multimesh.transform_format = MultiMesh.TRANSFORM_3D
multimesh.mesh = mesh
multimesh.instance_count = offsets.size()
for index in offsets.size():
var offset: Vector3 = offsets[index]
var world_sample := to_global(offset)
var height := terrain.data.get_height(world_sample)
if is_nan(height):
height = global_position.y
var local_position := to_local(Vector3(world_sample.x, height, world_sample.z))
local_position.y += vertical_offset
var scale_variation := 0.84 + float((index * 37) % 5) * 0.07
var rotation := deg_to_rad(float((index * 53) % 360))
var basis := Basis(Vector3.UP, rotation).scaled(base_scale * scale_variation)
multimesh.set_instance_transform(index, Transform3D(basis, local_position))
target.multimesh = multimesh
func _create_fern_mesh(material: Material) -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var uvs := PackedVector2Array()
for frond_index in 5:
var angle := float(frond_index) / 5.0 * TAU + 0.3
var direction := Vector3(cos(angle), 0.0, sin(angle))
var side := Vector3(-direction.z, 0.0, direction.x) * 0.16
var base := Vector3(0.0, 0.02, 0.0)
var middle := direction * 0.38 + Vector3.UP * 0.14
var tip := direction * 0.82 + Vector3.UP * 0.24
vertices.append_array([base, middle + side, tip, base, tip, middle - side])
for _vertex in 6:
normals.append(Vector3.UP)
for uv in [
Vector2(0.5, 0.0),
Vector2(1.0, 0.5),
Vector2(0.5, 1.0),
Vector2(0.5, 0.0),
Vector2(0.5, 1.0),
Vector2(0.0, 0.5),
]:
uvs.append(uv)
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_TEX_UV] = uvs
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
mesh.surface_set_material(0, material)
return mesh
@@ -0,0 +1 @@
uid://bu3x1gejupjqg