diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd index b27dd80..a45ad37 100644 --- a/network/network_world_spawn_service.gd +++ b/network/network_world_spawn_service.gd @@ -518,7 +518,12 @@ func _sample_surface_position( ) if triangles.is_empty() or total_area <= 0.0: return Vector3(INF, INF, INF) - var attempts: int = 48 if is_finite(maximum_distance) else 1 + var validates_diggable_surface := not entry.diggable_area_id.is_empty() + var attempts: int = ( + 48 + if validates_diggable_surface or is_finite(maximum_distance) + else 1 + ) var fallback: Vector3 = Vector3(INF, INF, INF) for _attempt: int in attempts: var roll: float = _rng.randf() * total_area @@ -534,12 +539,22 @@ func _sample_surface_position( + triangle[2] * barycentric_c ) fallback = point - if ( + var within_requested_distance := ( not is_finite(maximum_distance) or Vector2(point.x - origin.x, point.z - origin.z).length() <= maximum_distance - ): + ) + var valid_diggable_surface := ( + not validates_diggable_surface + or _world.is_diggable_surface_point_valid( + entry.diggable_area_id, + point, + ) + ) + if within_requested_distance and valid_diggable_surface: return point + if validates_diggable_surface: + return Vector3(INF, INF, INF) if origin.is_finite() and is_finite(maximum_distance): return origin return fallback diff --git a/tests/digging_prototype_validation.gd b/tests/digging_prototype_validation.gd index 21f8629..95917a0 100644 --- a/tests/digging_prototype_validation.gd +++ b/tests/digging_prototype_validation.gd @@ -14,11 +14,11 @@ const Gatherables: GatherableCatalog = preload( class PrimaryTerrainProvider: extends Node3D - var primary_mesh: MeshInstance3D + var primary_meshes: Array[MeshInstance3D] = [] func get_primary_terrain_meshes() -> Array[MeshInstance3D]: - return [primary_mesh] if primary_mesh != null else [] + return primary_meshes func _initialize() -> void: @@ -98,9 +98,17 @@ func _validate_primary_terrain_provider() -> void: var provider := PrimaryTerrainProvider.new() provider.name = "Provider" fixture.add_child(provider) - provider.primary_mesh = _flat_sand_triangle(0.0) - provider.add_child(provider.primary_mesh) - var buried_base := _flat_sand_triangle(10.0) + var clear_sand := _flat_surface_triangle(0.0, 0.0, &"sand") + var covered_sand := _flat_surface_triangle(10.0, 0.0, &"sand") + var covering_grass := _flat_surface_triangle(10.0, 0.01, &"grass_lite") + for mesh_instance: MeshInstance3D in [ + clear_sand, + covered_sand, + covering_grass, + ]: + provider.add_child(mesh_instance) + provider.primary_meshes.append(mesh_instance) + var buried_base := _flat_surface_triangle(20.0, 0.0, &"sand") buried_base.name = "BuriedSandBase" provider.add_child(buried_base) var area := DiggableArea3D.new() @@ -111,28 +119,34 @@ func _validate_primary_terrain_provider() -> void: area.generation_bounds = Rect2(-20.0, -20.0, 40.0, 40.0) fixture.add_child(area) var triangles := area.get_surface_triangles() - assert(triangles.size() == 1) - var center := (triangles[0][0] + triangles[0][1] + triangles[0][2]) / 3.0 - assert(center.x < 2.0) + assert(triangles.size() == 2) + var clear_center := Vector3(1.0 / 3.0, 0.0, 1.0 / 3.0) + var covered_center := Vector3(10.0 + 1.0 / 3.0, 0.0, 1.0 / 3.0) + assert(area.is_surface_point_valid(clear_center)) + assert(not area.is_surface_point_valid(covered_center)) fixture.free() -func _flat_sand_triangle(x_offset: float) -> MeshInstance3D: +func _flat_surface_triangle( + x_offset: float, + height: float, + material_name: StringName, +) -> MeshInstance3D: var arrays: Array = [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array([ - Vector3(x_offset, 0.0, 0.0), - Vector3(x_offset, 0.0, 1.0), - Vector3(x_offset + 1.0, 0.0, 0.0), + Vector3(x_offset, height, 0.0), + Vector3(x_offset, height, 1.0), + Vector3(x_offset + 1.0, height, 0.0), ]) arrays[Mesh.ARRAY_INDEX] = PackedInt32Array([0, 1, 2]) var mesh := ArrayMesh.new() mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) var material := StandardMaterial3D.new() - material.resource_name = "sand" + material.resource_name = str(material_name) mesh.surface_set_material(0, material) var mesh_instance := MeshInstance3D.new() - mesh_instance.name = "PrimarySand" + mesh_instance.name = "Primary%s" % str(material_name).capitalize() mesh_instance.mesh = mesh return mesh_instance diff --git a/world/digging/diggable_area_3d.gd b/world/digging/diggable_area_3d.gd index 47270b1..4290b44 100644 --- a/world/digging/diggable_area_3d.gd +++ b/world/digging/diggable_area_3d.gd @@ -2,6 +2,10 @@ class_name DiggableArea3D extends Node3D +const SURFACE_HEIGHT_TOLERANCE := 0.015 +const SURFACE_BOUNDS_TOLERANCE := 0.001 +const MINIMUM_VALIDATION_UP_DOT := 0.01 + @export var area_id: StringName @export_node_path("Node3D") var terrain_source: NodePath @export var surface_materials: Array[StringName] = [] @@ -10,9 +14,14 @@ extends Node3D @export_range(-100.0, 100.0, 0.01) var maximum_global_y: float = 100.0 @export_range(0.0, 1.0, 0.01) var minimum_up_dot: float = 0.6 +var _surface_validation_groups: Array[Dictionary] = [] +var _surface_validation_ready := false + func get_surface_triangles() -> Array[PackedVector3Array]: var triangles: Array[PackedVector3Array] = [] + _surface_validation_groups.clear() + _surface_validation_ready = false if area_id.is_empty() or surface_materials.is_empty(): return triangles var terrain_root: Node = get_node_or_null(terrain_source) @@ -22,25 +31,90 @@ func get_surface_triangles() -> Array[PackedVector3Array]: var mesh: Mesh = mesh_instance.mesh if mesh == null: continue + var validation_records: Array[Dictionary] = [] + var validation_bounds := Rect2() + var has_validation_bounds := false for surface_index: int in mesh.get_surface_count(): var material: Material = mesh_instance.get_active_material(surface_index) - if ( - material == null - or not surface_materials.has(StringName(material.resource_name)) - ): - continue + var allowed_surface := ( + material != null + and surface_materials.has(StringName(material.resource_name)) + ) _append_surface_triangles( triangles, + validation_records, mesh_instance, mesh.surface_get_arrays(surface_index), + allowed_surface, ) + for record: Dictionary in validation_records: + var record_bounds: Rect2 = record.get("bounds", Rect2()) + if not has_validation_bounds: + validation_bounds = record_bounds + has_validation_bounds = true + else: + validation_bounds = validation_bounds.merge(record_bounds) + if not validation_records.is_empty(): + _surface_validation_groups.append({ + "bounds": validation_bounds, + "records": validation_records, + }) + _surface_validation_ready = true return triangles +func invalidate_surface_cache() -> void: + _surface_validation_groups.clear() + _surface_validation_ready = false + + +func is_surface_point_valid(point: Vector3) -> bool: + if not point.is_finite(): + return false + if not _surface_validation_ready: + get_surface_triangles() + var sample := Vector2(point.x, point.z) + var highest_y := -INF + var highest_is_allowed := false + var found_surface := false + for group: Dictionary in _surface_validation_groups: + var group_bounds: Rect2 = group.get("bounds", Rect2()) + if not group_bounds.grow(SURFACE_BOUNDS_TOLERANCE).has_point(sample): + continue + var records: Array = group.get("records", []) + for record: Dictionary in records: + var record_bounds: Rect2 = record.get("bounds", Rect2()) + if not record_bounds.grow(SURFACE_BOUNDS_TOLERANCE).has_point(sample): + continue + var triangle := record.get( + "triangle", PackedVector3Array() + ) as PackedVector3Array + var surface_y := _surface_height_at_point(triangle, sample) + if not is_finite(surface_y): + continue + var allowed := bool(record.get("allowed", false)) + if surface_y > highest_y + SURFACE_HEIGHT_TOLERANCE: + highest_y = surface_y + highest_is_allowed = allowed + found_surface = true + elif absf(surface_y - highest_y) <= SURFACE_HEIGHT_TOLERANCE: + # Material seams may expose coplanar grass and sand triangles. + # Treat any tied disallowed surface as authoritative so a dig + # hotspot can never appear in visually grassy terrain. + highest_is_allowed = highest_is_allowed and allowed + return ( + found_surface + and highest_is_allowed + and absf(highest_y - point.y) <= SURFACE_HEIGHT_TOLERANCE + ) + + func _append_surface_triangles( result: Array[PackedVector3Array], + validation_records: Array[Dictionary], mesh_instance: MeshInstance3D, arrays: Array, + allowed_surface: bool, ) -> void: if arrays.size() <= Mesh.ARRAY_INDEX: return @@ -52,26 +126,49 @@ func _append_surface_triangles( for vertex_index: int in range(0, vertices.size() - 2, 3): _append_triangle( result, + validation_records, mesh_instance.to_global(vertices[vertex_index]), mesh_instance.to_global(vertices[vertex_index + 1]), mesh_instance.to_global(vertices[vertex_index + 2]), + allowed_surface, ) return for index_offset: int in range(0, indices.size() - 2, 3): _append_triangle( result, + validation_records, mesh_instance.to_global(vertices[indices[index_offset]]), mesh_instance.to_global(vertices[indices[index_offset + 1]]), mesh_instance.to_global(vertices[indices[index_offset + 2]]), + allowed_surface, ) func _append_triangle( result: Array[PackedVector3Array], + validation_records: Array[Dictionary], a: Vector3, b: Vector3, c: Vector3, + allowed_surface: bool, ) -> void: + var cross := (b - a).cross(c - a) + if cross.length_squared() <= 0.0000001: + return + var up_dot := absf(cross.normalized().dot(Vector3.UP)) + var triangle := PackedVector3Array([a, b, c]) + var projected_bounds := _projected_bounds(triangle) + if ( + up_dot >= MINIMUM_VALIDATION_UP_DOT + and generation_bounds.intersects(projected_bounds, true) + ): + validation_records.append({ + "allowed": allowed_surface, + "bounds": projected_bounds, + "triangle": triangle, + }) + if not allowed_surface: + return if ( a.y < minimum_global_y or b.y < minimum_global_y @@ -84,12 +181,56 @@ func _append_triangle( var center := (a + b + c) / 3.0 if not generation_bounds.has_point(Vector2(center.x, center.z)): return - var cross := (b - a).cross(c - a) - if cross.length_squared() <= 0.0000001: + if up_dot < minimum_up_dot: return - if absf(cross.normalized().dot(Vector3.UP)) < minimum_up_dot: - return - result.append(PackedVector3Array([a, b, c])) + result.append(triangle) + + +static func _projected_bounds(triangle: PackedVector3Array) -> Rect2: + var minimum := Vector2(triangle[0].x, triangle[0].z) + var maximum := minimum + for index: int in range(1, triangle.size()): + var point := Vector2(triangle[index].x, triangle[index].z) + minimum = minimum.min(point) + maximum = maximum.max(point) + return Rect2(minimum, maximum - minimum) + + +static func _surface_height_at_point( + triangle: PackedVector3Array, + point: Vector2, +) -> float: + if triangle.size() != 3: + return INF + var a := Vector2(triangle[0].x, triangle[0].z) + var b := Vector2(triangle[1].x, triangle[1].z) + var c := Vector2(triangle[2].x, triangle[2].z) + var denominator := ( + (b.y - c.y) * (a.x - c.x) + + (c.x - b.x) * (a.y - c.y) + ) + if absf(denominator) <= 0.0000001: + return INF + var weight_a := ( + (b.y - c.y) * (point.x - c.x) + + (c.x - b.x) * (point.y - c.y) + ) / denominator + var weight_b := ( + (c.y - a.y) * (point.x - c.x) + + (a.x - c.x) * (point.y - c.y) + ) / denominator + var weight_c := 1.0 - weight_a - weight_b + if ( + weight_a < -SURFACE_BOUNDS_TOLERANCE + or weight_b < -SURFACE_BOUNDS_TOLERANCE + or weight_c < -SURFACE_BOUNDS_TOLERANCE + ): + return INF + return ( + triangle[0].y * weight_a + + triangle[1].y * weight_b + + triangle[2].y * weight_c + ) func _terrain_mesh_instances(terrain_root: Node) -> Array[MeshInstance3D]: diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 8987caf..aa27a65 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -191,6 +191,7 @@ func get_spawn_surface_triangles( func _on_generation_completed(summary: Dictionary) -> void: + _diggable_beach.invalidate_surface_cache() var records: Array[Dictionary] = _generator.placement_records() _assign_biomes(records, summary) var spawn_position := Vector3.ZERO diff --git a/world/test_world.gd b/world/test_world.gd index d5ac83c..d9d18dc 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -207,6 +207,16 @@ func get_diggable_area_triangles( return [] +func is_diggable_surface_point_valid( + area_id: StringName, + point: Vector3, +) -> bool: + if _active_region == null: + return false + var area: DiggableArea3D = _active_region.get_diggable_area(area_id) + return area != null and area.is_surface_point_valid(point) + + func get_gatherable_spawn_positions( anchor_set_id: StringName, ) -> PackedVector3Array: