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

+7
View File
@@ -24,6 +24,11 @@ production sous `game/` n'en dépend.
à l'éjection et aux états terminaux. à l'éjection et aux états terminaux.
- Pause qui suspend les ticks, confirmations de sortie et premier remappage avec - Pause qui suspend les ticks, confirmations de sortie et premier remappage avec
détection des conflits. 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 ## Prototypes validés
@@ -83,6 +88,8 @@ Pour relancer les POC validés :
| Souris | Déplacer le regard dans le hublot uniquement | | Souris | Déplacer le regard dans le hublot uniquement |
| X maintenu | Éjection volontaire | | X maintenu | Éjection volontaire |
| R | Recentrer le regard | | R | Recentrer le regard |
| F | Émettre un relevé sonar |
| Tab | Agrandir / réduire le sonar |
| Échap | Pause / retour | | Échap | Pause / retour |
Les commandes propres aux POC restent décrites dans leurs documents respectifs. Les commandes propres aux POC restent décrites dans leurs documents respectifs.
+9 -2
View File
@@ -3,8 +3,8 @@
## Statut ## Statut
- Date : 10 août 2026. - Date : 10 août 2026.
- Dernière révision d'implémentation : 16 août 2026. - Dernière révision d'implémentation : 2 octobre 2026.
- État : cadrage approuvé, tranches 0 et 1 implémentées. - É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. - Jalon : verticale jouable, après validation des POC A et B.
- Durée directrice du plan général : quatre à six mois. - Durée directrice du plan général : quatre à six mois.
- Moteur figé pour le jalon : Godot 4.7.2. - 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 ### 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`. - Définir `SonarSampleBuffer`, `SonarContact` et `SonarScan`.
- Séparer acquisition, reconstruction et présentation. - Séparer acquisition, reconstruction et présentation.
- Réécrire la bathymétrie validée sans importer le POC B et comparer le - Réécrire la bathymétrie validée sans importer le POC B et comparer le
@@ -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")
@@ -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 ""
@@ -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
@@ -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)
@@ -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
+298
View File
@@ -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()
)
@@ -18,6 +18,7 @@ const VehicleCollisionSample = preload(
"res://game/application/vehicle/vehicle_collision_sample.gd" "res://game/application/vehicle/vehicle_collision_sample.gd"
) )
const PilotUseCase = preload("res://game/application/vehicle/pilot_use_case.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 SubmarineActor = preload("res://game/world/submarine/submarine_actor.gd")
const SubmarineMotionAdapter = preload( const SubmarineMotionAdapter = preload(
"res://game/world/submarine/submarine_motion_adapter.gd" "res://game/world/submarine/submarine_motion_adapter.gd"
@@ -25,6 +26,9 @@ const SubmarineMotionAdapter = preload(
const SubmarineTelemetryAdapter = preload( const SubmarineTelemetryAdapter = preload(
"res://game/world/submarine/submarine_telemetry_adapter.gd" "res://game/world/submarine/submarine_telemetry_adapter.gd"
) )
const ReferenceBathymetryAcquisition = preload(
"res://game/world/sonar/reference_bathymetry_acquisition.gd"
)
const ProductionInputAdapter = preload( const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd" "res://game/infrastructure/input/production_input_adapter.gd"
) )
@@ -35,6 +39,7 @@ const CockpitInstrumentPanel = preload(
"res://game/presentation/cockpit/cockpit_instrument_panel.gd" "res://game/presentation/cockpit/cockpit_instrument_panel.gd"
) )
const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.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 MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd")
const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd") const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd")
const ControlsSettingsScreen = preload( const ControlsSettingsScreen = preload(
@@ -50,6 +55,9 @@ const VEHICLE_TEST_WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/vehicle_test_world.tscn" "res://game/composition/vertical_slice/vehicle_test_world.tscn"
) )
const COCKPIT_HUD_SCENE = preload("res://game/presentation/cockpit/cockpit_hud.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 PAUSE_MENU_SCENE = preload("res://game/presentation/pause/pause_menu.tscn")
const CONTROLS_SETTINGS_SCENE = preload( const CONTROLS_SETTINGS_SCENE = preload(
"res://game/presentation/settings/controls_settings_screen.tscn" "res://game/presentation/settings/controls_settings_screen.tscn"
@@ -75,6 +83,8 @@ var _actor: SubmarineActor
var _camera_controller: CockpitCameraController var _camera_controller: CockpitCameraController
var _instrument_panel: CockpitInstrumentPanel var _instrument_panel: CockpitInstrumentPanel
var _hud: CockpitHud var _hud: CockpitHud
var _sonar_service: SonarService
var _sonar_display: SonarDisplay
var _latest_update: DiveUpdate var _latest_update: DiveUpdate
var _active_world: Node3D var _active_world: Node3D
var _active_screen: Control var _active_screen: Control
@@ -90,6 +100,8 @@ var _dive_terminal := false
func _ready() -> void: func _ready() -> void:
_input_adapter.pause_requested.connect(_on_pause_requested) _input_adapter.pause_requested.connect(_on_pause_requested)
_input_adapter.rebind_result_available.connect(_on_rebind_result_available) _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() _show_main_menu()
@@ -115,10 +127,18 @@ func _start_vehicle_test() -> void:
_instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel _instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel
_hud = COCKPIT_HUD_SCENE.instantiate() as CockpitHud _hud = COCKPIT_HUD_SCENE.instantiate() as CockpitHud
_replace_active_screen(_hud) _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) var motion_adapter := SubmarineMotionAdapter.new(_actor)
_pilot_use_case = PilotUseCase.new(motion_adapter) _pilot_use_case = PilotUseCase.new(motion_adapter)
_telemetry_adapter = SubmarineTelemetryAdapter.new(_actor) _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) _runner = DiveTickRunner.new(session)
_latest_update = _runner.initial_update() _latest_update = _runner.initial_update()
_vehicle_command_sequence = 0 _vehicle_command_sequence = 0
@@ -177,6 +197,8 @@ func _physics_process(delta: float) -> void:
) )
_hud.present(_latest_update, telemetry) _hud.present(_latest_update, telemetry)
_instrument_panel.present(_latest_update, telemetry) _instrument_panel.present(_latest_update, telemetry)
_sonar_service.advance(delta)
_present_sonar()
if _latest_update.snapshot.is_terminal(): if _latest_update.snapshot.is_terminal():
_finish_dive_motion() _finish_dive_motion()
@@ -213,6 +235,14 @@ func active_overlay() -> Control:
return _active_overlay return _active_overlay
func sonar_service() -> SonarService:
return _sonar_service
func sonar_display() -> SonarDisplay:
return _sonar_display
func _finish_dive_motion() -> void: func _finish_dive_motion() -> void:
_dive_terminal = true _dive_terminal = true
_input_adapter.set_gameplay_enabled(false) _input_adapter.set_gameplay_enabled(false)
@@ -242,6 +272,41 @@ func _on_collision_sampled(sample: VehicleCollisionSample) -> void:
_pending_collision_samples.append(sample) _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: func _on_pause_requested() -> void:
if _runner == null: if _runner == null:
return return
@@ -382,6 +447,8 @@ func _show_main_menu() -> void:
_camera_controller = null _camera_controller = null
_instrument_panel = null _instrument_panel = null
_hud = null _hud = null
_sonar_service = null
_sonar_display = null
_latest_update = null _latest_update = null
_pending_collision_samples.clear() _pending_collision_samples.clear()
_dive_terminal = false _dive_terminal = false
+39
View File
@@ -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 ""
+12
View File
@@ -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
+53
View File
@@ -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)
+115
View File
@@ -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
+119
View File
@@ -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 ""
+13
View File
@@ -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
@@ -3,6 +3,8 @@ extends Node
signal pause_requested signal pause_requested
signal rebind_result_available(result: RebindResult) 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 VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd")
const CameraLookCommand = preload("res://game/application/vehicle/camera_look_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_HIGH_THRUST := &"vehicle_high_thrust"
const ACTION_EJECT := &"dive_eject" const ACTION_EJECT := &"dive_eject"
const ACTION_RECENTER_CAMERA := &"camera_recenter" 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 ACTION_PAUSE := &"pause_game"
const BINDABLE_ACTIONS: Array[StringName] = [ const BINDABLE_ACTIONS: Array[StringName] = [
@@ -32,6 +36,8 @@ const BINDABLE_ACTIONS: Array[StringName] = [
ACTION_HIGH_THRUST, ACTION_HIGH_THRUST,
ACTION_EJECT, ACTION_EJECT,
ACTION_RECENTER_CAMERA, ACTION_RECENTER_CAMERA,
ACTION_SONAR_SCAN,
ACTION_SONAR_VIEW_TOGGLE,
] ]
const ACTION_LABELS := { const ACTION_LABELS := {
@@ -44,6 +50,8 @@ const ACTION_LABELS := {
ACTION_HIGH_THRUST: "Forte poussée", ACTION_HIGH_THRUST: "Forte poussée",
ACTION_EJECT: "Éjection maintenue", ACTION_EJECT: "Éjection maintenue",
ACTION_RECENTER_CAMERA: "Recentrer le regard", ACTION_RECENTER_CAMERA: "Recentrer le regard",
ACTION_SONAR_SCAN: "Émettre un relevé sonar",
ACTION_SONAR_VIEW_TOGGLE: "Agrandir le sonar",
} }
const DEFAULT_KEYS := { const DEFAULT_KEYS := {
@@ -56,6 +64,8 @@ const DEFAULT_KEYS := {
ACTION_HIGH_THRUST: KEY_SHIFT, ACTION_HIGH_THRUST: KEY_SHIFT,
ACTION_EJECT: KEY_X, ACTION_EJECT: KEY_X,
ACTION_RECENTER_CAMERA: KEY_R, ACTION_RECENTER_CAMERA: KEY_R,
ACTION_SONAR_SCAN: KEY_F,
ACTION_SONAR_VIEW_TOGGLE: KEY_TAB,
ACTION_PAUSE: KEY_ESCAPE, ACTION_PAUSE: KEY_ESCAPE,
} }
@@ -175,6 +185,15 @@ func _unhandled_input(event: InputEvent) -> void:
pause_requested.emit() pause_requested.emit()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return 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 ( if (
_gameplay_enabled _gameplay_enabled
and not get_tree().paused and not get_tree().paused
@@ -15,6 +15,10 @@ const COLOR_CRITICAL := Color(1.0, 0.42, 0.31, 1.0)
@onready var _integrity_label: Label3D = %IntegrityLabel @onready var _integrity_label: Label3D = %IntegrityLabel
@onready var _return_label: Label3D = %ReturnLabel @onready var _return_label: Label3D = %ReturnLabel
@onready var _status_label: Label3D = %StatusLabel @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: func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void:
@@ -79,6 +83,22 @@ func return_text() -> String:
return _return_label.text 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: func _return_text(estimate: ReturnEstimate) -> String:
match estimate.status: match estimate.status:
ReturnEstimate.Status.SAFE: ReturnEstimate.Status.SAFE:
@@ -395,6 +395,7 @@ mesh = SubResource("ScreenQuadMesh")
material_override = SubResource("ScreenMaterial") material_override = SubResource("ScreenMaterial")
[node name="SonarPlaceholder" type="Label3D" parent="SonarMonitor"] [node name="SonarPlaceholder" type="Label3D" parent="SonarMonitor"]
unique_name_in_owner = true
position = Vector3(0, 0.025, 0.068) position = Vector3(0, 0.025, 0.068)
pixel_size = 0.0009 pixel_size = 0.0009
font_size = 16 font_size = 16
+68
View File
@@ -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"
@@ -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"
@@ -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)
@@ -1,8 +1,20 @@
class_name VehicleTestEnvironment class_name VehicleTestEnvironment
extends Node3D 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: func _ready() -> void:
_add_bathymetric_terrain()
_place_calibration_target()
_add_suspended_particles()
func _add_suspended_particles() -> void:
var particles := GPUParticles3D.new() var particles := GPUParticles3D.new()
particles.name = "SuspendedParticles" particles.name = "SuspendedParticles"
particles.position = Vector3(0.0, -34.0, -42.0) particles.position = Vector3(0.0, -34.0, -42.0)
@@ -31,3 +43,111 @@ func _ready() -> void:
particle_mesh.height = 0.07 particle_mesh.height = 0.07
particles.draw_pass_1 = particle_mesh particles.draw_pass_1 = particle_mesh
add_child(particles) 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
+24 -81
View File
@@ -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"] [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_density = 0.025
volumetric_fog_length = 72.0 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"] [sub_resource type="SphereMesh" id="BeaconMesh"]
radius = 0.55 radius = 0.55
height = 1.1 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"] [sub_resource type="StandardMaterial3D" id="BeaconMaterial"]
shading_mode = 0 shading_mode = 0
albedo_color = Color(0.2, 0.78, 0.9, 1) 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 light_energy = 0.38
shadow_enabled = true 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="."] [node name="NavigationBeacon" type="MeshInstance3D" parent="."]
position = Vector3(0, -15, -22) position = Vector3(0, -15, -22)
mesh = SubResource("BeaconMesh") mesh = SubResource("BeaconMesh")
@@ -130,3 +61,15 @@ material_override = SubResource("BeaconMaterial")
omni_range = 12.0 omni_range = 12.0
light_color = Color(0.2, 0.78, 0.9, 1) light_color = Color(0.2, 0.78, 0.9, 1)
light_energy = 4.0 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")
@@ -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)
+10
View File
@@ -82,6 +82,16 @@ check_godot_log \
"production vehicle application unit tests" \ "production vehicle application unit tests" \
"$QUALITY_TEMP_DIRECTORY/vehicle-application-unit-console.log" "$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' printf 'Quality gate: production foundation integration\n'
"$GODOT_EXECUTABLE" --headless \ "$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \ --log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \
+4 -1
View File
@@ -15,6 +15,9 @@ Suites de production actuelles :
pression, collisions, alertes et états terminaux ; pression, collisions, alertes et états terminaux ;
- `unit/run_vehicle_application_tests.gd` : validation et séquençage des - `unit/run_vehicle_application_tests.gd` : validation et séquençage des
commandes de pilotage et de caméra ; 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 - `integration/run_vertical_slice_foundation.gd` : composition, mouvement
inertiel, caméra indépendante, cockpit 3D fixe, instruments physiques, couche 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" "res://game/infrastructure/input/production_input_adapter.gd"
) )
const TEST_COUNT := 40 const TEST_COUNT := 51
var _failures: Array[String] = [] var _failures: Array[String] = []
@@ -78,11 +78,15 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var instruments = composition.cockpit_instrument_panel() var instruments = composition.cockpit_instrument_panel()
var adapter := composition.input_adapter() as ProductionInputAdapter var adapter := composition.input_adapter() as ProductionInputAdapter
var hud := composition.active_screen() as CockpitHud 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(composition.active_world() != null, "vehicle test world is assembled")
_expect(actor != null, "production submarine actor is assembled") _expect(actor != null, "production submarine actor is assembled")
_expect(camera != null, "cockpit camera is attached to the submarine") _expect(camera != null, "cockpit camera is attached to the submarine")
_expect(instruments != null, "physical cockpit instruments are assembled") _expect(instruments != null, "physical cockpit instruments are assembled")
_expect(hud != null, "critical HUD overlay is 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") _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: if actor == null or camera == null or instruments == null or adapter == null or hud == null:
return 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("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("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(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 start_position: Vector3 = actor.global_position
var starting_battery: float = composition.latest_update().snapshot.battery.remaining_kwh 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") _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_sequence: int = composition.latest_update().snapshot.tick_sequence
var paused_sonar_progress: float = sonar_service.current_scan().reveal_progress
adapter.pause_requested.emit() adapter.pause_requested.emit()
await process_frame await process_frame
_expect(paused, "pause menu suspends the scene tree") _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, composition.latest_update().snapshot.tick_sequence == paused_sequence,
"dive ticks are frozen while paused" "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 var controls_button := pause_menu.get_node("%ControlsButton") as Button
controls_button.pressed.emit() controls_button.pressed.emit()
await process_frame await process_frame
var controls := composition.active_overlay() as ControlsSettingsScreen var controls := composition.active_overlay() as ControlsSettingsScreen
_expect(controls != null, "controls settings open over the paused dive") _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 = [] var rebind_results: Array = []
adapter.rebind_result_available.connect( adapter.rebind_result_available.connect(
@@ -186,7 +213,10 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
adapter.pause_requested.emit() adapter.pause_requested.emit()
await process_frame await process_frame
_expect(not paused, "second escape resumes the dive") _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( _expect(
composition.latest_update().snapshot.tick_sequence > paused_sequence, composition.latest_update().snapshot.tick_sequence > paused_sequence,
"fixed dive ticks resume after pause" "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)