DiveSite.configure() builds the height field once (TerrainHeightField validates the data) instead of running the definition validation and then the field construction again; the composition root relies on the error returned by configure(). The collision shape takes the field's row-major elevations directly instead of copying 263k cells one by one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.6 KiB
GDScript
203 lines
8.6 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 := 39
|
|
|
|
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(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)
|