Add production bathymetric sonar slice

This commit is contained in:
chindit committed 2026-10-02 15:52:11 +02:00
1 parent a18e82eb44
commit 7613daee84
28 files changed
+1825 -87

No files matched your search

+4 -1
View File
@@ -15,6 +15,9 @@ Suites de production actuelles :
pression, collisions, alertes et états terminaux ;
- `unit/run_vehicle_application_tests.gd` : validation et séquençage des
commandes de pilotage et de caméra ;
- `unit/run_sonar_application_tests.gd` : buffer compact scellé, validation des
captures, contacts séparés et temporalité de révélation/récupération ;
- `integration/run_vertical_slice_foundation.gd` : composition, mouvement
inertiel, caméra indépendante, cockpit 3D fixe, instruments physiques, couche
d'alerte, pause des ticks et remappage avec conflit.
d'alerte, sonar bathymétrique partagé, pause des ticks et remappage avec
conflit.
@@ -13,7 +13,7 @@ const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd"
)
const TEST_COUNT := 40
const TEST_COUNT := 51
var _failures: Array[String] = []
@@ -78,11 +78,15 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var instruments = composition.cockpit_instrument_panel()
var adapter := composition.input_adapter() as ProductionInputAdapter
var hud := composition.active_screen() as CockpitHud
var sonar_service = composition.sonar_service()
var sonar_display = composition.sonar_display()
_expect(composition.active_world() != null, "vehicle test world is assembled")
_expect(actor != null, "production submarine actor is assembled")
_expect(camera != null, "cockpit camera is attached to the submarine")
_expect(instruments != null, "physical cockpit instruments are assembled")
_expect(hud != null, "critical HUD overlay is assembled")
_expect(sonar_service != null, "production sonar service is assembled")
_expect(sonar_display != null, "shared sonar presentation is assembled")
_expect(composition.latest_update() != null, "dive session starts with a typed update")
if actor == null or camera == null or instruments == null or adapter == null or hud == null:
return
@@ -94,6 +98,24 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
_expect("L/min" in instruments.resources_text(), "oxygen instrument has volume and consumption")
_expect("bar" in instruments.integrity_text(), "hull instrument has current and certified pressure")
_expect(not hud.alert_overlay_visible(), "normal telemetry stays on physical instruments only")
_expect(
instruments.sonar_texture() == sonar_display.sonar_texture(),
"physical and expanded sonar views share one texture"
)
adapter.sonar_scan_requested.emit()
var first_scan = sonar_service.current_scan()
_expect(first_scan != null, "manual sonar command publishes a typed scan")
_expect(first_scan.samples.observed_count() > 1000, "reference acquisition captures dense bathymetry")
_expect(first_scan.contacts.size() >= 1, "metal calibration target becomes a distinct contact")
_expect("RELEVÉ EN COURS" in sonar_display.status_text(), "sonar display reports progressive reveal")
for frame in 8:
await physics_frame
_expect(sonar_display.built_row_count() > 0, "renderer reveals near rows incrementally")
adapter.sonar_view_toggle_requested.emit()
_expect(sonar_display.is_expanded(), "sonar can expand without pausing the dive")
adapter.sonar_view_toggle_requested.emit()
_expect(not sonar_display.is_expanded(), "expanded sonar returns to cockpit view")
var start_position: Vector3 = actor.global_position
var starting_battery: float = composition.latest_update().snapshot.battery.remaining_kwh
@@ -130,6 +152,7 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
_expect(instruments.global_transform.is_equal_approx(cockpit_transform), "cockpit remains fixed while the player looks around")
var paused_sequence: int = composition.latest_update().snapshot.tick_sequence
var paused_sonar_progress: float = sonar_service.current_scan().reveal_progress
adapter.pause_requested.emit()
await process_frame
_expect(paused, "pause menu suspends the scene tree")
@@ -141,13 +164,17 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
composition.latest_update().snapshot.tick_sequence == paused_sequence,
"dive ticks are frozen while paused"
)
_expect(
is_equal_approx(sonar_service.current_scan().reveal_progress, paused_sonar_progress),
"sonar reveal is frozen while paused"
)
var controls_button := pause_menu.get_node("%ControlsButton") as Button
controls_button.pressed.emit()
await process_frame
var controls := composition.active_overlay() as ControlsSettingsScreen
_expect(controls != null, "controls settings open over the paused dive")
_expect(adapter.binding_snapshots().size() == 9, "all gameplay keyboard bindings are exposed")
_expect(adapter.binding_snapshots().size() == 11, "all gameplay keyboard bindings are exposed")
var rebind_results: Array = []
adapter.rebind_result_available.connect(
@@ -186,7 +213,10 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
adapter.pause_requested.emit()
await process_frame
_expect(not paused, "second escape resumes the dive")
await physics_frame
for frame in 3:
await physics_frame
if composition.latest_update().snapshot.tick_sequence > paused_sequence:
break
_expect(
composition.latest_update().snapshot.tick_sequence > paused_sequence,
"fixed dive ticks resume after pause"
+181
View File
@@ -0,0 +1,181 @@
extends SceneTree
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SonarScanState = preload("res://game/domain/sonar/sonar_scan_state.gd")
const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd")
const SonarSampleBuffer = preload(
"res://game/domain/sonar/sonar_sample_buffer.gd"
)
const SonarCaptureRequest = preload(
"res://game/application/sonar/sonar_capture_request.gd"
)
const SonarCaptureSample = preload(
"res://game/application/sonar/sonar_capture_sample.gd"
)
const SonarCaptureResult = preload(
"res://game/application/sonar/sonar_capture_result.gd"
)
const SonarAcquisitionPort = preload(
"res://game/application/interfaces/sonar_acquisition_port.gd"
)
const SonarService = preload("res://game/application/sonar/sonar_service.gd")
const TEST_COUNT := 32
var _failures: Array[String] = []
class RecordingAcquisition extends SonarAcquisitionPort:
var requests: Array[SonarCaptureRequest] = []
func capture(request: SonarCaptureRequest) -> SonarCaptureResult:
requests.append(request)
var samples: Array[SonarCaptureSample] = [
SonarCaptureSample.new(
0, 0, Vector3(-2.0, -7.0, 8.0), Vector3.UP, 0.9, -7.0,
SonarMaterial.Value.METAL
),
SonarCaptureSample.new(
0, 1, Vector3(-1.0, -7.0, 8.0), Vector3.UP, 0.8, -7.0,
SonarMaterial.Value.METAL
),
SonarCaptureSample.new(
5, 5, Vector3(12.0, -11.0, 24.0), Vector3.UP, 1.0, -11.0,
SonarMaterial.Value.METAL
),
SonarCaptureSample.new(
2, 2, Vector3(0.0, -14.0, 16.0), Vector3.UP, 0.6, -14.0,
SonarMaterial.Value.ROCK
),
]
return SonarCaptureResult.new(true, samples)
func _init() -> void:
_test_sample_buffer_contract()
_test_service_lifecycle_and_contacts()
if _failures.is_empty():
print("Sonar application unit tests: %d passed, 0 failed." % TEST_COUNT)
quit(0)
else:
for failure: String in _failures:
push_error(failure)
print("Sonar application unit tests: %d failed." % _failures.size())
quit(1)
func _test_sample_buffer_contract() -> void:
var sample := SonarSample.new(
Vector3(1.0, -3.0, 8.0),
Vector3.UP,
0.75,
-3.0,
SonarMaterial.Value.ROCK,
true
)
_expect(sample.validation_error().is_empty(), "observed typed sample is valid")
var buffer := SonarSampleBuffer.new(3, 2)
_expect(buffer.set_sample(1, 2, sample).is_empty(), "sample writes inside buffer bounds")
_expect(buffer.observed_count() == 1, "buffer counts observed cells")
var restored: SonarSample = buffer.sample_at(1, 2)
_expect(
restored.observed and is_equal_approx(restored.intensity, 0.75),
"typed accessor restores aligned sample fields"
)
_expect(not buffer.sample_at(4, 4).observed, "out-of-range accessor returns unknown sample")
_expect(buffer.seal().is_empty(), "valid sample buffer seals")
_expect(
buffer.set_sample(0, 0, sample) == "sonar sample buffer is sealed",
"sealed sample buffer rejects mutation"
)
_expect(buffer.validation_error().is_empty(), "sealed aligned buffer is publishable")
func _test_service_lifecycle_and_contacts() -> void:
var acquisition := RecordingAcquisition.new()
var service := SonarService.new(acquisition, &"test-dive")
var first = service.request_scan(Transform3D.IDENTITY)
_expect(first.accepted, "manual sonar request is accepted while ready")
_expect(
acquisition.requests.size() == 1
and acquisition.requests[0].columns == 49
and acquisition.requests[0].rows == 31,
"acquisition receives baseline grid dimensions"
)
_expect(
is_equal_approx(acquisition.requests[0].maximum_range_m, 95.0)
and is_equal_approx(rad_to_deg(acquisition.requests[0].horizontal_opening_radians), 76.0),
"acquisition receives baseline range and opening"
)
_expect(first.scan.validation_error().is_empty(), "published sonar scan is valid")
_expect(first.scan.samples.observed_count() == 4, "capture becomes a compact sealed buffer")
_expect(first.scan.contacts.size() == 2, "separated metal groups become distinct contacts")
_expect(
first.scan.contacts[0].sample_count == 2
and first.scan.contacts[1].sample_count == 1,
"adjacent metal samples cluster deterministically"
)
_expect(service.visible_row_count() == 0, "scan has no immediate visible return")
_expect(not service.request_scan(Transform3D.IDENTITY).accepted, "request during reveal is rejected")
service.advance(0.1)
_expect(service.visible_row_count() == 1, "first return appears at four percent")
_expect(
service.current_scan().state == SonarScanState.Value.REVEALING,
"scan remains in reveal after its first return"
)
service.advance(2.4)
_expect(
service.current_scan().state == SonarScanState.Value.RECOVERY,
"completed reveal enters recovery"
)
_expect(service.previous_scan() == null, "previous scan is released after replacement")
_expect(not service.request_scan(Transform3D.IDENTITY).accepted, "request during recovery is rejected")
service.advance(0.35)
_expect(service.current_scan().state == SonarScanState.Value.READY, "recovery returns sonar to ready")
var second = service.request_scan(Transform3D.IDENTITY)
_expect(second.accepted, "next manual sonar request is accepted")
_expect(
service.previous_scan().scan_id == first.scan.scan_id,
"one previous scan is retained"
)
_expect(
is_equal_approx(service.previous_scan_opacity(), 0.52),
"previous scan starts at baseline opacity"
)
service.advance(1.25)
_expect(
is_equal_approx(service.current_scan().reveal_progress, 0.5),
"reveal advances from explicit elapsed time"
)
_expect(
service.previous_scan_opacity() > 0.0
and service.previous_scan_opacity() < 0.52,
"previous scan fades during replacement"
)
_expect(
service.visible_row_count() > 1 and service.visible_row_count() < 31,
"partial reveal exposes only near rows"
)
service.advance(1.25)
_expect(
service.current_scan().state == SonarScanState.Value.RECOVERY,
"second reveal completes after 2.5 seconds"
)
_expect(service.previous_scan() == null, "faded previous scan is discarded")
service.advance(0.35)
_expect(service.current_scan().state == SonarScanState.Value.READY, "second recovery completes")
_expect(first.scan.scan_id != second.scan.scan_id, "scan identifiers are unique and ordered")
_expect(acquisition.requests.size() == 2, "rejected commands do not invoke acquisition")
_expect(
is_equal_approx(first.scan.maximum_elevation_m - first.scan.minimum_elevation_m, 7.0),
"scan retains a real elevation scale wider than the minimum span"
)
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)