From 5ed4cccf76320bb8f5b01ef612c994633553d049 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:45 -0400 Subject: [PATCH] Anchor beetles to generated tree surfaces --- gathering/catalog/beetle_stag_common.tres | 2 + gathering/gatherable_data.gd | 24 ++ network/network_world_spawn_service.gd | 15 +- tests/generated_world_runtime_validation.gd | 47 ++-- tests/tree_gathering_prototype_validation.gd | 67 +++++ tests/world_spawn_protocol_validation.gd | 2 + world/generation/generated_world_region.gd | 48 ++-- .../generation/mesh_surface_anchor_sampler.gd | 260 ++++++++++++++++++ .../mesh_surface_anchor_sampler.gd.uid | 1 + .../props/definitions/prop_palm.tres | 1 + .../props/definitions/prop_pine.tres | 2 +- .../props/definitions/prop_pine_large.tres | 2 +- .../props/definitions/prop_tree_1.tres | 2 +- .../props/definitions/prop_tree_2.tres | 2 +- .../props/definitions/prop_tree_3.tres | 2 +- .../props/definitions/prop_tree_large.tres | 2 +- world/generation/terrain_prop_catalog.gd | 7 +- world/generation/terrain_prop_definition.gd | 31 ++- 18 files changed, 446 insertions(+), 71 deletions(-) create mode 100644 world/generation/mesh_surface_anchor_sampler.gd create mode 100644 world/generation/mesh_surface_anchor_sampler.gd.uid diff --git a/gathering/catalog/beetle_stag_common.tres b/gathering/catalog/beetle_stag_common.tres index 118d733..65b15df 100644 --- a/gathering/catalog/beetle_stag_common.tres +++ b/gathering/catalog/beetle_stag_common.tres @@ -10,6 +10,8 @@ catch_data = ExtResource("2_catch") required_tool_id = &"crab_net" spawn_anchor_set_id = &"starter_reachable_tree_trunks" population = 3 +spawn_anchor_occupancy_ratio = 0.35 +maximum_anchor_population = 64 requires_sneaking = false movement_speed = 0.0 roam_radius = 0.1 diff --git a/gathering/gatherable_data.gd b/gathering/gatherable_data.gd index 9f984cf..d60dcc9 100644 --- a/gathering/gatherable_data.gd +++ b/gathering/gatherable_data.gd @@ -18,6 +18,11 @@ enum PresentationMode { @export var spawn_anchor_set_id: StringName @export_range(-100.0, 100.0, 0.01) var minimum_surface_y: float = 0.08 @export_range(0, 64, 1) var population: int = 0 +## Anchored gatherables can scale with authored/generated attachment geometry. +## Zero preserves the fixed population above. +@export_range(0.0, 1.0, 0.01) var spawn_anchor_occupancy_ratio := 0.0 +## Zero leaves the anchor count as the only ceiling. +@export_range(0, 256, 1) var maximum_anchor_population := 0 @export var presentation_mode: PresentationMode = PresentationMode.VISIBLE_CREATURE @export var requires_sneaking: bool = true @export_range(0.0, 120.0, 0.1) var active_lifetime_seconds: float = 0.0 @@ -78,6 +83,10 @@ func is_valid() -> bool: == FishDataType.CollectionMethod.DIGGING ) and population > 0 + and ( + maximum_anchor_population == 0 + or maximum_anchor_population >= population + ) and movement_parameters_valid and _quality_multipliers_are_valid( quality_movement_speed_multipliers @@ -102,6 +111,21 @@ func is_stationary_spawn() -> bool: return is_stationary_hotspot() or not spawn_anchor_set_id.is_empty() +func target_population_for_anchor_count(anchor_count: int) -> int: + if spawn_anchor_set_id.is_empty() or spawn_anchor_occupancy_ratio <= 0.0: + return population + if anchor_count <= 0: + return 0 + var target := maxi( + population, + ceili(float(anchor_count) * spawn_anchor_occupancy_ratio), + ) + target = mini(target, anchor_count) + if maximum_anchor_population > 0: + target = mini(target, maximum_anchor_population) + return target + + func can_be_scared() -> bool: return not is_stationary_spawn() and scare_radius > 0.0 diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd index 87814ee..b27dd80 100644 --- a/network/network_world_spawn_service.gd +++ b/network/network_world_spawn_service.gd @@ -294,7 +294,7 @@ func _begin_population_if_ready() -> void: _current_season() ): _cache_spawn_surface(entry) - for _spawn_index: int in entry.population: + for _spawn_index: int in _target_population(entry): _spawn_entity(entry) @@ -1383,11 +1383,22 @@ func _reconcile_seasonal_population() -> void: for entry: GatherableDataType in _catalog.get_available_entries(season): _cache_spawn_surface(entry) var current_population: int = _population_for_type(entry.type_id) - while current_population < entry.population: + var target_population := _target_population(entry) + while current_population < target_population: _spawn_entity(entry) current_population += 1 +func _target_population(entry: GatherableDataType) -> int: + if entry == null or entry.spawn_anchor_set_id.is_empty(): + return entry.population if entry != null else 0 + var anchors: PackedVector3Array = _spawn_anchor_positions.get( + entry.type_id, + PackedVector3Array(), + ) + return entry.target_population_for_anchor_count(anchors.size()) + + func _population_for_type(type_id: StringName) -> int: var count: int = 0 for state: Dictionary in _entities.values(): diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 64b6ee1..6e03df7 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -1131,49 +1131,36 @@ func _validate_tree_gatherable_anchors( decorations: Node3D, anchors: GatherableAnchorSet3D, ) -> void: - var eligible_props: Array[Node3D] = [] + var eligible_props: Dictionary[StringName, Node3D] = {} for child: Node in decorations.get_children(): var prop := child as Node3D if prop == null: continue var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - if definition != null and definition.gatherable_anchor_height > 0.0: - eligible_props.append(prop) + if definition != null and definition.has_gatherable_surface(): + eligible_props[prop.name] = prop var positions := anchors.get_spawn_positions() assert(positions.size() == eligible_props.size()) - for prop: Node3D in eligible_props: + assert(positions.size() >= 12) + for child: Node in anchors.get_children(): + var anchor := child as Marker3D + assert(anchor != null) + assert(bool(anchor.get_meta(&"mesh_surface_sampled", false))) + var prop_name := StringName(anchor.get_meta(&"terrain_prop_name", &"")) + assert(eligible_props.has(prop_name)) + var prop: Node3D = eligible_props[prop_name] var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - var visual_scale := float( - prop.get_meta(&"terrain_prop_visual_scale", 1.0) - ) - var nearest_anchor := Vector3(INF, INF, INF) - var nearest_distance_squared := INF - for position: Vector3 in positions: - var distance_squared := position.distance_squared_to( - prop.global_position - ) - if distance_squared < nearest_distance_squared: - nearest_distance_squared = distance_squared - nearest_anchor = position - assert(nearest_anchor.is_finite()) - var horizontal_distance := Vector2( - nearest_anchor.x - prop.global_position.x, - nearest_anchor.z - prop.global_position.z, - ).length() + assert(definition != null and definition.has_gatherable_surface()) + var local_anchor := prop.to_local(anchor.global_position) assert( - horizontal_distance - >= definition.gatherable_anchor_surface_radius() * visual_scale + local_anchor.y + >= definition.gatherable_surface_minimum_height - 0.001 ) assert( - absf( - nearest_anchor.y - - ( - prop.global_position.y - + definition.gatherable_anchor_height * visual_scale - ) - ) <= 0.001 + local_anchor.y + <= definition.gatherable_surface_maximum_height + 0.001 ) diff --git a/tests/tree_gathering_prototype_validation.gd b/tests/tree_gathering_prototype_validation.gd index d97f67e..bb6d1df 100644 --- a/tests/tree_gathering_prototype_validation.gd +++ b/tests/tree_gathering_prototype_validation.gd @@ -7,6 +7,9 @@ const Gatherables: GatherableCatalog = preload( "res://gathering/catalog/gatherable_catalog.tres" ) const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres") +const MeshSurfaceAnchorSamplerType = preload( + "res://world/generation/mesh_surface_anchor_sampler.gd" +) func _initialize() -> void: @@ -15,6 +18,7 @@ func _initialize() -> void: func _run() -> void: _validate_beetle_data() + await _validate_mesh_surface_sampler() await _validate_tree_anchors() _validate_anchored_presentation() _validate_three_dimensional_targeting() @@ -32,11 +36,74 @@ func _validate_beetle_data() -> void: assert(beetle.required_tool_id == &"crab_net") assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") assert(beetle.population == 3) + assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35)) + assert(beetle.maximum_anchor_population == 64) + assert(beetle.target_population_for_anchor_count(8) == 3) + assert(beetle.target_population_for_anchor_count(40) == 14) + assert(beetle.target_population_for_anchor_count(400) == 64) assert(is_equal_approx(beetle.sprite_pixel_size, 0.005)) assert(beetle.is_stationary_spawn()) assert(not beetle.can_be_scared()) +func _validate_mesh_surface_sampler() -> void: + var prop := Node3D.new() + # The accessibility band is local to the planted prop, so the same tree on + # a raised cliff remains reachable from that cliff's walkable surface. + prop.position = Vector3(3.0, 12.0, -4.0) + root.add_child(prop) + var visual := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(2.0, 4.0, 2.0) + var wood := StandardMaterial3D.new() + wood.resource_name = "wood" + box.material = wood + visual.mesh = box + visual.position.y = 2.0 + prop.add_child(visual) + await process_frame + var random := RandomNumberGenerator.new() + random.seed = 115 + var sample: Dictionary = MeshSurfaceAnchorSamplerType.sample_vertical_surface( + prop, + prop, + PackedStringArray(["wood"]), + 0.7, + 1.5, + 0.35, + 0.025, + random, + ) + assert(not sample.is_empty()) + var surface_position: Vector3 = sample["surface_position"] + var anchor_position: Vector3 = sample["position"] + assert(surface_position.y >= 0.7 and surface_position.y <= 1.5) + var world_anchor_position := prop.to_global(anchor_position) + assert( + world_anchor_position.y >= 12.7 + and world_anchor_position.y <= 13.5 + ) + assert(is_equal_approx( + maxf(absf(surface_position.x), absf(surface_position.z)), + 1.0, + )) + assert(is_equal_approx( + anchor_position.distance_to(surface_position), + 0.025, + )) + assert(MeshSurfaceAnchorSamplerType.sample_vertical_surface( + prop, + prop, + PackedStringArray(["leaf"]), + 0.7, + 1.5, + 0.35, + 0.025, + random, + ).is_empty()) + prop.queue_free() + + func _validate_tree_anchors() -> void: var region := StarterIslandScene.instantiate() as WorldRegion root.add_child(region) diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index f529389..29f7fde 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -73,6 +73,8 @@ func _validate_catalog_statuses() -> void: assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET) assert(beetle.required_tool_id == &"crab_net") assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") + assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35)) + assert(beetle.maximum_anchor_population == 64) assert(beetle.is_stationary_spawn()) assert(not beetle.is_stationary_hotspot()) assert(not beetle.requires_sneaking) diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 5adf173..06406de 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -9,6 +9,9 @@ const FishingShopInteractionType = preload( const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) +const MeshSurfaceAnchorSamplerType = preload( + "res://world/generation/mesh_surface_anchor_sampler.gd" +) const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn") const SALT_WATER_MATERIAL: Material = preload( "res://world/materials/stylized_water.tres" @@ -35,7 +38,6 @@ const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10 const PROP_MINIMUM_GROUND_CLEARANCE := 0.05 const PROP_CHANCE_SCALE := 10000 const PROP_SELECTION_WEIGHT_SCALE := 1000 -const GATHERABLE_ANCHOR_SURFACE_CLEARANCE := 0.02 const PROCEDURAL_PROP_GROUPS: Array[StringName] = [ &"grass_tree", &"grass_detail", @@ -681,25 +683,37 @@ func _instantiate_prop( definition.clearance_radius * visual_scale ) _placed_prop_groups.append(definition.procedural_group) - if definition.gatherable_anchor_height > 0.0: - var anchor := Marker3D.new() - anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count() - var local_anchor_position := ( - definition.collision_offset * visual_scale - + Vector3( - 0.0, - definition.gatherable_anchor_height * visual_scale, - -( - definition.gatherable_anchor_surface_radius() - * visual_scale - + GATHERABLE_ANCHOR_SURFACE_CLEARANCE - ), + if definition.has_gatherable_surface(): + var surface_sample: Dictionary = ( + MeshSurfaceAnchorSamplerType.sample_vertical_surface( + visual_root, + prop, + definition.gatherable_surface_material_names, + definition.gatherable_surface_minimum_height, + definition.gatherable_surface_maximum_height, + definition.gatherable_surface_maximum_up_dot, + definition.gatherable_surface_clearance, + random, ) ) - anchor.position = prop.position + local_anchor_position.rotated( - Vector3.UP, - yaw, + if surface_sample.is_empty(): + push_warning( + "No reachable gatherable mesh surface found on %s." + % definition.stable_id + ) + return true + var anchor := Marker3D.new() + anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count() + var local_anchor_position: Vector3 = surface_sample["position"] + anchor.position = _tree_anchors.to_local( + prop.to_global(local_anchor_position) ) + anchor.set_meta( + &"terrain_prop_id", + definition.stable_id, + ) + anchor.set_meta(&"terrain_prop_name", prop.name) + anchor.set_meta(&"mesh_surface_sampled", true) _tree_anchors.add_child(anchor) return true diff --git a/world/generation/mesh_surface_anchor_sampler.gd b/world/generation/mesh_surface_anchor_sampler.gd new file mode 100644 index 0000000..3f41f56 --- /dev/null +++ b/world/generation/mesh_surface_anchor_sampler.gd @@ -0,0 +1,260 @@ +class_name MeshSurfaceAnchorSampler +extends RefCounted + +const HEIGHT_EPSILON := 0.0001 + + +static func sample_vertical_surface( + visual_root: Node3D, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + clearance: float, + random: RandomNumberGenerator, +) -> Dictionary: + if ( + visual_root == null + or relative_root == null + or material_names.is_empty() + or maximum_height <= minimum_height + or random == null + ): + return {} + var candidates: Array[Dictionary] = [] + _collect_candidates( + visual_root, + relative_root, + material_names, + minimum_height, + maximum_height, + clampf(maximum_up_dot, 0.0, 1.0), + candidates, + ) + while not candidates.is_empty(): + var candidate_index := _weighted_candidate_index(candidates, random) + var candidate: Dictionary = candidates[candidate_index] + candidates.remove_at(candidate_index) + var point := _sample_triangle_height_slice( + candidate["a"], + candidate["b"], + candidate["c"], + minimum_height, + maximum_height, + random, + ) + if not point.is_finite(): + continue + var normal: Vector3 = candidate["normal"] + var surface_normal := Vector3(normal.x, 0.0, normal.z).normalized() + if surface_normal.is_zero_approx(): + continue + return { + "position": point + surface_normal * maxf(clearance, 0.0), + "surface_position": point, + "surface_normal": surface_normal, + } + return {} + + +static func _collect_candidates( + node: Node, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var mesh_instance := node as MeshInstance3D + if mesh_instance != null and mesh_instance.mesh != null: + _collect_mesh_candidates( + mesh_instance, + relative_root, + material_names, + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + for child: Node in node.get_children(): + _collect_candidates( + child, + relative_root, + material_names, + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + + +static func _collect_mesh_candidates( + mesh_instance: MeshInstance3D, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var mesh := mesh_instance.mesh + var to_relative := ( + relative_root.global_transform.affine_inverse() + * mesh_instance.global_transform + ) + for surface_index: int in mesh.get_surface_count(): + if ( + mesh is ArrayMesh + and (mesh as ArrayMesh).surface_get_primitive_type(surface_index) + != Mesh.PRIMITIVE_TRIANGLES + ): + continue + var material := mesh.surface_get_material(surface_index) + if not _material_matches(material, material_names): + continue + var arrays := mesh.surface_get_arrays(surface_index) + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + if indices.is_empty(): + for vertex_index: int in range(0, vertices.size() - 2, 3): + _add_triangle_candidate( + to_relative * vertices[vertex_index], + to_relative * vertices[vertex_index + 1], + to_relative * vertices[vertex_index + 2], + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + continue + for index_offset: int in range(0, indices.size() - 2, 3): + _add_triangle_candidate( + to_relative * vertices[indices[index_offset]], + to_relative * vertices[indices[index_offset + 1]], + to_relative * vertices[indices[index_offset + 2]], + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + + +static func _material_matches( + material: Material, + material_names: PackedStringArray, +) -> bool: + if material == null: + return false + var candidate_name := material.resource_name.to_lower() + for configured_name: String in material_names: + if candidate_name == configured_name.to_lower(): + return true + return false + + +static func _add_triangle_candidate( + a: Vector3, + b: Vector3, + c: Vector3, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var cross := (b - a).cross(c - a) + var doubled_area := cross.length() + if doubled_area <= HEIGHT_EPSILON: + return + var normal := cross / doubled_area + if absf(normal.dot(Vector3.UP)) > maximum_up_dot: + return + var triangle_minimum := minf(a.y, minf(b.y, c.y)) + var triangle_maximum := maxf(a.y, maxf(b.y, c.y)) + var overlap := ( + minf(triangle_maximum, maximum_height) + - maxf(triangle_minimum, minimum_height) + ) + if overlap <= HEIGHT_EPSILON: + return + candidates.append({ + "a": a, + "b": b, + "c": c, + "normal": normal, + "weight": doubled_area * 0.5 * overlap, + }) + + +static func _weighted_candidate_index( + candidates: Array[Dictionary], + random: RandomNumberGenerator, +) -> int: + var total_weight := 0.0 + for candidate: Dictionary in candidates: + total_weight += float(candidate.get("weight", 0.0)) + if total_weight <= 0.0: + return random.randi_range(0, candidates.size() - 1) + var roll := random.randf() * total_weight + var cumulative := 0.0 + for index: int in candidates.size(): + cumulative += float(candidates[index].get("weight", 0.0)) + if roll <= cumulative: + return index + return candidates.size() - 1 + + +static func _sample_triangle_height_slice( + a: Vector3, + b: Vector3, + c: Vector3, + minimum_height: float, + maximum_height: float, + random: RandomNumberGenerator, +) -> Vector3: + var slice_minimum := maxf(minimum_height, minf(a.y, minf(b.y, c.y))) + var slice_maximum := minf(maximum_height, maxf(a.y, maxf(b.y, c.y))) + if slice_maximum - slice_minimum <= HEIGHT_EPSILON: + return Vector3(INF, INF, INF) + var target_height := random.randf_range(slice_minimum, slice_maximum) + var intersections := PackedVector3Array() + _append_edge_intersection(a, b, target_height, intersections) + _append_edge_intersection(b, c, target_height, intersections) + _append_edge_intersection(c, a, target_height, intersections) + if intersections.size() < 2: + return Vector3(INF, INF, INF) + var first := intersections[0] + var second := intersections[1] + var greatest_distance := first.distance_squared_to(second) + for first_index: int in intersections.size(): + for second_index: int in range(first_index + 1, intersections.size()): + var distance := intersections[first_index].distance_squared_to( + intersections[second_index] + ) + if distance > greatest_distance: + greatest_distance = distance + first = intersections[first_index] + second = intersections[second_index] + return first.lerp(second, random.randf()) + + +static func _append_edge_intersection( + a: Vector3, + b: Vector3, + height: float, + intersections: PackedVector3Array, +) -> void: + var minimum := minf(a.y, b.y) + var maximum := maxf(a.y, b.y) + if height < minimum - HEIGHT_EPSILON or height > maximum + HEIGHT_EPSILON: + return + var height_delta := b.y - a.y + if absf(height_delta) <= HEIGHT_EPSILON: + return + var weight := clampf((height - a.y) / height_delta, 0.0, 1.0) + var point := a.lerp(b, weight) + for existing: Vector3 in intersections: + if existing.distance_squared_to(point) <= HEIGHT_EPSILON * HEIGHT_EPSILON: + return + intersections.append(point) diff --git a/world/generation/mesh_surface_anchor_sampler.gd.uid b/world/generation/mesh_surface_anchor_sampler.gd.uid new file mode 100644 index 0000000..a56caf8 --- /dev/null +++ b/world/generation/mesh_surface_anchor_sampler.gd.uid @@ -0,0 +1 @@ +uid://dlen7b42nxpjv diff --git a/world/generation/props/definitions/prop_palm.tres b/world/generation/props/definitions/prop_palm.tres index 4d1d344..e586f3d 100644 --- a/world/generation/props/definitions/prop_palm.tres +++ b/world/generation/props/definitions/prop_palm.tres @@ -23,3 +23,4 @@ local_overhang_direction = Vector2(-0.883, 0.469) ocean_facing_spread_degrees = 55.0 collision_radius = 0.4 collision_height = 6.0 +gatherable_surface_material_names = PackedStringArray("wood_light") diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 26e9cb0..062dd3b 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -17,4 +17,4 @@ minimum_visual_scale = 0.65 maximum_visual_scale = 1.2 collision_radius = 0.5 collision_height = 4.0 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres index 47c8577..5f5d584 100644 --- a/world/generation/props/definitions/prop_pine_large.tres +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -17,4 +17,4 @@ minimum_visual_scale = 0.75 maximum_visual_scale = 1.2 collision_radius = 0.65 collision_height = 9.5 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_tree_1.tres b/world/generation/props/definitions/prop_tree_1.tres index bb5d2af..3553988 100644 --- a/world/generation/props/definitions/prop_tree_1.tres +++ b/world/generation/props/definitions/prop_tree_1.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.4 collision_height = 3.2 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_2.tres b/world/generation/props/definitions/prop_tree_2.tres index a6931be..868e044 100644 --- a/world/generation/props/definitions/prop_tree_2.tres +++ b/world/generation/props/definitions/prop_tree_2.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.5 collision_height = 3.8 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_3.tres b/world/generation/props/definitions/prop_tree_3.tres index 382117e..58e3549 100644 --- a/world/generation/props/definitions/prop_tree_3.tres +++ b/world/generation/props/definitions/prop_tree_3.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.55 collision_height = 4.2 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_large.tres b/world/generation/props/definitions/prop_tree_large.tres index ebf6f2f..9092ad1 100644 --- a/world/generation/props/definitions/prop_tree_large.tres +++ b/world/generation/props/definitions/prop_tree_large.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.85 collision_height = 7.5 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/terrain_prop_catalog.gd b/world/generation/terrain_prop_catalog.gd index f475563..ed37c68 100644 --- a/world/generation/terrain_prop_catalog.gd +++ b/world/generation/terrain_prop_catalog.gd @@ -74,11 +74,12 @@ func validation_errors() -> PackedStringArray: % definition.stable_id ) if ( - definition.gatherable_anchor_height > 0.0 - and definition.gatherable_anchor_surface_radius() <= 0.0 + not definition.gatherable_surface_material_names.is_empty() + and definition.gatherable_surface_maximum_height + <= definition.gatherable_surface_minimum_height ): errors.append( - "%s is gatherable but has no trunk-surface radius." + "%s has an invalid gatherable-surface height band." % definition.stable_id ) if ( diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index 1479371..58fdddd 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -47,11 +47,18 @@ extends Resource @export_range(0.0, 20.0, 0.05) var collision_height := 0.0 @export var collision_box_size := Vector3.ZERO @export var collision_offset := Vector3.ZERO -## Values above zero add this prop to the tree-gathering anchor set. -@export_range(0.0, 20.0, 0.05) var gatherable_anchor_height := 0.0 -## Optional distance from the prop origin to the visible trunk surface. A -## zero value derives the distance from the authored collision shape. -@export_range(0.0, 5.0, 0.05) var gatherable_anchor_radius := 0.0 +@export_category("Gatherable Surface") +## Non-empty values explicitly designate this prop's matching mesh surfaces as +## valid attachment geometry. Unlisted props and materials are never sampled. +@export var gatherable_surface_material_names := PackedStringArray() +## Accessibility band measured upward from this prop's planted origin. It +## follows the tree onto hills/cliffs while keeping anchors within net reach. +@export_range(0.0, 20.0, 0.05) var gatherable_surface_minimum_height := 0.7 +@export_range(0.0, 20.0, 0.05) var gatherable_surface_maximum_height := 1.5 +## Reject upward-facing branches and foliage so attachments favor trunk-like +## faces. Zero accepts only vertical faces; one accepts every orientation. +@export_range(0.0, 1.0, 0.05) var gatherable_surface_maximum_up_dot := 0.35 +@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.025 func supports_chunk_tags(chunk_tags: PackedStringArray) -> bool: @@ -77,14 +84,12 @@ func has_box_collision() -> bool: ) -func gatherable_anchor_surface_radius() -> float: - if gatherable_anchor_radius > 0.0: - return gatherable_anchor_radius - if has_cylinder_collision(): - return collision_radius - if has_box_collision(): - return maxf(collision_box_size.x, collision_box_size.z) * 0.5 - return 0.0 +func has_gatherable_surface() -> bool: + return ( + not gatherable_surface_material_names.is_empty() + and gatherable_surface_maximum_height + > gatherable_surface_minimum_height + ) func is_procedural() -> bool: