Tiles holding a slope over 35 degrees are meshed at two vertices per cell on a clamped Catmull-Rom surface that passes through every grid node, so cliffs and ledges lose the facets of the heightfield grid while the collision stays on the grid. The domain field exposes the smooth elevation and a separable, per-phase-weighted block sampler with linear edge extrapolation and a one-cell margin for continuous normals; the actor samples tiles on the worker thread pool and builds meshes afterwards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
248 lines
11 KiB
GDScript
248 lines
11 KiB
GDScript
extends SceneTree
|
|
|
|
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
|
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
|
|
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
|
|
const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
|
|
const SiteRoute = preload("res://game/domain/sites/site_route.gd")
|
|
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
|
|
|
|
const TEST_COUNT := 46
|
|
|
|
var _failures: Array[String] = []
|
|
|
|
|
|
func _init() -> void:
|
|
_test_terrain_data_validation()
|
|
_test_height_field_sampling()
|
|
_test_site_definition_validation()
|
|
|
|
if _failures.is_empty():
|
|
print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT)
|
|
quit(0)
|
|
else:
|
|
for failure: String in _failures:
|
|
push_error(failure)
|
|
print("Site domain unit tests: %d failed." % _failures.size())
|
|
quit(1)
|
|
|
|
|
|
func _make_terrain(columns: int, rows: int) -> SiteTerrainData:
|
|
var data := SiteTerrainData.new()
|
|
data.columns = columns
|
|
data.rows = rows
|
|
data.cell_size_m = 2.0
|
|
data.origin_x_m = -10.0
|
|
data.origin_z_m = 100.0
|
|
# Packed arrays are passed by value in GDScript: build locally, then assign.
|
|
var elevations := PackedFloat32Array()
|
|
var materials := PackedByteArray()
|
|
elevations.resize(columns * rows)
|
|
materials.resize(columns * rows)
|
|
for row in rows:
|
|
for column in columns:
|
|
var index := row * columns + column
|
|
# Plane sloping down toward +Z (south): -20 at row 0, -2 m per row.
|
|
elevations[index] = -20.0 - 2.0 * float(row)
|
|
materials[index] = (
|
|
SonarMaterial.Value.ROCK if column == 0 else SonarMaterial.Value.SAND
|
|
)
|
|
data.elevations_m = elevations
|
|
data.materials = materials
|
|
return data
|
|
|
|
|
|
func _test_terrain_data_validation() -> void:
|
|
var data := _make_terrain(3, 4)
|
|
_expect(data.validation_error().is_empty(), "regular terrain data is valid")
|
|
_expect(data.index_of(2, 3) == 11, "index is row-major with columns along X")
|
|
|
|
var tiny := _make_terrain(1, 4)
|
|
_expect(not tiny.validation_error().is_empty(), "terrain needs at least two columns")
|
|
|
|
var mismatched := _make_terrain(3, 4)
|
|
mismatched.elevations_m.resize(5)
|
|
_expect(not mismatched.validation_error().is_empty(), "elevation array must match the grid")
|
|
|
|
var non_finite := _make_terrain(3, 4)
|
|
non_finite.elevations_m[0] = INF
|
|
_expect(not non_finite.validation_error().is_empty(), "elevations must be finite")
|
|
|
|
var metal_terrain := _make_terrain(3, 4)
|
|
metal_terrain.materials[1] = SonarMaterial.Value.METAL
|
|
_expect(not metal_terrain.validation_error().is_empty(), "terrain cells are rock or sand only")
|
|
|
|
var bad_cell := _make_terrain(3, 4)
|
|
bad_cell.cell_size_m = 0.0
|
|
_expect(not bad_cell.validation_error().is_empty(), "cell size must be positive")
|
|
|
|
var bad_reflectivity := _make_terrain(3, 4)
|
|
bad_reflectivity.sand_reflectivity = 1.5
|
|
_expect(not bad_reflectivity.validation_error().is_empty(), "reflectivity stays within 0..1")
|
|
_expect(TerrainHeightField.from_data(bad_reflectivity) == null, "invalid data yields no field")
|
|
|
|
|
|
func _test_height_field_sampling() -> void:
|
|
var data := _make_terrain(3, 4)
|
|
var field := TerrainHeightField.from_data(data)
|
|
_expect(field != null, "valid data yields a field")
|
|
if field == null:
|
|
return
|
|
_expect(field.elevations_m() == data.elevations_m, "elevations are exposed in row-major data order")
|
|
_expect(field.bounds_m().is_equal_approx(Rect2(-10.0, 100.0, 4.0, 6.0)), "bounds cover (columns-1) by (rows-1) cells")
|
|
_expect(field.cell_world_position(2, 3).is_equal_approx(Vector3(-6.0, -26.0, 106.0)), "cell position follows origin and cell size")
|
|
_expect(is_equal_approx(field.elevation_at(-10.0, 100.0), -20.0), "elevation at a vertex is exact")
|
|
_expect(is_equal_approx(field.elevation_at(-9.0, 101.0), -21.0), "elevation is bilinear between vertices")
|
|
_expect(is_equal_approx(field.elevation_at(-50.0, 1000.0), -26.0), "sampling outside clamps to the nearest edge")
|
|
_expect(field.material_at(-10.4, 100.0) == SonarMaterial.Value.ROCK, "material is the nearest cell")
|
|
_expect(field.material_at(-6.0, 103.0) == SonarMaterial.Value.SAND, "material of an interior cell")
|
|
_expect(field.normal_at_cell(1, 1).is_equal_approx(Vector3(0.0, 1.0, 1.0).normalized()), "normal leans against the downhill direction")
|
|
_expect(is_equal_approx(field.smooth_elevation_at(-10.0, 100.0), field.elevation_at(-10.0, 100.0)), "smooth elevation passes through the grid nodes")
|
|
var plane_data := SiteTerrainData.new()
|
|
plane_data.columns = 7
|
|
plane_data.rows = 6
|
|
plane_data.cell_size_m = 2.0
|
|
plane_data.origin_x_m = 0.0
|
|
plane_data.origin_z_m = 0.0
|
|
var plane_elevations := PackedFloat32Array()
|
|
var plane_materials := PackedByteArray()
|
|
for row in 6:
|
|
for column in 7:
|
|
plane_elevations.append(-20.0 + 0.5 * float(column) * 2.0 - 0.25 * float(row) * 2.0)
|
|
plane_materials.append(SonarMaterial.Value.SAND)
|
|
plane_data.elevations_m = plane_elevations
|
|
plane_data.materials = plane_materials
|
|
var plane := TerrainHeightField.from_data(plane_data)
|
|
_expect(
|
|
is_equal_approx(plane.smooth_elevation_at(5.0, 3.0), -20.0 + 2.5 - 0.75)
|
|
and is_equal_approx(plane.smooth_elevation_at(0.5, 0.5), -20.0 + 0.25 - 0.125),
|
|
"smooth elevation reproduces a plane between nodes, edges included"
|
|
)
|
|
var fine := field.cell_block_positions_subdivided(1, 2, 0, 3, 2)
|
|
var fine_normals := field.cell_block_normals_subdivided(1, 2, 0, 3, 2)
|
|
_expect(fine.size() == 3 * 7 and fine_normals.size() == 3 * 7, "a subdivided block has cells * 2 + 1 points per axis")
|
|
var nodes_match := fine.size() == 21
|
|
for row in 4:
|
|
for column in range(1, 3):
|
|
var index := (row * 2) * 3 + (column - 1) * 2
|
|
if nodes_match and not fine[index].is_equal_approx(field.cell_world_position(column, row)):
|
|
nodes_match = false
|
|
_expect(nodes_match, "subdivided block points on the grid nodes equal the cell positions")
|
|
_expect(fine_normals[0].is_normalized() and fine_normals[10].y > 0.0, "subdivided normals are unit length and point up")
|
|
var block_positions := field.cell_block_positions(1, 2, 0, 3)
|
|
var block_normals := field.cell_block_normals(1, 2, 0, 3)
|
|
var block_matches := block_positions.size() == 8 and block_normals.size() == 8
|
|
var normals_match := block_matches
|
|
for row in 4:
|
|
for column in range(1, 3):
|
|
var index := row * 2 + column - 1
|
|
if block_matches and not block_positions[index].is_equal_approx(field.cell_world_position(column, row)):
|
|
block_matches = false
|
|
if normals_match and not block_normals[index].is_equal_approx(field.normal_at_cell(column, row)):
|
|
normals_match = false
|
|
_expect(block_matches, "cell block positions match cell_world_position, row-major")
|
|
_expect(normals_match, "cell block normals match normal_at_cell, edges included")
|
|
_expect(not field.contains(-11.0, 100.0) and field.contains(-8.0, 103.0), "contains follows the bounds")
|
|
_expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data")
|
|
|
|
|
|
func _make_placement(id: String, kind: int, position: Vector3) -> SitePlacement:
|
|
var placement := SitePlacement.new()
|
|
placement.placement_id = StringName(id)
|
|
placement.kind = kind
|
|
placement.position_m = position
|
|
placement.footprint_m = Vector3(4.0, 2.0, 3.0)
|
|
return placement
|
|
|
|
|
|
func _make_route(id: String, waypoints: PackedVector3Array, maximum_depth: float) -> SiteRoute:
|
|
var route := SiteRoute.new()
|
|
route.route_id = StringName(id)
|
|
route.waypoints_m = waypoints
|
|
route.maximum_depth_m = maximum_depth
|
|
return route
|
|
|
|
|
|
func _make_definition() -> DiveSiteDefinition:
|
|
var definition := DiveSiteDefinition.new()
|
|
definition.site_id = &"test_site"
|
|
definition.display_name = "Site de test"
|
|
definition.terrain = _make_terrain(3, 4)
|
|
definition.playable_bounds_m = Rect2(-10.0, 100.0, 4.0, 6.0)
|
|
definition.spawn_position_m = Vector3(-8.0, -10.0, 102.0)
|
|
definition.spawn_yaw_radians = PI * 0.5
|
|
var placements_list: Array[SitePlacement] = [
|
|
_make_placement("target", SitePlacement.Kind.WRECK_TARGET, Vector3(-8.0, -23.0, 103.0)),
|
|
_make_placement("anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(-8.0, -20.0, 100.5)),
|
|
]
|
|
definition.placements = placements_list
|
|
var routes_list: Array[SiteRoute] = [
|
|
_make_route(
|
|
"safe",
|
|
PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -14.0, 103.0)]),
|
|
20.0
|
|
),
|
|
]
|
|
definition.routes = routes_list
|
|
return definition
|
|
|
|
|
|
func _test_site_definition_validation() -> void:
|
|
var definition := _make_definition()
|
|
_expect(definition.validation_error().is_empty(), "complete definition is valid")
|
|
_expect(definition.height_field() != null, "definition exposes its height field")
|
|
_expect(definition.placement(&"target").kind == SitePlacement.Kind.WRECK_TARGET, "placement lookup by id")
|
|
_expect(definition.placement(&"missing") == null, "unknown placement is null")
|
|
_expect(definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR).size() == 1, "placements filter by kind")
|
|
_expect(definition.route(&"safe").waypoints_m.size() == 2, "route lookup by id")
|
|
_expect(definition.spawn_transform().origin.is_equal_approx(Vector3(-8.0, -10.0, 102.0)), "spawn transform origin")
|
|
_expect(
|
|
(definition.spawn_transform().basis * Vector3.FORWARD).is_equal_approx(Vector3.LEFT),
|
|
"spawn yaw rotates the forward axis about Y"
|
|
)
|
|
_expect(definition.placement(&"target").is_sonar_metal(), "wrecks read as metal on sonar")
|
|
_expect(not definition.placement(&"anchor").is_sonar_metal(), "anchor is not a sonar body")
|
|
|
|
var no_id := _make_definition()
|
|
no_id.site_id = &""
|
|
_expect(not no_id.validation_error().is_empty(), "site id is required")
|
|
|
|
var no_terrain := _make_definition()
|
|
no_terrain.terrain = null
|
|
_expect(not no_terrain.validation_error().is_empty(), "terrain is required")
|
|
|
|
var bounds_outside := _make_definition()
|
|
bounds_outside.playable_bounds_m = Rect2(-20.0, 100.0, 4.0, 6.0)
|
|
_expect(not bounds_outside.validation_error().is_empty(), "playable bounds must lie inside the terrain")
|
|
|
|
var spawn_outside := _make_definition()
|
|
spawn_outside.spawn_position_m = Vector3(50.0, -10.0, 102.0)
|
|
_expect(not spawn_outside.validation_error().is_empty(), "spawn must lie inside the bounds")
|
|
|
|
var duplicate_ids := _make_definition()
|
|
duplicate_ids.placements.append(
|
|
_make_placement("target", SitePlacement.Kind.WRECK_DECOY, Vector3(-7.0, -23.0, 104.0))
|
|
)
|
|
_expect(not duplicate_ids.validation_error().is_empty(), "placement ids are unique")
|
|
|
|
var placement_outside := _make_definition()
|
|
placement_outside.placements.append(
|
|
_make_placement("far", SitePlacement.Kind.DEBRIS, Vector3(80.0, -23.0, 104.0))
|
|
)
|
|
_expect(not placement_outside.validation_error().is_empty(), "placements must lie inside the bounds")
|
|
|
|
var empty_route := _make_definition()
|
|
empty_route.routes.append(_make_route("empty", PackedVector3Array(), 20.0))
|
|
_expect(not empty_route.validation_error().is_empty(), "routes need at least two waypoints")
|
|
|
|
var deep_route := _make_definition()
|
|
deep_route.routes.append(
|
|
_make_route("deep", PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -30.0, 103.0)]), 20.0)
|
|
)
|
|
_expect(not deep_route.validation_error().is_empty(), "route waypoints respect the route maximum depth")
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
if not condition:
|
|
_failures.append(label)
|