diff --git a/README.md b/README.md index 6f286f8..c203e3f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ production sous `game/` n'en dépend. à l'éjection et aux états terminaux. - Pause qui suspend les ticks, confirmations de sortie et premier remappage avec détection des conflits. +- Première boucle sonar de production : acquisition bathymétrique typée, relief + révélé progressivement et texture unique partagée entre cockpit et grande vue. + +La tranche sonar reste en cours : la calibration face au faisceau frontal et +la décision d'acquisition autoritaire ne sont pas encore terminées. ## Prototypes validés @@ -83,6 +88,8 @@ Pour relancer les POC validés : | Souris | Déplacer le regard dans le hublot uniquement | | X maintenu | Éjection volontaire | | R | Recentrer le regard | +| F | Émettre un relevé sonar | +| Tab | Agrandir / réduire le sonar | | Échap | Pause / retour | Les commandes propres aux POC restent décrites dans leurs documents respectifs. diff --git a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md index 1e7d6b6..0dd2b7a 100644 --- a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md +++ b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md @@ -3,8 +3,8 @@ ## Statut - Date : 10 août 2026. -- Dernière révision d'implémentation : 16 août 2026. -- État : cadrage approuvé, tranches 0 et 1 implémentées. +- Dernière révision d'implémentation : 2 octobre 2026. +- État : cadrage approuvé, tranches 0, 1 et 1.5 implémentées ; tranche 2 en cours. - Jalon : verticale jouable, après validation des POC A et B. - Durée directrice du plan général : quatre à six mois. - Moteur figé pour le jalon : Godot 4.7.2. @@ -823,6 +823,13 @@ constituent la cible réelle d'intégration du sonar. ### Tranche 2 — sonar de production +État au 2 octobre 2026 : première boucle de production implémentée. Le buffer +compact scellé, les contacts distincts, le cycle révélation/récupération, la +réécriture de l'acquisition bathymétrique de référence et le renderer +incrémental partagé par les deux vues sont intégrés et testés. La comparaison +bornée avec le faisceau frontal, les mesures de coût et l'ADR de sélection +restent nécessaires avant de déclarer la tranche terminée. + - Définir `SonarSampleBuffer`, `SonarContact` et `SonarScan`. - Séparer acquisition, reconstruction et présentation. - Réécrire la bathymétrie validée sans importer le POC B et comparer le diff --git a/game/application/interfaces/sonar_acquisition_port.gd b/game/application/interfaces/sonar_acquisition_port.gd new file mode 100644 index 0000000..6b76dbf --- /dev/null +++ b/game/application/interfaces/sonar_acquisition_port.gd @@ -0,0 +1,13 @@ +class_name SonarAcquisitionPort +extends RefCounted + +const SonarCaptureRequest = preload( + "res://game/application/sonar/sonar_capture_request.gd" +) +const SonarCaptureResult = preload( + "res://game/application/sonar/sonar_capture_result.gd" +) + + +func capture(_request: SonarCaptureRequest) -> SonarCaptureResult: + return SonarCaptureResult.new(false, [], "sonar acquisition is not implemented") diff --git a/game/application/sonar/sonar_capture_request.gd b/game/application/sonar/sonar_capture_request.gd new file mode 100644 index 0000000..d56e596 --- /dev/null +++ b/game/application/sonar/sonar_capture_request.gd @@ -0,0 +1,48 @@ +class_name SonarCaptureRequest +extends RefCounted + +var scan_id: StringName +var session_id: StringName +var sensor_transform: Transform3D +var minimum_range_m: float +var maximum_range_m: float +var horizontal_opening_radians: float +var vertical_opening_radians: float +var columns: int +var rows: int + + +func _init( + p_scan_id: StringName, + p_session_id: StringName, + p_sensor_transform: Transform3D, + p_minimum_range_m: float, + p_maximum_range_m: float, + p_horizontal_opening_radians: float, + p_vertical_opening_radians: float, + p_columns: int, + p_rows: int +) -> void: + scan_id = p_scan_id + session_id = p_session_id + sensor_transform = p_sensor_transform + minimum_range_m = p_minimum_range_m + maximum_range_m = p_maximum_range_m + horizontal_opening_radians = p_horizontal_opening_radians + vertical_opening_radians = p_vertical_opening_radians + columns = p_columns + rows = p_rows + + +func validation_error() -> String: + if scan_id.is_empty() or session_id.is_empty(): + return "sonar capture identifiers must not be empty" + if minimum_range_m < 0.0 or maximum_range_m <= minimum_range_m: + return "sonar capture range is invalid" + if horizontal_opening_radians <= 0.0 or horizontal_opening_radians >= PI: + return "sonar capture horizontal opening is invalid" + if vertical_opening_radians <= 0.0 or vertical_opening_radians >= PI: + return "sonar capture vertical opening is invalid" + if columns < 2 or rows < 2: + return "sonar capture dimensions must be at least 2 by 2" + return "" diff --git a/game/application/sonar/sonar_capture_result.gd b/game/application/sonar/sonar_capture_result.gd new file mode 100644 index 0000000..a83b118 --- /dev/null +++ b/game/application/sonar/sonar_capture_result.gd @@ -0,0 +1,20 @@ +class_name SonarCaptureResult +extends RefCounted + +const SonarCaptureSample = preload( + "res://game/application/sonar/sonar_capture_sample.gd" +) + +var accepted: bool +var samples: Array[SonarCaptureSample] +var message: String + + +func _init( + p_accepted: bool, + p_samples: Array[SonarCaptureSample] = [], + p_message: String = "" +) -> void: + accepted = p_accepted + samples = p_samples.duplicate() + message = p_message diff --git a/game/application/sonar/sonar_capture_sample.gd b/game/application/sonar/sonar_capture_sample.gd new file mode 100644 index 0000000..c983ed6 --- /dev/null +++ b/game/application/sonar/sonar_capture_sample.gd @@ -0,0 +1,50 @@ +class_name SonarCaptureSample +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") + +var row: int +var column: int +var position_m: Vector3 +var normal: Vector3 +var intensity: float +var elevation_m: float +var material: SonarMaterial.Value + + +func _init( + p_row: int, + p_column: int, + p_position_m: Vector3, + p_normal: Vector3, + p_intensity: float, + p_elevation_m: float, + p_material: SonarMaterial.Value +) -> void: + row = p_row + column = p_column + position_m = p_position_m + normal = p_normal + intensity = p_intensity + elevation_m = p_elevation_m + material = p_material + + +func validation_error(columns: int, rows: int) -> String: + if row < 0 or row >= rows or column < 0 or column >= columns: + return "sonar capture sample coordinates are invalid" + if not _vector_is_finite(position_m) or not _vector_is_finite(normal): + return "sonar capture sample vectors are not finite" + if normal.length_squared() < 0.25: + return "sonar capture sample normal is invalid" + if not is_finite(intensity) or intensity < 0.0 or intensity > 1.0: + return "sonar capture sample intensity is invalid" + if not is_finite(elevation_m): + return "sonar capture sample elevation is invalid" + if material == SonarMaterial.Value.UNKNOWN or not SonarMaterial.is_valid(material): + return "sonar capture sample material is invalid" + return "" + + +func _vector_is_finite(value: Vector3) -> bool: + return is_finite(value.x) and is_finite(value.y) and is_finite(value.z) diff --git a/game/application/sonar/sonar_command_result.gd b/game/application/sonar/sonar_command_result.gd new file mode 100644 index 0000000..a98828f --- /dev/null +++ b/game/application/sonar/sonar_command_result.gd @@ -0,0 +1,18 @@ +class_name SonarCommandResult +extends RefCounted + +const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") + +var accepted: bool +var message: String +var scan: SonarScan + + +func _init( + p_accepted: bool, + p_message: String = "", + p_scan: SonarScan = null +) -> void: + accepted = p_accepted + message = p_message + scan = p_scan diff --git a/game/application/sonar/sonar_service.gd b/game/application/sonar/sonar_service.gd new file mode 100644 index 0000000..ec5ffe1 --- /dev/null +++ b/game/application/sonar/sonar_service.gd @@ -0,0 +1,298 @@ +class_name SonarService +extends RefCounted + +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 SonarContact = preload("res://game/domain/sonar/sonar_contact.gd") +const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") +const SonarCaptureRequest = preload( + "res://game/application/sonar/sonar_capture_request.gd" +) +const SonarCaptureResult = preload( + "res://game/application/sonar/sonar_capture_result.gd" +) +const SonarAcquisitionPort = preload( + "res://game/application/interfaces/sonar_acquisition_port.gd" +) +const SonarCommandResult = preload( + "res://game/application/sonar/sonar_command_result.gd" +) + +const MINIMUM_RANGE_M := 5.0 +const MAXIMUM_RANGE_M := 95.0 +const HORIZONTAL_OPENING_RADIANS := deg_to_rad(76.0) +const VERTICAL_OPENING_RADIANS := deg_to_rad(64.0) +const COLUMNS := 49 +const ROWS := 31 +const REVEAL_DURATION_SECONDS := 2.5 +const RECOVERY_DURATION_SECONDS := 0.35 +const FIRST_RETURN_PROGRESS := 0.04 +const PREVIOUS_SCAN_STARTING_OPACITY := 0.52 +const MINIMUM_COLOR_SPAN_M := 6.0 + +var _acquisition: SonarAcquisitionPort +var _session_id: StringName +var _scan_sequence := 0 +var _phase_elapsed_seconds := 0.0 +var _current_scan: SonarScan +var _previous_scan: SonarScan + + +func _init(p_acquisition: SonarAcquisitionPort, p_session_id: StringName) -> void: + _acquisition = p_acquisition + _session_id = p_session_id + + +func request_scan(sensor_transform: Transform3D) -> SonarCommandResult: + if _acquisition == null: + return SonarCommandResult.new(false, "sonar acquisition port is missing") + if _session_id.is_empty(): + return SonarCommandResult.new(false, "sonar session id is empty") + if _current_scan != null and _current_scan.state != SonarScanState.Value.READY: + return SonarCommandResult.new(false, "sonar is already scanning") + + var scan_id := StringName("%s-scan-%d" % [_session_id, _scan_sequence]) + var request := SonarCaptureRequest.new( + scan_id, + _session_id, + sensor_transform, + MINIMUM_RANGE_M, + MAXIMUM_RANGE_M, + HORIZONTAL_OPENING_RADIANS, + VERTICAL_OPENING_RADIANS, + COLUMNS, + ROWS + ) + var request_error := request.validation_error() + if not request_error.is_empty(): + return SonarCommandResult.new(false, request_error) + var capture: SonarCaptureResult = _acquisition.capture(request) + if capture == null or not capture.accepted: + return SonarCommandResult.new( + false, + "sonar acquisition failed" if capture == null else capture.message + ) + var built = _build_scan(request, capture) + if built is String: + return SonarCommandResult.new(false, built) + + _previous_scan = _current_scan + _current_scan = built as SonarScan + _phase_elapsed_seconds = 0.0 + _scan_sequence += 1 + return SonarCommandResult.new(true, "", _current_scan) + + +func advance(elapsed_seconds: float) -> SonarScan: + if _current_scan == null or not is_finite(elapsed_seconds) or elapsed_seconds < 0.0: + return _current_scan + if _current_scan.state == SonarScanState.Value.REVEALING: + _phase_elapsed_seconds += elapsed_seconds + var progress := clampf( + _phase_elapsed_seconds / REVEAL_DURATION_SECONDS, + 0.0, + 1.0 + ) + if progress >= 1.0: + _current_scan = _current_scan.with_phase( + SonarScanState.Value.RECOVERY, + 1.0 + ) + _phase_elapsed_seconds = 0.0 + _previous_scan = null + else: + _current_scan = _current_scan.with_phase( + SonarScanState.Value.REVEALING, + progress + ) + elif _current_scan.state == SonarScanState.Value.RECOVERY: + _phase_elapsed_seconds += elapsed_seconds + if _phase_elapsed_seconds >= RECOVERY_DURATION_SECONDS: + _current_scan = _current_scan.with_phase( + SonarScanState.Value.READY, + 1.0 + ) + _phase_elapsed_seconds = 0.0 + return _current_scan + + +func current_scan() -> SonarScan: + return _current_scan + + +func previous_scan() -> SonarScan: + return _previous_scan + + +func visible_row_count() -> int: + if _current_scan == null: + return 0 + if _current_scan.state != SonarScanState.Value.REVEALING: + return _current_scan.samples.rows + if _current_scan.reveal_progress < FIRST_RETURN_PROGRESS: + return 0 + var normalized := inverse_lerp( + FIRST_RETURN_PROGRESS, + 1.0, + _current_scan.reveal_progress + ) + return clampi( + floori(normalized * float(_current_scan.samples.rows - 1)) + 1, + 1, + _current_scan.samples.rows + ) + + +func previous_scan_opacity() -> float: + if _previous_scan == null or _current_scan == null: + return 0.0 + return PREVIOUS_SCAN_STARTING_OPACITY * (1.0 - _current_scan.reveal_progress) + + +func _build_scan(request: SonarCaptureRequest, capture: SonarCaptureResult) -> Variant: + var buffer := SonarSampleBuffer.new(request.columns, request.rows) + var minimum_elevation := INF + var maximum_elevation := -INF + for captured_sample in capture.samples: + if captured_sample == null: + return "sonar acquisition returned a missing sample" + var capture_error: String = captured_sample.validation_error( + request.columns, + request.rows + ) + if not capture_error.is_empty(): + return capture_error + var sample := SonarSample.new( + captured_sample.position_m, + captured_sample.normal, + captured_sample.intensity, + captured_sample.elevation_m, + captured_sample.material, + true + ) + var write_error := buffer.set_sample( + captured_sample.row, + captured_sample.column, + sample + ) + if not write_error.is_empty(): + return write_error + minimum_elevation = minf(minimum_elevation, sample.elevation_m) + maximum_elevation = maxf(maximum_elevation, sample.elevation_m) + var seal_error := buffer.seal() + if not seal_error.is_empty(): + return seal_error + if buffer.observed_count() == 0: + minimum_elevation = -MINIMUM_COLOR_SPAN_M * 0.5 + maximum_elevation = MINIMUM_COLOR_SPAN_M * 0.5 + elif maximum_elevation - minimum_elevation < MINIMUM_COLOR_SPAN_M: + var center := (minimum_elevation + maximum_elevation) * 0.5 + minimum_elevation = center - MINIMUM_COLOR_SPAN_M * 0.5 + maximum_elevation = center + MINIMUM_COLOR_SPAN_M * 0.5 + + var scan := SonarScan.new( + request.scan_id, + request.session_id, + request.sensor_transform, + request.minimum_range_m, + request.maximum_range_m, + request.horizontal_opening_radians, + request.vertical_opening_radians, + REVEAL_DURATION_SECONDS, + RECOVERY_DURATION_SECONDS, + buffer, + _cluster_contacts(request.scan_id, buffer), + minimum_elevation, + maximum_elevation, + SonarScanState.Value.REVEALING, + 0.0 + ) + var scan_error := scan.validation_error() + return scan if scan_error.is_empty() else scan_error + + +func _cluster_contacts( + scan_id: StringName, + buffer: SonarSampleBuffer +) -> Array[SonarContact]: + var metal_indices: Dictionary = {} + for row in buffer.rows: + for column in buffer.columns: + var sample: SonarSample = buffer.sample_at(row, column) + if sample.observed and sample.material == SonarMaterial.Value.METAL: + metal_indices[row * buffer.columns + column] = true + + var contacts: Array[SonarContact] = [] + var visited: Dictionary = {} + for seed_index: int in metal_indices: + if visited.has(seed_index): + continue + var queue: Array[int] = [seed_index] + var cluster_indices: Array[int] = [] + visited[seed_index] = true + while not queue.is_empty(): + var index: int = queue.pop_front() + cluster_indices.append(index) + var row: int = index / buffer.columns + var column: int = index % buffer.columns + for row_offset: int in range(-1, 2): + for column_offset: int in range(-1, 2): + var neighbor_row: int = row + row_offset + var neighbor_column: int = column + column_offset + if ( + neighbor_row < 0 + or neighbor_row >= buffer.rows + or neighbor_column < 0 + or neighbor_column >= buffer.columns + ): + continue + var neighbor_index: int = neighbor_row * buffer.columns + neighbor_column + if metal_indices.has(neighbor_index) and not visited.has(neighbor_index): + visited[neighbor_index] = true + queue.append(neighbor_index) + contacts.append( + _contact_from_indices(scan_id, contacts.size(), buffer, cluster_indices) + ) + return contacts + + +func _contact_from_indices( + scan_id: StringName, + contact_index: int, + buffer: SonarSampleBuffer, + indices: Array[int] +) -> SonarContact: + var center := Vector3.ZERO + var minimum := Vector3(INF, INF, INF) + var maximum := Vector3(-INF, -INF, -INF) + var maximum_intensity := 0.0 + for index: int in indices: + var sample: SonarSample = buffer.sample_at( + index / buffer.columns, + index % buffer.columns + ) + center += sample.position_m + minimum = Vector3( + minf(minimum.x, sample.position_m.x), + minf(minimum.y, sample.position_m.y), + minf(minimum.z, sample.position_m.z) + ) + maximum = Vector3( + maxf(maximum.x, sample.position_m.x), + maxf(maximum.y, sample.position_m.y), + maxf(maximum.z, sample.position_m.z) + ) + maximum_intensity = maxf(maximum_intensity, sample.intensity) + center /= float(indices.size()) + return SonarContact.new( + StringName("%s-contact-%d" % [scan_id, contact_index]), + SonarMaterial.Value.METAL, + center, + AABB(minimum, maximum - minimum), + maximum_intensity, + indices.size() + ) diff --git a/game/composition/vertical_slice/vertical_slice_root.gd b/game/composition/vertical_slice/vertical_slice_root.gd index 2d40084..2c2c4e8 100644 --- a/game/composition/vertical_slice/vertical_slice_root.gd +++ b/game/composition/vertical_slice/vertical_slice_root.gd @@ -18,6 +18,7 @@ const VehicleCollisionSample = preload( "res://game/application/vehicle/vehicle_collision_sample.gd" ) const PilotUseCase = preload("res://game/application/vehicle/pilot_use_case.gd") +const SonarService = preload("res://game/application/sonar/sonar_service.gd") const SubmarineActor = preload("res://game/world/submarine/submarine_actor.gd") const SubmarineMotionAdapter = preload( "res://game/world/submarine/submarine_motion_adapter.gd" @@ -25,6 +26,9 @@ const SubmarineMotionAdapter = preload( const SubmarineTelemetryAdapter = preload( "res://game/world/submarine/submarine_telemetry_adapter.gd" ) +const ReferenceBathymetryAcquisition = preload( + "res://game/world/sonar/reference_bathymetry_acquisition.gd" +) const ProductionInputAdapter = preload( "res://game/infrastructure/input/production_input_adapter.gd" ) @@ -35,6 +39,7 @@ const CockpitInstrumentPanel = preload( "res://game/presentation/cockpit/cockpit_instrument_panel.gd" ) const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd") +const SonarDisplay = preload("res://game/presentation/sonar/sonar_display.gd") const MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd") const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd") const ControlsSettingsScreen = preload( @@ -50,6 +55,9 @@ const VEHICLE_TEST_WORLD_SCENE_PATH := ( "res://game/composition/vertical_slice/vehicle_test_world.tscn" ) const COCKPIT_HUD_SCENE = preload("res://game/presentation/cockpit/cockpit_hud.tscn") +const SONAR_DISPLAY_SCENE = preload( + "res://game/presentation/sonar/sonar_display.tscn" +) const PAUSE_MENU_SCENE = preload("res://game/presentation/pause/pause_menu.tscn") const CONTROLS_SETTINGS_SCENE = preload( "res://game/presentation/settings/controls_settings_screen.tscn" @@ -75,6 +83,8 @@ var _actor: SubmarineActor var _camera_controller: CockpitCameraController var _instrument_panel: CockpitInstrumentPanel var _hud: CockpitHud +var _sonar_service: SonarService +var _sonar_display: SonarDisplay var _latest_update: DiveUpdate var _active_world: Node3D var _active_screen: Control @@ -90,6 +100,8 @@ var _dive_terminal := false func _ready() -> void: _input_adapter.pause_requested.connect(_on_pause_requested) _input_adapter.rebind_result_available.connect(_on_rebind_result_available) + _input_adapter.sonar_scan_requested.connect(_on_sonar_scan_requested) + _input_adapter.sonar_view_toggle_requested.connect(_on_sonar_view_toggle_requested) _show_main_menu() @@ -115,10 +127,18 @@ func _start_vehicle_test() -> void: _instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel _hud = COCKPIT_HUD_SCENE.instantiate() as CockpitHud _replace_active_screen(_hud) + _sonar_display = SONAR_DISPLAY_SCENE.instantiate() as SonarDisplay + _hud.add_child(_sonar_display) + _instrument_panel.attach_sonar_texture(_sonar_display.sonar_texture()) var motion_adapter := SubmarineMotionAdapter.new(_actor) _pilot_use_case = PilotUseCase.new(motion_adapter) _telemetry_adapter = SubmarineTelemetryAdapter.new(_actor) + var sonar_acquisition := ReferenceBathymetryAcquisition.new( + _actor.get_world_3d(), + _actor + ) + _sonar_service = SonarService.new(sonar_acquisition, DIVE_SESSION_ID) _runner = DiveTickRunner.new(session) _latest_update = _runner.initial_update() _vehicle_command_sequence = 0 @@ -177,6 +197,8 @@ func _physics_process(delta: float) -> void: ) _hud.present(_latest_update, telemetry) _instrument_panel.present(_latest_update, telemetry) + _sonar_service.advance(delta) + _present_sonar() if _latest_update.snapshot.is_terminal(): _finish_dive_motion() @@ -213,6 +235,14 @@ func active_overlay() -> Control: return _active_overlay +func sonar_service() -> SonarService: + return _sonar_service + + +func sonar_display() -> SonarDisplay: + return _sonar_display + + func _finish_dive_motion() -> void: _dive_terminal = true _input_adapter.set_gameplay_enabled(false) @@ -242,6 +272,41 @@ func _on_collision_sampled(sample: VehicleCollisionSample) -> void: _pending_collision_samples.append(sample) +func _on_sonar_scan_requested() -> void: + if _sonar_service == null or _actor == null or _dive_terminal: + return + var result = _sonar_service.request_scan(_sonar_sensor_transform()) + if result.accepted: + _present_sonar() + + +func _on_sonar_view_toggle_requested() -> void: + if _sonar_display != null and not _dive_terminal: + _sonar_display.toggle_expanded() + + +func _present_sonar() -> void: + if _sonar_service == null or _sonar_display == null: + return + _sonar_display.present( + _sonar_service.current_scan(), + _sonar_service.previous_scan(), + _sonar_service.visible_row_count(), + _sonar_service.previous_scan_opacity() + ) + + +func _sonar_sensor_transform() -> Transform3D: + var world_basis := _actor.global_basis + var contract_basis := Basis( + world_basis.x, + world_basis.y, + -world_basis.z + ) + var sensor_origin := _actor.global_transform * Vector3(0.0, -0.75, -2.2) + return Transform3D(contract_basis, sensor_origin) + + func _on_pause_requested() -> void: if _runner == null: return @@ -382,6 +447,8 @@ func _show_main_menu() -> void: _camera_controller = null _instrument_panel = null _hud = null + _sonar_service = null + _sonar_display = null _latest_update = null _pending_collision_samples.clear() _dive_terminal = false diff --git a/game/domain/sonar/sonar_contact.gd b/game/domain/sonar/sonar_contact.gd new file mode 100644 index 0000000..d2b07fe --- /dev/null +++ b/game/domain/sonar/sonar_contact.gd @@ -0,0 +1,39 @@ +class_name SonarContact +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") + +var contact_id: StringName +var material: SonarMaterial.Value +var center_m: Vector3 +var bounds: AABB +var maximum_intensity: float +var sample_count: int + + +func _init( + p_contact_id: StringName, + p_material: SonarMaterial.Value, + p_center_m: Vector3, + p_bounds: AABB, + p_maximum_intensity: float, + p_sample_count: int +) -> void: + contact_id = p_contact_id + material = p_material + center_m = p_center_m + bounds = p_bounds + maximum_intensity = p_maximum_intensity + sample_count = p_sample_count + + +func validation_error() -> String: + if contact_id.is_empty(): + return "sonar contact id is empty" + if material == SonarMaterial.Value.UNKNOWN or not SonarMaterial.is_valid(material): + return "sonar contact material is invalid" + if sample_count <= 0: + return "sonar contact sample count must be positive" + if not is_finite(maximum_intensity) or maximum_intensity < 0.0 or maximum_intensity > 1.0: + return "sonar contact intensity must be between 0 and 1" + return "" diff --git a/game/domain/sonar/sonar_material.gd b/game/domain/sonar/sonar_material.gd new file mode 100644 index 0000000..b4ef673 --- /dev/null +++ b/game/domain/sonar/sonar_material.gd @@ -0,0 +1,12 @@ +class_name SonarMaterial +extends RefCounted + +enum Value { + UNKNOWN, + ROCK, + METAL, +} + + +static func is_valid(value: int) -> bool: + return value >= Value.UNKNOWN and value <= Value.METAL diff --git a/game/domain/sonar/sonar_sample.gd b/game/domain/sonar/sonar_sample.gd new file mode 100644 index 0000000..b26892b --- /dev/null +++ b/game/domain/sonar/sonar_sample.gd @@ -0,0 +1,53 @@ +class_name SonarSample +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") + +var position_m: Vector3 +var normal: Vector3 +var intensity: float +var elevation_m: float +var material: SonarMaterial.Value +var observed: bool + + +func _init( + p_position_m: Vector3 = Vector3.ZERO, + p_normal: Vector3 = Vector3.UP, + p_intensity: float = 0.0, + p_elevation_m: float = 0.0, + p_material: SonarMaterial.Value = SonarMaterial.Value.UNKNOWN, + p_observed: bool = false +) -> void: + position_m = p_position_m + normal = p_normal + intensity = p_intensity + elevation_m = p_elevation_m + material = p_material + observed = p_observed + + +static func unknown() -> SonarSample: + return new() + + +func validation_error() -> String: + if not observed: + if material != SonarMaterial.Value.UNKNOWN: + return "unobserved sonar sample has a material" + return "" + if not _vector_is_finite(position_m): + return "sonar sample position is not finite" + if not _vector_is_finite(normal) or normal.length_squared() < 0.25: + return "sonar sample normal is invalid" + if not is_finite(intensity) or intensity < 0.0 or intensity > 1.0: + return "sonar sample intensity must be between 0 and 1" + if not is_finite(elevation_m): + return "sonar sample elevation is not finite" + if material == SonarMaterial.Value.UNKNOWN or not SonarMaterial.is_valid(material): + return "observed sonar sample material is invalid" + return "" + + +func _vector_is_finite(value: Vector3) -> bool: + return is_finite(value.x) and is_finite(value.y) and is_finite(value.z) diff --git a/game/domain/sonar/sonar_sample_buffer.gd b/game/domain/sonar/sonar_sample_buffer.gd new file mode 100644 index 0000000..b7cccac --- /dev/null +++ b/game/domain/sonar/sonar_sample_buffer.gd @@ -0,0 +1,115 @@ +class_name SonarSampleBuffer +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd") + +var columns: int +var rows: int + +var _positions := PackedVector3Array() +var _normals := PackedVector3Array() +var _intensities := PackedFloat32Array() +var _elevations_m := PackedFloat32Array() +var _materials := PackedInt32Array() +var _observed := PackedByteArray() +var _sealed := false + + +func _init(p_columns: int, p_rows: int) -> void: + columns = p_columns + rows = p_rows + var sample_count := maxi(columns, 0) * maxi(rows, 0) + _positions.resize(sample_count) + _normals.resize(sample_count) + _intensities.resize(sample_count) + _elevations_m.resize(sample_count) + _materials.resize(sample_count) + _observed.resize(sample_count) + for index in sample_count: + _normals[index] = Vector3.UP + _materials[index] = SonarMaterial.Value.UNKNOWN + + +func set_sample(row: int, column: int, sample: SonarSample) -> String: + if _sealed: + return "sonar sample buffer is sealed" + var index := _index_of(row, column) + if index < 0: + return "sonar sample coordinates are outside the buffer" + if sample == null: + return "sonar sample is missing" + var error := sample.validation_error() + if not error.is_empty(): + return error + if not sample.observed: + return "cannot write an unobserved sonar sample" + if _observed[index] == 1: + return "sonar sample coordinates are duplicated" + _positions[index] = sample.position_m + _normals[index] = sample.normal.normalized() + _intensities[index] = sample.intensity + _elevations_m[index] = sample.elevation_m + _materials[index] = sample.material + _observed[index] = 1 + return "" + + +func seal() -> String: + if columns < 2 or rows < 2: + return "sonar sample buffer dimensions must be at least 2 by 2" + _sealed = true + return "" + + +func is_sealed() -> bool: + return _sealed + + +func sample_count() -> int: + return columns * rows + + +func observed_count() -> int: + var count := 0 + for value: int in _observed: + count += value + return count + + +func sample_at(row: int, column: int) -> SonarSample: + var index := _index_of(row, column) + if index < 0 or _observed[index] == 0: + return SonarSample.unknown() + return SonarSample.new( + _positions[index], + _normals[index], + _intensities[index], + _elevations_m[index], + _materials[index], + true + ) + + +func validation_error() -> String: + if columns < 2 or rows < 2: + return "sonar sample buffer dimensions must be at least 2 by 2" + if not _sealed: + return "sonar sample buffer must be sealed before publication" + var expected_size := columns * rows + if ( + _positions.size() != expected_size + or _normals.size() != expected_size + or _intensities.size() != expected_size + or _elevations_m.size() != expected_size + or _materials.size() != expected_size + or _observed.size() != expected_size + ): + return "sonar sample buffer arrays are not aligned" + return "" + + +func _index_of(row: int, column: int) -> int: + if row < 0 or row >= rows or column < 0 or column >= columns: + return -1 + return row * columns + column diff --git a/game/domain/sonar/sonar_scan.gd b/game/domain/sonar/sonar_scan.gd new file mode 100644 index 0000000..ce9de89 --- /dev/null +++ b/game/domain/sonar/sonar_scan.gd @@ -0,0 +1,119 @@ +class_name SonarScan +extends RefCounted + +const SonarScanState = preload("res://game/domain/sonar/sonar_scan_state.gd") +const SonarSampleBuffer = preload("res://game/domain/sonar/sonar_sample_buffer.gd") +const SonarContact = preload("res://game/domain/sonar/sonar_contact.gd") + +var scan_id: StringName +var session_id: StringName +var sensor_transform: Transform3D +var minimum_range_m: float +var maximum_range_m: float +var horizontal_opening_radians: float +var vertical_opening_radians: float +var reveal_duration_seconds: float +var recovery_duration_seconds: float +var samples: SonarSampleBuffer +var contacts: Array[SonarContact] +var minimum_elevation_m: float +var maximum_elevation_m: float +var state: SonarScanState.Value +var reveal_progress: float + + +func _init( + p_scan_id: StringName, + p_session_id: StringName, + p_sensor_transform: Transform3D, + p_minimum_range_m: float, + p_maximum_range_m: float, + p_horizontal_opening_radians: float, + p_vertical_opening_radians: float, + p_reveal_duration_seconds: float, + p_recovery_duration_seconds: float, + p_samples: SonarSampleBuffer, + p_contacts: Array[SonarContact], + p_minimum_elevation_m: float, + p_maximum_elevation_m: float, + p_state: SonarScanState.Value, + p_reveal_progress: float +) -> void: + scan_id = p_scan_id + session_id = p_session_id + sensor_transform = p_sensor_transform + minimum_range_m = p_minimum_range_m + maximum_range_m = p_maximum_range_m + horizontal_opening_radians = p_horizontal_opening_radians + vertical_opening_radians = p_vertical_opening_radians + reveal_duration_seconds = p_reveal_duration_seconds + recovery_duration_seconds = p_recovery_duration_seconds + samples = p_samples + contacts = p_contacts.duplicate() + minimum_elevation_m = p_minimum_elevation_m + maximum_elevation_m = p_maximum_elevation_m + state = p_state + reveal_progress = p_reveal_progress + + +func with_phase(next_state: SonarScanState.Value, next_progress: float) -> SonarScan: + return new( + scan_id, + session_id, + sensor_transform, + minimum_range_m, + maximum_range_m, + horizontal_opening_radians, + vertical_opening_radians, + reveal_duration_seconds, + recovery_duration_seconds, + samples, + contacts, + minimum_elevation_m, + maximum_elevation_m, + next_state, + next_progress + ) + + +func validation_error() -> String: + if scan_id.is_empty() or session_id.is_empty(): + return "sonar scan identifiers must not be empty" + if not is_finite(minimum_range_m) or minimum_range_m < 0.0: + return "sonar minimum range is invalid" + if not is_finite(maximum_range_m) or maximum_range_m <= minimum_range_m: + return "sonar maximum range must exceed minimum range" + if ( + not is_finite(horizontal_opening_radians) + or horizontal_opening_radians <= 0.0 + or horizontal_opening_radians >= PI + ): + return "sonar horizontal opening is invalid" + if ( + not is_finite(vertical_opening_radians) + or vertical_opening_radians <= 0.0 + or vertical_opening_radians >= PI + ): + return "sonar vertical opening is invalid" + if reveal_duration_seconds <= 0.0 or recovery_duration_seconds < 0.0: + return "sonar phase durations are invalid" + if samples == null: + return "sonar sample buffer is missing" + var buffer_error := samples.validation_error() + if not buffer_error.is_empty(): + return buffer_error + for contact: SonarContact in contacts: + if contact == null: + return "sonar contact is missing" + var contact_error := contact.validation_error() + if not contact_error.is_empty(): + return contact_error + if not is_finite(minimum_elevation_m) or not is_finite(maximum_elevation_m): + return "sonar elevation scale is not finite" + if maximum_elevation_m <= minimum_elevation_m: + return "sonar elevation scale is empty" + if not SonarScanState.is_valid(state): + return "sonar scan state is invalid" + if not is_finite(reveal_progress) or reveal_progress < 0.0 or reveal_progress > 1.0: + return "sonar reveal progress must be between 0 and 1" + return "" diff --git a/game/domain/sonar/sonar_scan_state.gd b/game/domain/sonar/sonar_scan_state.gd new file mode 100644 index 0000000..e9f46b2 --- /dev/null +++ b/game/domain/sonar/sonar_scan_state.gd @@ -0,0 +1,13 @@ +class_name SonarScanState +extends RefCounted + +enum Value { + READY, + CAPTURE, + REVEALING, + RECOVERY, +} + + +static func is_valid(value: int) -> bool: + return value >= Value.READY and value <= Value.RECOVERY diff --git a/game/infrastructure/input/production_input_adapter.gd b/game/infrastructure/input/production_input_adapter.gd index a4563ea..5e0a257 100644 --- a/game/infrastructure/input/production_input_adapter.gd +++ b/game/infrastructure/input/production_input_adapter.gd @@ -3,6 +3,8 @@ extends Node signal pause_requested signal rebind_result_available(result: RebindResult) +signal sonar_scan_requested +signal sonar_view_toggle_requested const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd") const CameraLookCommand = preload("res://game/application/vehicle/camera_look_command.gd") @@ -20,6 +22,8 @@ const ACTION_DESCEND := &"vehicle_descend" const ACTION_HIGH_THRUST := &"vehicle_high_thrust" const ACTION_EJECT := &"dive_eject" const ACTION_RECENTER_CAMERA := &"camera_recenter" +const ACTION_SONAR_SCAN := &"sonar_scan" +const ACTION_SONAR_VIEW_TOGGLE := &"sonar_view_toggle" const ACTION_PAUSE := &"pause_game" const BINDABLE_ACTIONS: Array[StringName] = [ @@ -32,6 +36,8 @@ const BINDABLE_ACTIONS: Array[StringName] = [ ACTION_HIGH_THRUST, ACTION_EJECT, ACTION_RECENTER_CAMERA, + ACTION_SONAR_SCAN, + ACTION_SONAR_VIEW_TOGGLE, ] const ACTION_LABELS := { @@ -44,6 +50,8 @@ const ACTION_LABELS := { ACTION_HIGH_THRUST: "Forte poussée", ACTION_EJECT: "Éjection maintenue", ACTION_RECENTER_CAMERA: "Recentrer le regard", + ACTION_SONAR_SCAN: "Émettre un relevé sonar", + ACTION_SONAR_VIEW_TOGGLE: "Agrandir le sonar", } const DEFAULT_KEYS := { @@ -56,6 +64,8 @@ const DEFAULT_KEYS := { ACTION_HIGH_THRUST: KEY_SHIFT, ACTION_EJECT: KEY_X, ACTION_RECENTER_CAMERA: KEY_R, + ACTION_SONAR_SCAN: KEY_F, + ACTION_SONAR_VIEW_TOGGLE: KEY_TAB, ACTION_PAUSE: KEY_ESCAPE, } @@ -175,6 +185,15 @@ func _unhandled_input(event: InputEvent) -> void: pause_requested.emit() get_viewport().set_input_as_handled() return + if _gameplay_enabled and not get_tree().paused: + if event.is_action_pressed(ACTION_SONAR_SCAN): + sonar_scan_requested.emit() + get_viewport().set_input_as_handled() + return + if event.is_action_pressed(ACTION_SONAR_VIEW_TOGGLE): + sonar_view_toggle_requested.emit() + get_viewport().set_input_as_handled() + return if ( _gameplay_enabled and not get_tree().paused diff --git a/game/presentation/cockpit/cockpit_instrument_panel.gd b/game/presentation/cockpit/cockpit_instrument_panel.gd index a36c6b8..11667e1 100644 --- a/game/presentation/cockpit/cockpit_instrument_panel.gd +++ b/game/presentation/cockpit/cockpit_instrument_panel.gd @@ -15,6 +15,10 @@ const COLOR_CRITICAL := Color(1.0, 0.42, 0.31, 1.0) @onready var _integrity_label: Label3D = %IntegrityLabel @onready var _return_label: Label3D = %ReturnLabel @onready var _status_label: Label3D = %StatusLabel +@onready var _sonar_screen: MeshInstance3D = %SonarScreen +@onready var _sonar_placeholder: Label3D = %SonarPlaceholder + +var _sonar_texture: Texture2D func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void: @@ -79,6 +83,22 @@ func return_text() -> String: return _return_label.text +func attach_sonar_texture(texture: Texture2D) -> void: + _sonar_texture = texture + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_texture = texture + material.emission_enabled = true + material.emission_texture = texture + material.emission_energy_multiplier = 1.7 + _sonar_screen.material_override = material + _sonar_placeholder.visible = false + + +func sonar_texture() -> Texture2D: + return _sonar_texture + + func _return_text(estimate: ReturnEstimate) -> String: match estimate.status: ReturnEstimate.Status.SAFE: diff --git a/game/presentation/cockpit/cockpit_interior.tscn b/game/presentation/cockpit/cockpit_interior.tscn index 3ff3e8e..d89dcae 100644 --- a/game/presentation/cockpit/cockpit_interior.tscn +++ b/game/presentation/cockpit/cockpit_interior.tscn @@ -395,6 +395,7 @@ mesh = SubResource("ScreenQuadMesh") material_override = SubResource("ScreenMaterial") [node name="SonarPlaceholder" type="Label3D" parent="SonarMonitor"] +unique_name_in_owner = true position = Vector3(0, 0.025, 0.068) pixel_size = 0.0009 font_size = 16 diff --git a/game/presentation/sonar/sonar_display.gd b/game/presentation/sonar/sonar_display.gd new file mode 100644 index 0000000..298024a --- /dev/null +++ b/game/presentation/sonar/sonar_display.gd @@ -0,0 +1,68 @@ +class_name SonarDisplay +extends Control + +const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") +const SonarScanState = preload("res://game/domain/sonar/sonar_scan_state.gd") +const BathymetricSonarRendererScript = preload( + "res://game/presentation/sonar/sonar_relief_renderer.gd" +) + +@onready var _renderer: Node = %SonarReliefRenderer +@onready var _expanded_panel: PanelContainer = %ExpandedPanel +@onready var _expanded_view: TextureRect = %ExpandedView +@onready var _status_label: Label = %StatusLabel + + +func _ready() -> void: + _expanded_view.texture = _renderer.render_texture() + _expanded_panel.visible = false + + +func present( + current_scan: SonarScan, + previous_scan: SonarScan, + visible_rows: int, + previous_opacity: float +) -> void: + if current_scan == null: + _status_label.text = "SONAR PRÊT · F POUR ÉMETTRE" + return + _renderer.present(current_scan, previous_scan, visible_rows, previous_opacity) + _status_label.text = "%s · %.0f %% · %.0f–%.0f m · %d CONTACT(S)" % [ + _state_text(current_scan.state), + current_scan.reveal_progress * 100.0, + current_scan.minimum_elevation_m, + current_scan.maximum_elevation_m, + _renderer.visible_contact_count(), + ] + + +func toggle_expanded() -> void: + _expanded_panel.visible = not _expanded_panel.visible + + +func is_expanded() -> bool: + return _expanded_panel.visible + + +func sonar_texture() -> Texture2D: + return _renderer.render_texture() + + +func built_row_count() -> int: + return _renderer.built_row_count() + + +func status_text() -> String: + return _status_label.text + + +func _state_text(state: SonarScanState.Value) -> String: + match state: + SonarScanState.Value.REVEALING: + return "RELEVÉ EN COURS" + SonarScanState.Value.RECOVERY: + return "RÉCUPÉRATION" + SonarScanState.Value.READY: + return "RELEVÉ DISPONIBLE" + return "CAPTURE" diff --git a/game/presentation/sonar/sonar_display.tscn b/game/presentation/sonar/sonar_display.tscn new file mode 100644 index 0000000..162032b --- /dev/null +++ b/game/presentation/sonar/sonar_display.tscn @@ -0,0 +1,80 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://game/presentation/sonar/sonar_display.gd" id="1_display"] +[ext_resource type="Script" path="res://game/presentation/sonar/sonar_relief_renderer.gd" id="2_renderer"] + +[sub_resource type="StyleBoxFlat" id="ExpandedStyle"] +bg_color = Color(0.008, 0.02, 0.025, 0.98) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.18, 0.68, 0.7, 1) +content_margin_left = 10.0 +content_margin_top = 10.0 +content_margin_right = 10.0 +content_margin_bottom = 10.0 + +[node name="SonarDisplay" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_display") + +[node name="SonarReliefRenderer" type="SubViewport" parent="."] +unique_name_in_owner = true +size = Vector2i(960, 540) +render_target_update_mode = 4 +script = ExtResource("2_renderer") + +[node name="ExpandedPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -530.0 +offset_top = -326.0 +offset_right = 530.0 +offset_bottom = 326.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_styles/panel = SubResource("ExpandedStyle") + +[node name="Layout" type="VBoxContainer" parent="ExpandedPanel"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Title" type="Label" parent="ExpandedPanel/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.42, 0.9, 0.91, 1) +theme_override_font_sizes/font_size = 22 +text = "SONAR BATHYMÉTRIQUE · CAP VERS LE HAUT" + +[node name="ExpandedView" type="TextureRect" parent="ExpandedPanel/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(1038, 556) +layout_mode = 2 +texture_filter = 1 +expand_mode = 1 +stretch_mode = 5 +mouse_filter = 2 + +[node name="StatusLabel" type="Label" parent="ExpandedPanel/Layout"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.7, 0.82, 0.78, 1) +theme_override_font_sizes/font_size = 16 +text = "SONAR PRÊT · F POUR ÉMETTRE" + +[node name="Legend" type="Label" parent="ExpandedPanel/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.58, 0.7, 0.71, 1) +text = "BLEU PROFOND · VERT/JAUNE HAUTEUR · ORANGE ANOMALIE · NOIR INCONNU · TAB FERMER" diff --git a/game/presentation/sonar/sonar_relief_renderer.gd b/game/presentation/sonar/sonar_relief_renderer.gd new file mode 100644 index 0000000..70361dc --- /dev/null +++ b/game/presentation/sonar/sonar_relief_renderer.gd @@ -0,0 +1,295 @@ +class_name BathymetricSonarRenderer +extends SubViewport + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd") +const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") + +const COLOR_DEEP := Color("173f91") +const COLOR_LOW := Color("168fc1") +const COLOR_MIDDLE := Color("36b979") +const COLOR_HIGH := Color("d7d93d") +const COLOR_PEAK := Color("ee5734") +const COLOR_METAL := Color("ff6b22") + +var _current_mesh_instance: MeshInstance3D +var _previous_mesh_instance: MeshInstance3D +var _contact_root: Node3D +var _current_material: StandardMaterial3D +var _previous_material: StandardMaterial3D +var _current_scan_id: StringName = &"" +var _previous_scan_id: StringName = &"" +var _built_current_rows := 0 + + +func _ready() -> void: + size = Vector2i(960, 540) + render_target_update_mode = SubViewport.UPDATE_ALWAYS + positional_shadow_atlas_size = 1024 + own_world_3d = true + _build_render_world() + + +func present( + current_scan: SonarScan, + previous_scan: SonarScan, + visible_rows: int, + previous_opacity: float +) -> void: + if current_scan == null: + return + if current_scan.scan_id != _current_scan_id: + _current_scan_id = current_scan.scan_id + _current_mesh_instance.mesh = ArrayMesh.new() + _built_current_rows = 0 + _rebuild_contacts(current_scan) + _build_current_rows(current_scan, clampi(visible_rows, 0, current_scan.samples.rows)) + _update_contact_visibility(current_scan, visible_rows) + + if previous_scan == null: + _previous_scan_id = &"" + _previous_mesh_instance.mesh = ArrayMesh.new() + _previous_mesh_instance.visible = false + else: + _previous_mesh_instance.visible = previous_opacity > 0.0 + if previous_scan.scan_id != _previous_scan_id: + _previous_scan_id = previous_scan.scan_id + _previous_mesh_instance.mesh = _build_full_mesh( + previous_scan, + _previous_material + ) + _previous_material.albedo_color.a = clampf(previous_opacity, 0.0, 1.0) + + +func render_texture() -> Texture2D: + return get_texture() + + +func built_row_count() -> int: + return _built_current_rows + + +func visible_contact_count() -> int: + var count := 0 + for child: Node in _contact_root.get_children(): + if child is Node3D and (child as Node3D).visible: + count += 1 + return count + + +func _build_render_world() -> void: + var environment_node := WorldEnvironment.new() + var environment := Environment.new() + environment.background_mode = Environment.BG_COLOR + environment.background_color = Color("020407") + environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.ambient_light_color = Color(0.18, 0.31, 0.34) + environment.ambient_light_energy = 0.42 + environment_node.environment = environment + add_child(environment_node) + + _current_material = _relief_material(false) + _previous_material = _relief_material(true) + _current_mesh_instance = MeshInstance3D.new() + _current_mesh_instance.name = "CurrentRelief" + _current_mesh_instance.mesh = ArrayMesh.new() + add_child(_current_mesh_instance) + _previous_mesh_instance = MeshInstance3D.new() + _previous_mesh_instance.name = "PreviousRelief" + _previous_mesh_instance.mesh = ArrayMesh.new() + _previous_mesh_instance.visible = false + add_child(_previous_mesh_instance) + + _contact_root = Node3D.new() + _contact_root.name = "Contacts" + add_child(_contact_root) + _add_submarine_marker() + + var camera := Camera3D.new() + camera.name = "SonarCamera" + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 116.0 + camera.far = 240.0 + camera.position = Vector3(0.0, 65.0, -30.0) + add_child(camera) + camera.look_at(Vector3(0.0, -10.0, 60.0), Vector3.UP) + camera.current = true + + var key_light := DirectionalLight3D.new() + key_light.rotation_degrees = Vector3(-52.0, -28.0, 0.0) + key_light.light_energy = 1.25 + key_light.shadow_enabled = true + add_child(key_light) + var fill_light := DirectionalLight3D.new() + fill_light.rotation_degrees = Vector3(-28.0, 145.0, 18.0) + fill_light.light_energy = 0.38 + add_child(fill_light) + + +func _relief_material(transparent: bool) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.vertex_color_use_as_albedo = true + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.cull_mode = BaseMaterial3D.CULL_DISABLED + if transparent: + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.albedo_color = Color(1.0, 1.0, 1.0, 0.0) + return material + + +func _build_current_rows(scan: SonarScan, visible_rows: int) -> void: + var mesh := _current_mesh_instance.mesh as ArrayMesh + while _built_current_rows < visible_rows: + if _built_current_rows > 0: + _add_row_band(mesh, scan, _built_current_rows, _current_material) + _built_current_rows += 1 + + +func _build_full_mesh( + scan: SonarScan, + material: StandardMaterial3D +) -> ArrayMesh: + var mesh := ArrayMesh.new() + for row in range(1, scan.samples.rows): + _add_row_band(mesh, scan, row, material) + return mesh + + +func _add_row_band( + mesh: ArrayMesh, + scan: SonarScan, + row: int, + material: StandardMaterial3D +) -> void: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var colors := PackedColorArray() + for column in range(scan.samples.columns - 1): + var top_left: SonarSample = scan.samples.sample_at(row - 1, column) + var top_right: SonarSample = scan.samples.sample_at(row - 1, column + 1) + var bottom_left: SonarSample = scan.samples.sample_at(row, column) + var bottom_right: SonarSample = scan.samples.sample_at(row, column + 1) + if not ( + top_left.observed + and top_right.observed + and bottom_left.observed + and bottom_right.observed + ): + continue + _append_triangle( + vertices, normals, colors, scan, + top_left, bottom_left, top_right + ) + _append_triangle( + vertices, normals, colors, scan, + top_right, bottom_left, bottom_right + ) + if vertices.is_empty(): + return + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_COLOR] = colors + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + mesh.surface_set_material(mesh.get_surface_count() - 1, material) + + +func _append_triangle( + vertices: PackedVector3Array, + normals: PackedVector3Array, + colors: PackedColorArray, + scan: SonarScan, + a: SonarSample, + b: SonarSample, + c: SonarSample +) -> void: + for sample: SonarSample in [a, b, c]: + vertices.append(sample.position_m) + normals.append(sample.normal) + colors.append(_sample_color(scan, sample)) + + +func _sample_color(scan: SonarScan, sample: SonarSample) -> Color: + if sample.material == SonarMaterial.Value.METAL: + return COLOR_METAL + var height := inverse_lerp( + scan.minimum_elevation_m, + scan.maximum_elevation_m, + sample.elevation_m + ) + var color: Color + if height <= 0.28: + color = COLOR_DEEP.lerp(COLOR_LOW, height / 0.28) + elif height <= 0.52: + color = COLOR_LOW.lerp(COLOR_MIDDLE, (height - 0.28) / 0.24) + elif height <= 0.76: + color = COLOR_MIDDLE.lerp(COLOR_HIGH, (height - 0.52) / 0.24) + else: + color = COLOR_HIGH.lerp(COLOR_PEAK, (height - 0.76) / 0.24) + var light_direction := Vector3(-0.35, 0.82, -0.45).normalized() + var slope_light := clampf(sample.normal.dot(light_direction), 0.0, 1.0) + var shade := lerpf(0.68, 1.0, slope_light) + var lit_color := color.lerp(Color.WHITE, sample.intensity * 0.08) + return Color( + lit_color.r * shade, + lit_color.g * shade, + lit_color.b * shade, + lit_color.a + ) + + +func _rebuild_contacts(scan: SonarScan) -> void: + for child: Node in _contact_root.get_children(): + _contact_root.remove_child(child) + child.queue_free() + for contact in scan.contacts: + var marker := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(4.0, 2.25, 4.0) + marker.mesh = box + marker.position = contact.center_m + Vector3.UP * 1.4 + marker.visible = false + marker.set_meta("forward_distance_m", contact.center_m.z) + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = COLOR_METAL + material.emission_enabled = true + material.emission = COLOR_METAL + material.emission_energy_multiplier = 4.0 + marker.material_override = material + _contact_root.add_child(marker) + + +func _update_contact_visibility(scan: SonarScan, visible_rows: int) -> void: + var maximum_visible_distance := -INF + if visible_rows > 0: + var row_ratio := float(visible_rows - 1) / float(scan.samples.rows - 1) + maximum_visible_distance = lerpf( + scan.minimum_range_m, + scan.maximum_range_m, + row_ratio + ) + for child: Node in _contact_root.get_children(): + if child is Node3D: + (child as Node3D).visible = ( + float(child.get_meta("forward_distance_m", INF)) + <= maximum_visible_distance + ) + + +func _add_submarine_marker() -> void: + var marker := MeshInstance3D.new() + marker.name = "SubmarineMarker" + var box := BoxMesh.new() + box.size = Vector3(3.2, 1.1, 5.8) + marker.mesh = box + marker.position = Vector3(0.0, 0.0, 1.0) + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = Color.WHITE + material.emission_enabled = true + material.emission = Color.WHITE + material.emission_energy_multiplier = 2.4 + marker.material_override = material + add_child(marker) diff --git a/game/world/sites/vehicle_test_environment.gd b/game/world/sites/vehicle_test_environment.gd index 11021da..aeceefd 100644 --- a/game/world/sites/vehicle_test_environment.gd +++ b/game/world/sites/vehicle_test_environment.gd @@ -1,8 +1,20 @@ class_name VehicleTestEnvironment extends Node3D +const TERRAIN_COLUMNS := 61 +const TERRAIN_ROWS := 69 +const TERRAIN_WIDTH_M := 180.0 +const TERRAIN_LENGTH_M := 200.0 +const TERRAIN_NEAR_Z := 28.0 + func _ready() -> void: + _add_bathymetric_terrain() + _place_calibration_target() + _add_suspended_particles() + + +func _add_suspended_particles() -> void: var particles := GPUParticles3D.new() particles.name = "SuspendedParticles" particles.position = Vector3(0.0, -34.0, -42.0) @@ -31,3 +43,111 @@ func _ready() -> void: particle_mesh.height = 0.07 particles.draw_pass_1 = particle_mesh add_child(particles) + + +func _add_bathymetric_terrain() -> void: + var surface := SurfaceTool.new() + surface.begin(Mesh.PRIMITIVE_TRIANGLES) + for row in range(TERRAIN_ROWS - 1): + for column in range(TERRAIN_COLUMNS - 1): + var top_left := _terrain_vertex(row, column) + var top_right := _terrain_vertex(row, column + 1) + var bottom_left := _terrain_vertex(row + 1, column) + var bottom_right := _terrain_vertex(row + 1, column + 1) + _add_terrain_triangle(surface, top_left, top_right, bottom_left) + _add_terrain_triangle(surface, top_right, bottom_right, bottom_left) + surface.generate_normals() + var mesh := surface.commit() + + var body := StaticBody3D.new() + body.name = "BathymetricTerrain" + body.set_meta("sonar_material", 1) + body.set_meta("sonar_reflectivity", 0.72) + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "Mesh" + mesh_instance.mesh = mesh + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.075, 0.19, 0.19) + material.roughness = 0.92 + mesh_instance.material_override = material + body.add_child(mesh_instance) + var collision := CollisionShape3D.new() + collision.name = "Collision" + var height_map := HeightMapShape3D.new() + height_map.map_width = TERRAIN_COLUMNS + height_map.map_depth = TERRAIN_ROWS + var height_data := PackedFloat32Array() + height_data.resize(TERRAIN_COLUMNS * TERRAIN_ROWS) + for row in TERRAIN_ROWS: + for column in TERRAIN_COLUMNS: + var vertex := _terrain_vertex(row, column) + height_data[row * TERRAIN_COLUMNS + column] = vertex.y + height_map.map_data = height_data + collision.shape = height_map + collision.position = Vector3( + 0.0, + 0.0, + TERRAIN_NEAR_Z - TERRAIN_LENGTH_M * 0.5 + ) + collision.scale = Vector3( + TERRAIN_WIDTH_M / float(TERRAIN_COLUMNS - 1), + 1.0, + TERRAIN_LENGTH_M / float(TERRAIN_ROWS - 1) + ) + body.add_child(collision) + add_child(body) + + +func _terrain_vertex(row: int, column: int) -> Vector3: + var x := lerpf( + -TERRAIN_WIDTH_M * 0.5, + TERRAIN_WIDTH_M * 0.5, + float(column) / float(TERRAIN_COLUMNS - 1) + ) + var z := TERRAIN_NEAR_Z - ( + TERRAIN_LENGTH_M * float(row) / float(TERRAIN_ROWS - 1) + ) + return Vector3(x, _terrain_height(x, z), z) + + +func _terrain_height(x: float, z: float) -> float: + var distance_ratio := clampf( + (TERRAIN_NEAR_Z - z) / TERRAIN_LENGTH_M, + 0.0, + 1.0 + ) + var base := lerpf(-40.0, -78.0, distance_ratio) + var left_ridge := 13.0 * exp( + -(pow(x + 24.0, 2.0) / 210.0 + pow(z + 48.0, 2.0) / 980.0) + ) + var right_ridge := 15.0 * exp( + -(pow(x - 25.0, 2.0) / 250.0 + pow(z + 58.0, 2.0) / 1150.0) + ) + var channel := -8.5 * exp( + -(pow(x, 2.0) / 120.0 + pow(z + 66.0, 2.0) / 2500.0) + ) + var rear_rise := 8.0 * exp( + -(pow(x + 5.0, 2.0) / 900.0 + pow(z + 125.0, 2.0) / 620.0) + ) + var undulation := 1.8 * sin(x * 0.075) * cos(z * 0.048) + return base + left_ridge + right_ridge + channel + rear_rise + undulation + + +func _add_terrain_triangle( + surface: SurfaceTool, + a: Vector3, + b: Vector3, + c: Vector3 +) -> void: + surface.set_uv(Vector2(a.x, a.z) * 0.02) + surface.add_vertex(a) + surface.set_uv(Vector2(b.x, b.z) * 0.02) + surface.add_vertex(b) + surface.set_uv(Vector2(c.x, c.z) * 0.02) + surface.add_vertex(c) + + +func _place_calibration_target() -> void: + var target := get_node_or_null("SonarCalibrationTarget") as Node3D + if target != null: + target.position.y = _terrain_height(target.position.x, target.position.z) + 2.5 diff --git a/game/world/sites/vehicle_test_environment.tscn b/game/world/sites/vehicle_test_environment.tscn index 6cdace5..ac53448 100644 --- a/game/world/sites/vehicle_test_environment.tscn +++ b/game/world/sites/vehicle_test_environment.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=12 format=3] +[gd_scene load_steps=8 format=3] [ext_resource type="Script" path="res://game/world/sites/vehicle_test_environment.gd" id="1_environment"] @@ -18,36 +18,21 @@ volumetric_fog_enabled = true volumetric_fog_density = 0.025 volumetric_fog_length = 72.0 -[sub_resource type="StandardMaterial3D" id="RockMaterial"] -albedo_color = Color(0.075, 0.19, 0.19, 1) -roughness = 0.92 - -[sub_resource type="StandardMaterial3D" id="RockMaterialLight"] -albedo_color = Color(0.105, 0.25, 0.24, 1) -roughness = 0.88 - -[sub_resource type="BoxMesh" id="SeafloorMesh"] -size = Vector3(220, 8, 250) - -[sub_resource type="BoxShape3D" id="SeafloorShape"] -size = Vector3(220, 8, 250) - -[sub_resource type="BoxMesh" id="RockMeshA"] -size = Vector3(12, 30, 14) - -[sub_resource type="BoxShape3D" id="RockShapeA"] -size = Vector3(12, 30, 14) - -[sub_resource type="BoxMesh" id="RockMeshB"] -size = Vector3(18, 42, 16) - -[sub_resource type="BoxShape3D" id="RockShapeB"] -size = Vector3(18, 42, 16) - [sub_resource type="SphereMesh" id="BeaconMesh"] radius = 0.55 height = 1.1 +[sub_resource type="StandardMaterial3D" id="MetalTargetMaterial"] +albedo_color = Color(0.24, 0.18, 0.08, 1) +metallic = 0.88 +roughness = 0.27 + +[sub_resource type="BoxMesh" id="MetalTargetMesh"] +size = Vector3(8, 5, 7) + +[sub_resource type="BoxShape3D" id="MetalTargetShape"] +size = Vector3(8, 5, 7) + [sub_resource type="StandardMaterial3D" id="BeaconMaterial"] shading_mode = 0 albedo_color = Color(0.2, 0.78, 0.9, 1) @@ -67,60 +52,6 @@ light_color = Color(0.39, 0.65, 0.7, 1) light_energy = 0.38 shadow_enabled = true -[node name="Seafloor" type="StaticBody3D" parent="."] -position = Vector3(0, -84, -58) - -[node name="Mesh" type="MeshInstance3D" parent="Seafloor"] -mesh = SubResource("SeafloorMesh") -material_override = SubResource("RockMaterial") - -[node name="Collision" type="CollisionShape3D" parent="Seafloor"] -shape = SubResource("SeafloorShape") - -[node name="RockNearLeft" type="StaticBody3D" parent="."] -position = Vector3(-10, -18, -18) -rotation_degrees = Vector3(0, -14, 8) - -[node name="Mesh" type="MeshInstance3D" parent="RockNearLeft"] -mesh = SubResource("RockMeshA") -material_override = SubResource("RockMaterialLight") - -[node name="Collision" type="CollisionShape3D" parent="RockNearLeft"] -shape = SubResource("RockShapeA") - -[node name="RockNearRight" type="StaticBody3D" parent="."] -position = Vector3(12, -22, -32) -rotation_degrees = Vector3(0, 18, -5) - -[node name="Mesh" type="MeshInstance3D" parent="RockNearRight"] -mesh = SubResource("RockMeshB") -material_override = SubResource("RockMaterial") - -[node name="Collision" type="CollisionShape3D" parent="RockNearRight"] -shape = SubResource("RockShapeB") - -[node name="RockFarLeft" type="StaticBody3D" parent="."] -position = Vector3(-18, -48, -68) -rotation_degrees = Vector3(0, 9, 4) - -[node name="Mesh" type="MeshInstance3D" parent="RockFarLeft"] -mesh = SubResource("RockMeshA") -material_override = SubResource("RockMaterialLight") - -[node name="Collision" type="CollisionShape3D" parent="RockFarLeft"] -shape = SubResource("RockShapeA") - -[node name="RockFarRight" type="StaticBody3D" parent="."] -position = Vector3(20, -58, -94) -rotation_degrees = Vector3(0, -11, -7) - -[node name="Mesh" type="MeshInstance3D" parent="RockFarRight"] -mesh = SubResource("RockMeshB") -material_override = SubResource("RockMaterial") - -[node name="Collision" type="CollisionShape3D" parent="RockFarRight"] -shape = SubResource("RockShapeB") - [node name="NavigationBeacon" type="MeshInstance3D" parent="."] position = Vector3(0, -15, -22) mesh = SubResource("BeaconMesh") @@ -130,3 +61,15 @@ material_override = SubResource("BeaconMaterial") omni_range = 12.0 light_color = Color(0.2, 0.78, 0.9, 1) light_energy = 4.0 + +[node name="SonarCalibrationTarget" type="StaticBody3D" parent="."] +position = Vector3(0, -60, -58) +metadata/sonar_material = 2 +metadata/sonar_reflectivity = 1.0 + +[node name="Mesh" type="MeshInstance3D" parent="SonarCalibrationTarget"] +mesh = SubResource("MetalTargetMesh") +material_override = SubResource("MetalTargetMaterial") + +[node name="Collision" type="CollisionShape3D" parent="SonarCalibrationTarget"] +shape = SubResource("MetalTargetShape") diff --git a/game/world/sonar/reference_bathymetry_acquisition.gd b/game/world/sonar/reference_bathymetry_acquisition.gd new file mode 100644 index 0000000..13267ab --- /dev/null +++ b/game/world/sonar/reference_bathymetry_acquisition.gd @@ -0,0 +1,89 @@ +class_name ReferenceBathymetryAcquisition +extends "res://game/application/interfaces/sonar_acquisition_port.gd" + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SonarCaptureSample = preload( + "res://game/application/sonar/sonar_capture_sample.gd" +) + +const PROBE_HEIGHT_M := 48.0 +const PROBE_DEPTH_M := 92.0 +const COLLISION_MASK := 1 + +var _world: World3D +var _excluded_rids: Array[RID] = [] + + +func _init(p_world: World3D, excluded_body: CollisionObject3D = null) -> void: + _world = p_world + if excluded_body != null: + _excluded_rids.append(excluded_body.get_rid()) + + +func capture(request: SonarCaptureRequest) -> SonarCaptureResult: + if _world == null: + return SonarCaptureResult.new(false, [], "sonar world is missing") + var request_error := request.validation_error() + if not request_error.is_empty(): + return SonarCaptureResult.new(false, [], request_error) + + var samples: Array[SonarCaptureSample] = [] + var inverse_sensor := request.sensor_transform.affine_inverse() + for row in request.rows: + var distance_ratio := float(row) / float(request.rows - 1) + var forward_distance := lerpf( + request.minimum_range_m, + request.maximum_range_m, + distance_ratio + ) + var half_width := tan(request.horizontal_opening_radians * 0.5) * forward_distance + for column in request.columns: + var lateral_ratio := float(column) / float(request.columns - 1) + var lateral_offset := lerpf(-half_width, half_width, lateral_ratio) + var ray_start := request.sensor_transform * Vector3( + lateral_offset, + PROBE_HEIGHT_M, + forward_distance + ) + var ray_end := request.sensor_transform * Vector3( + lateral_offset, + -PROBE_DEPTH_M, + forward_distance + ) + var query := PhysicsRayQueryParameters3D.create( + ray_start, + ray_end, + COLLISION_MASK, + _excluded_rids + ) + query.hit_back_faces = true + var hit := _world.direct_space_state.intersect_ray(query) + if hit.is_empty(): + continue + var world_position: Vector3 = hit["position"] + var world_normal: Vector3 = hit["normal"] + var local_position := inverse_sensor * world_position + var local_normal := (inverse_sensor.basis * world_normal).normalized() + var collider := hit.get("collider") as Object + var material := SonarMaterial.Value.ROCK + var reflectivity := 0.72 + if collider != null: + material = int(collider.get_meta("sonar_material", material)) + reflectivity = float( + collider.get_meta("sonar_reflectivity", reflectivity) + ) + var incidence := clampf(absf(world_normal.dot(Vector3.UP)), 0.18, 1.0) + var attenuation := lerpf(1.0, 0.55, distance_ratio) + var intensity := clampf(incidence * reflectivity * attenuation, 0.0, 1.0) + samples.append( + SonarCaptureSample.new( + row, + column, + local_position, + local_normal, + intensity, + local_position.y, + material + ) + ) + return SonarCaptureResult.new(true, samples) diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index 9d23069..379193f 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -82,6 +82,16 @@ check_godot_log \ "production vehicle application unit tests" \ "$QUALITY_TEMP_DIRECTORY/vehicle-application-unit-console.log" +printf 'Quality gate: production sonar application unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/sonar-application-unit.log" \ + --path . \ + --script tests/unit/run_sonar_application_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/sonar-application-unit-console.log" +check_godot_log \ + "production sonar application unit tests" \ + "$QUALITY_TEMP_DIRECTORY/sonar-application-unit-console.log" + printf 'Quality gate: production foundation integration\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \ diff --git a/tests/README.md b/tests/README.md index 16c2f46..cbab9ff 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd index 2f0f3c9..b386126 100644 --- a/tests/integration/run_vertical_slice_foundation.gd +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -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" diff --git a/tests/unit/run_sonar_application_tests.gd b/tests/unit/run_sonar_application_tests.gd new file mode 100644 index 0000000..ee4f31d --- /dev/null +++ b/tests/unit/run_sonar_application_tests.gd @@ -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)