Files
gamedev-the-steward/world/jajce/PainterlyMeadow.gd
T

218 lines
7.4 KiB
GDScript

class_name PainterlyMeadow
extends Node3D
## Decorative flower drifts. Local seeded placement never touches simulation RNG.
const FLOWER_SCENES: Array[PackedScene] = [
preload("res://assets/meadow/cream_daisy.glb"),
preload("res://assets/meadow/pink_cosmos.glb"),
preload("res://assets/meadow/yellow_buttercup.glb"),
preload("res://assets/meadow/lavender_spire.glb"),
]
const FLOWER_SHADER := preload("res://world/jajce/materials/meadow_flowers.gdshader")
const PROFILE_DENSITY := [1.0, 0.65, 0.32]
const PROFILE_DISTANCE := [56.0, 46.0, 34.0]
@export var terrain_path := NodePath("../TerrainRoot/Terrain3D")
@export_range(0, 450, 1) var clumps_per_species := 420
@export var placement_seed := 57091
## Local x/z center, then ellipse x/z radii. Each drift favors one flower color.
@export var drifts: Array[Vector4] = [
Vector4(7.0, 20.0, 7.0, 3.5),
Vector4(15.0, 13.0, 2.4, 4.0),
Vector4(-5.0, 20.0, 5.0, 3.0),
Vector4(-18.0, -21.0, 4.5, 3.2),
Vector4(14.0, -10.0, 3.8, 4.5),
Vector4(-25.0, -17.0, 3.5, 4.0),
Vector4(8.0, -20.0, 5.0, 2.5),
Vector4(-2.0, -24.0, 6.0, 2.2),
Vector4(17.0, 24.0, 4.0, 3.0),
Vector4(-9.0, 21.0, 3.6, 2.5),
Vector4(9.0, 7.2, 3.2, 1.8),
Vector4(-16.0, 19.0, 3.0, 2.4),
]
var _terrain: Terrain3D
var _batches: Array[MultiMeshInstance3D] = []
var _material: ShaderMaterial
var _quality := 0
var _clearings: Array[Vector3] = []
var _path_strips: Array[MeshInstance3D] = []
func _ready() -> void:
call_deferred("_build_meadow")
func apply_presentation_quality(quality: int) -> void:
_quality = clampi(quality, 0, PROFILE_DENSITY.size() - 1)
for batch in _batches:
batch.multimesh.visible_instance_count = floori(
batch.multimesh.instance_count * PROFILE_DENSITY[_quality]
)
if _material != null:
_material.set_shader_parameter("fade_begin", PROFILE_DISTANCE[_quality] - 12.0)
_material.set_shader_parameter("fade_end", PROFILE_DISTANCE[_quality])
func get_presentation_stats() -> Dictionary:
var total := 0
var visible_count := 0
for batch in _batches:
total += batch.multimesh.instance_count
visible_count += batch.multimesh.visible_instance_count
return {
"flower_clump_count": total,
"visible_flower_clump_count": visible_count,
"multimesh_batch_count": _batches.size(),
}
func _build_meadow() -> void:
if not _batches.is_empty():
return
_terrain = get_node_or_null(terrain_path) as Terrain3D
if _terrain == null or _terrain.data == null or drifts.is_empty():
return
_collect_clearings()
_material = ShaderMaterial.new()
_material.shader = FLOWER_SHADER
for species in FLOWER_SCENES.size():
var source := FLOWER_SCENES[species].instantiate()
var mesh := _find_mesh(source)
if mesh != null:
_add_batch(mesh, species)
source.free()
apply_presentation_quality(_quality)
func _find_mesh(node: Node) -> Mesh:
if node is MeshInstance3D:
return (node as MeshInstance3D).mesh
for child in node.get_children():
var mesh := _find_mesh(child)
if mesh != null:
return mesh
return null
func _add_batch(mesh: Mesh, species: int) -> void:
var rng := RandomNumberGenerator.new()
rng.seed = placement_seed + species * 173
var transforms: Array[Transform3D] = []
var variations: Array[Color] = []
for _attempt in clumps_per_species * 18:
if transforms.size() >= clumps_per_species:
break
var drift_index := rng.randi_range(0, drifts.size() - 1)
# Broad color masses with a few companion flowers at the edges.
if drift_index % FLOWER_SCENES.size() != species and rng.randf() > 0.12:
continue
var drift := drifts[drift_index]
var angle := rng.randf_range(0.0, TAU)
var radius := pow(rng.randf(), 0.7) * (0.9 + 0.12 * sin(angle * 3.0 + drift_index))
var offset := Vector3(
drift.x + cos(angle) * radius * drift.z, 0.0, drift.y + sin(angle) * radius * drift.w
)
var world_sample := to_global(offset)
if _is_cleared(world_sample):
continue
var height := _terrain.data.get_height(world_sample)
if is_nan(height):
continue
var nearby := _terrain.data.get_height(world_sample + Vector3(0.35, 0.0, 0.35))
if is_nan(nearby) or absf(height - nearby) > 0.32:
continue
var local_position := to_local(Vector3(world_sample.x, height - 0.018, world_sample.z))
var scale_variation := rng.randf_range(0.80, 1.28)
var basis := Basis(Vector3.UP, rng.randf_range(0.0, TAU)).scaled(
Vector3.ONE * scale_variation
)
transforms.append(Transform3D(basis, local_position))
variations.append(Color(rng.randf(), 0.0, 0.0, 1.0))
var multimesh := MultiMesh.new()
multimesh.transform_format = MultiMesh.TRANSFORM_3D
# Compatibility needs an explicit neutral color alongside the custom buffer
# to preserve the GLB's painted vertex colors.
multimesh.use_colors = true
multimesh.use_custom_data = true
multimesh.mesh = mesh
multimesh.instance_count = transforms.size()
for index in transforms.size():
multimesh.set_instance_transform(index, transforms[index])
multimesh.set_instance_color(index, Color.WHITE)
multimesh.set_instance_custom_data(index, variations[index])
var batch := MultiMeshInstance3D.new()
batch.name = "FlowerDrifts_%d" % species
batch.multimesh = multimesh
batch.material_override = _material
batch.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
batch.extra_cull_margin = 0.4
add_child(batch)
_batches.append(batch)
func _collect_clearings() -> void:
var village := get_node_or_null("../VillageRoot")
if village != null:
for child in village.get_children():
if child is MeshInstance3D and str(child.name).begins_with("Path_"):
_path_strips.append(child)
elif child is Node3D:
var radius := 4.3 if str(child.name).begins_with("House_") else 4.5
_add_clearing(child, radius)
var fortress := get_node_or_null("../FortressBlockout") as Node3D
if fortress != null:
_add_clearing(fortress, 11.0)
for root_path in [
"../WorldObjects/ResourceNodes",
"../WorldObjects/StorageSites",
"../WorldObjects/ActivitySites",
"../WorldObjects/AnimalHabitats",
]:
var root := get_node_or_null(root_path)
if root != null:
for child in root.get_children():
if child is Node3D:
_add_worksite_clearing(child)
var clusters := get_node_or_null("../WorldObjects/ResourceClusters")
if clusters != null:
for cluster in clusters.get_children():
var anchors := cluster.get_node_or_null("ResourceAnchors")
if anchors == null:
continue
for anchor in anchors.get_children():
if anchor is Node3D:
_add_worksite_clearing(anchor)
func _add_worksite_clearing(node: Node3D) -> void:
_add_clearing(node, 2.0)
var interaction := node.get_node_or_null("InteractionPoint") as Node3D
if interaction != null:
_add_clearing(interaction, 1.2)
func _add_clearing(node: Node3D, radius: float) -> void:
_clearings.append(Vector3(node.global_position.x, node.global_position.z, radius))
func _is_cleared(point: Vector3) -> bool:
var flat := Vector2(point.x, point.z)
for clearing in _clearings:
if flat.distance_squared_to(Vector2(clearing.x, clearing.y)) < clearing.z * clearing.z:
return true
for strip in _path_strips:
var box := strip.mesh as BoxMesh
if box == null:
continue
var local := strip.to_local(point)
var half_width := box.size.x * 0.5 + 0.35 / strip.global_basis.x.length()
if absf(local.x) < half_width and absf(local.z) < box.size.z * 0.5 + 0.08:
return true
var river_distance := absf(point.x - JajceWatercourse.downstream_center_x(point.z))
return (
point.z >= JajceWatercourse.DOWNSTREAM_START_Z - 3.0
and point.z <= JajceWatercourse.DOWNSTREAM_END_Z
and river_distance < JajceWatercourse.downstream_half_width(point.z) + 1.7
)