Review findings: the mesa forced rock onto the canyon bed, two runners declared the wrong TEST_COUNT, and the escarpment check could pass on the slope alone. Canyon gains floor_material, the showcase is re-baked with sand beds, the contract measures the step across the crest band, counts distinct ledges, and probes the crevasse against the open plateau. Degenerate recipe parameters now fail an assert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
276 lines
12 KiB
GDScript
276 lines
12 KiB
GDScript
extends SceneTree
|
|
|
|
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
|
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
|
|
const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd")
|
|
const BathymetricTerrainActor = preload(
|
|
"res://game/world/sites/bathymetric_terrain_actor.gd"
|
|
)
|
|
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
|
|
const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
|
|
const DiveSite = preload("res://game/world/sites/dive_site.gd")
|
|
const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd")
|
|
const ReferenceBathymetryAcquisition = preload(
|
|
"res://game/world/sonar/reference_bathymetry_acquisition.gd"
|
|
)
|
|
const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn")
|
|
|
|
const ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres"
|
|
const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres"
|
|
const GROOVE_PROBE_OFFSET_M := 25.0
|
|
const TEST_COUNT := 36
|
|
|
|
var _failures: Array[String] = []
|
|
|
|
|
|
func _init() -> void:
|
|
_run.call_deferred()
|
|
|
|
|
|
func _run() -> void:
|
|
await _test_actor_matches_field()
|
|
await _test_dive_site_scene()
|
|
await _test_zone1_site()
|
|
await _test_showcase_site()
|
|
|
|
var exit_code := 0
|
|
if _failures.is_empty():
|
|
print("Site terrain integration: %d passed, 0 failed." % TEST_COUNT)
|
|
else:
|
|
for failure: String in _failures:
|
|
push_error(failure)
|
|
print("Site terrain integration: %d failed." % _failures.size())
|
|
exit_code = 1
|
|
await process_frame
|
|
quit(exit_code)
|
|
|
|
|
|
func _asymmetric_field() -> TerrainHeightField:
|
|
var recipe := SiteTerrainBaker.Recipe.new()
|
|
recipe.columns = 81
|
|
recipe.rows = 65
|
|
recipe.cell_size_m = 2.0
|
|
recipe.origin_x_m = -80.0
|
|
recipe.origin_z_m = -80.0
|
|
recipe.floor_elevation_m = -90.0
|
|
recipe.primitives = [
|
|
SiteTerrainBaker.SlopedPlane.new(40.0, 0.0, 0.0, 0.0, 0.0),
|
|
# Tall bump off the diagonal on a non-square grid: any front/back flip or
|
|
# row/column transposition shows up.
|
|
SiteTerrainBaker.Blob.new(Vector2(-60.0, 20.0), 10.0, 10.0, 18.0),
|
|
]
|
|
return TerrainHeightField.from_data(SiteTerrainBaker.bake(recipe))
|
|
|
|
|
|
func _test_actor_matches_field() -> void:
|
|
var field := _asymmetric_field()
|
|
_expect(field != null, "asymmetric test field bakes")
|
|
if field == null:
|
|
return
|
|
var actor := BathymetricTerrainActor.new()
|
|
root.add_child(actor)
|
|
_expect(actor.configure(field).is_empty(), "actor accepts a valid field")
|
|
await physics_frame
|
|
await physics_frame
|
|
|
|
_expect(actor.tile_count() == 2, "80 x 64 cells split into 2 x 1 tiles of at most 64 cells")
|
|
var first_tile := actor.get_node("Tiles").get_child(0) as MeshInstance3D
|
|
var arrays: Array = first_tile.mesh.surface_get_arrays(0)
|
|
var tile_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
|
var tile_indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
|
var first_face := Plane(
|
|
tile_vertices[tile_indices[0]],
|
|
tile_vertices[tile_indices[1]],
|
|
tile_vertices[tile_indices[2]]
|
|
)
|
|
_expect(first_face.normal.y > 0.0, "tile triangles are wound clockwise from above (front face up)")
|
|
# The first tile spans cells 0..64 on both axes, so vertex index = row * 65 + column.
|
|
var worst_vertex_error := 0.0
|
|
for cell: Vector2i in [Vector2i(0, 0), Vector2i(10, 50), Vector2i(64, 64)]:
|
|
var vertex := first_tile.global_transform * tile_vertices[cell.y * 65 + cell.x]
|
|
worst_vertex_error = maxf(worst_vertex_error, vertex.distance_to(field.cell_world_position(cell.x, cell.y)))
|
|
_expect(worst_vertex_error < 0.01, "tile vertices sit on their field cells (worst %.4f m)" % worst_vertex_error)
|
|
var space := actor.get_world_3d().direct_space_state
|
|
var worst_error := 0.0
|
|
for cell: Vector2i in [
|
|
Vector2i(0, 0), Vector2i(80, 0), Vector2i(0, 64), Vector2i(80, 64),
|
|
Vector2i(10, 50), Vector2i(40, 40), Vector2i(64, 20),
|
|
]:
|
|
var expected := field.cell_world_position(cell.x, cell.y)
|
|
var query := PhysicsRayQueryParameters3D.create(
|
|
Vector3(expected.x, 50.0, expected.z),
|
|
Vector3(expected.x, -200.0, expected.z)
|
|
)
|
|
var hit := space.intersect_ray(query)
|
|
if hit.is_empty():
|
|
_failures.append("collision ray at cell %s hits nothing" % cell)
|
|
continue
|
|
worst_error = maxf(worst_error, absf(float(hit["position"].y) - expected.y))
|
|
_expect(worst_error < 0.05, "collision height equals the field height at vertices (worst %.3f m)" % worst_error)
|
|
|
|
var bump := _collision_height(space, -60.0, 20.0)
|
|
var north_east := _collision_height(space, 60.0, -60.0)
|
|
_expect(bump > -30.0, "south-west bump is where the field puts it (collision)")
|
|
_expect(north_east < -39.0, "north-east stays flat (collision)")
|
|
_expect(absf(bump - field.elevation_at(-60.0, 20.0)) < 0.05, "bump height matches the field")
|
|
var mid_cell := _collision_height(space, -59.0, 21.0)
|
|
_expect(absf(mid_cell - field.elevation_at(-59.0, 21.0)) < 0.3, "collision between vertices follows the field (%.2f m)" % absf(mid_cell - field.elevation_at(-59.0, 21.0)))
|
|
|
|
_expect(actor.sonar_material_at(Vector3(-52.0, -30.0, 20.0)) == SonarMaterial.Value.ROCK, "steep flank reports rock")
|
|
_expect(actor.sonar_material_at(Vector3(40.0, -40.0, 40.0)) == SonarMaterial.Value.SAND, "flat plain reports sand")
|
|
_expect(is_equal_approx(actor.sonar_reflectivity_at(Vector3(40.0, -40.0, 40.0)), 0.45), "sand reflectivity from the data")
|
|
|
|
_expect(actor.configure(field).is_empty() and actor.tile_count() == 2, "reconfiguring rebuilds without duplicating tiles")
|
|
_expect(not actor.configure(null).is_empty(), "null field is refused")
|
|
actor.queue_free()
|
|
|
|
|
|
func _collision_height(space: PhysicsDirectSpaceState3D, x: float, z: float) -> float:
|
|
var hit := space.intersect_ray(
|
|
PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z))
|
|
)
|
|
if hit.is_empty():
|
|
return INF
|
|
return float(hit["position"].y)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
if not condition:
|
|
_failures.append(label)
|
|
|
|
|
|
func _test_dive_site_scene() -> void:
|
|
var definition := load("res://content/sites/vehicle_test.tres") as DiveSiteDefinition
|
|
_expect(definition != null, "calibration site definition loads")
|
|
if definition == null:
|
|
return
|
|
_expect(definition.validation_error().is_empty(), "calibration site definition is valid")
|
|
var started_usec := Time.get_ticks_usec()
|
|
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
|
|
root.add_child(site)
|
|
_expect(site.configure(definition).is_empty(), "dive site scene accepts the definition")
|
|
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
|
|
_expect(configure_ms < 3000.0, "site configures under the 3 s load budget (%.0f ms)" % configure_ms)
|
|
await physics_frame
|
|
await physics_frame
|
|
_expect(site.terrain().tile_count() > 0, "site builds its terrain tiles")
|
|
_expect(site.placeholder_count() == 2, "calibration target and beacon are placed")
|
|
var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D
|
|
_expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body")
|
|
_expect(site.spawn_transform().origin.is_equal_approx(Vector3(0.0, -12.0, 12.0)), "spawn comes from the definition")
|
|
|
|
var sensor := Transform3D(Basis(Vector3.UP, 0.0), Vector3(0.0, -12.0, 10.0))
|
|
sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z)
|
|
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
|
|
var request := SonarCaptureRequest.new(
|
|
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
|
|
)
|
|
var result = acquisition.capture(request)
|
|
_expect(result.accepted, "reference acquisition scans the baked terrain")
|
|
var materials: Dictionary = {}
|
|
for sample in result.samples:
|
|
materials[sample.material] = true
|
|
_expect(materials.has(SonarMaterial.Value.SAND) and materials.has(SonarMaterial.Value.ROCK), "acquisition reports per-cell sand and rock")
|
|
_expect(materials.has(SonarMaterial.Value.METAL), "acquisition still reports the metal placeholder")
|
|
site.queue_free()
|
|
|
|
|
|
func _test_zone1_site() -> void:
|
|
# Let the calibration site queued for deletion leave the physics space first.
|
|
await process_frame
|
|
var definition := load(ZONE1_SITE_PATH) as DiveSiteDefinition
|
|
if definition == null:
|
|
_failures.append("zone 1 site definition loads")
|
|
return
|
|
var started_usec := Time.get_ticks_usec()
|
|
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
|
|
root.add_child(site)
|
|
_expect(site.configure(definition).is_empty(), "dive site scene accepts the Banc du Vigilant")
|
|
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
|
|
_expect(configure_ms < 3000.0, "zone 1 configures under the 3 s load budget (%.0f ms)" % configure_ms)
|
|
await physics_frame
|
|
await physics_frame
|
|
var space := site.get_world_3d().direct_space_state
|
|
var terrain := site.terrain()
|
|
|
|
var hull := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET)[0]
|
|
var hull_body := site.get_node_or_null("%%Placements/%s" % hull.placement_id) as StaticBody3D
|
|
var excluded: Array[RID] = []
|
|
if hull_body != null:
|
|
excluded.append(hull_body.get_rid())
|
|
var under_hull := _terrain_height(space, terrain, hull.position_m.x, hull.position_m.z, excluded)
|
|
_expect(under_hull < hull.position_m.y, "a ray at the target hits the seabed under the hull centre (%.1f m)" % under_hull)
|
|
|
|
var entrance := definition.placement(&"saignee_entrance").position_m
|
|
var groove_floor := _terrain_height(space, terrain, entrance.x, entrance.z, [])
|
|
var beside_groove := _terrain_height(space, terrain, entrance.x + GROOVE_PROBE_OFFSET_M, entrance.z, [])
|
|
_expect(groove_floor < beside_groove, "la Saignee is a trench at its entrance (%.1f m vs %.1f m)" % [groove_floor, beside_groove])
|
|
|
|
var sensor := definition.spawn_transform()
|
|
sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z)
|
|
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
|
|
var request := SonarCaptureRequest.new(
|
|
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
|
|
)
|
|
var result = acquisition.capture(request)
|
|
var has_sand := false
|
|
for sample in result.samples:
|
|
has_sand = has_sand or sample.material == SonarMaterial.Value.SAND
|
|
_expect(result.accepted and has_sand, "a reference scan from the zone 1 spawn reads the sand plateau")
|
|
site.queue_free()
|
|
|
|
|
|
func _test_showcase_site() -> void:
|
|
await process_frame
|
|
var definition := load(SHOWCASE_SITE_PATH) as DiveSiteDefinition
|
|
_expect(definition != null, "showcase site definition loads")
|
|
if definition == null:
|
|
return
|
|
var started_usec := Time.get_ticks_usec()
|
|
var site := DIVE_SITE_SCENE.instantiate() as DiveSite
|
|
root.add_child(site)
|
|
_expect(site.configure(definition).is_empty(), "dive site scene accepts the Jardin des Formes")
|
|
var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0
|
|
_expect(configure_ms < 3000.0, "showcase configures under the 3 s load budget (%.0f ms)" % configure_ms)
|
|
await physics_frame
|
|
await physics_frame
|
|
var space := site.get_world_3d().direct_space_state
|
|
var terrain := site.terrain()
|
|
var canyon_floor := _terrain_height(space, terrain, -130.0, -135.0, [])
|
|
var mesa_top := _terrain_height(space, terrain, -80.0, -90.0, [])
|
|
_expect(mesa_top - canyon_floor >= 18.0, "collision rays find the canyon under the mesa (%.1f m vs %.1f m)" % [canyon_floor, mesa_top])
|
|
|
|
# Sensor above the canyon looking along its second leg (the acquisition probes along
|
|
# local +Z): rock walls and sand floor.
|
|
var sensor := Transform3D(Basis.IDENTITY, Vector3(-130.0, -15.0, -135.0))
|
|
sensor.basis = Basis.looking_at(-Vector3(40.0, 0.0, -30.0).normalized(), Vector3.UP)
|
|
var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d())
|
|
var request := SonarCaptureRequest.new(
|
|
&"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31
|
|
)
|
|
var result = acquisition.capture(request)
|
|
var materials: Dictionary = {}
|
|
for sample in result.samples:
|
|
materials[sample.material] = true
|
|
_expect(
|
|
result.accepted and materials.has(SonarMaterial.Value.ROCK) and materials.has(SonarMaterial.Value.SAND),
|
|
"a scan along the canyon reads rock walls and a sand floor"
|
|
)
|
|
site.queue_free()
|
|
|
|
|
|
## Seabed height under (x, z), or NAN (every comparison fails) when the ray misses the terrain actor.
|
|
func _terrain_height(
|
|
space: PhysicsDirectSpaceState3D,
|
|
terrain: Node,
|
|
x: float,
|
|
z: float,
|
|
excluded: Array[RID]
|
|
) -> float:
|
|
var query := PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z))
|
|
query.exclude = excluded
|
|
var hit := space.intersect_ray(query)
|
|
if hit.is_empty() or hit["collider"] != terrain:
|
|
return NAN
|
|
return float(hit["position"].y)
|