Add production bathymetric sonar slice
This commit is contained in:
1 parent
a18e82eb44
commit
7613daee84
28 files changed
+1825
-87
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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" \
|
||||
|
||||
+4
-1
@@ -15,6 +15,9 @@ Suites de production actuelles :
|
||||
pression, collisions, alertes et états terminaux ;
|
||||
- `unit/run_vehicle_application_tests.gd` : validation et séquençage des
|
||||
commandes de pilotage et de caméra ;
|
||||
- `unit/run_sonar_application_tests.gd` : buffer compact scellé, validation des
|
||||
captures, contacts séparés et temporalité de révélation/récupération ;
|
||||
- `integration/run_vertical_slice_foundation.gd` : composition, mouvement
|
||||
inertiel, caméra indépendante, cockpit 3D fixe, instruments physiques, couche
|
||||
d'alerte, pause des ticks et remappage avec conflit.
|
||||
d'alerte, sonar bathymétrique partagé, pause des ticks et remappage avec
|
||||
conflit.
|
||||
@@ -13,7 +13,7 @@ const ProductionInputAdapter = preload(
|
||||
"res://game/infrastructure/input/production_input_adapter.gd"
|
||||
)
|
||||
|
||||
const TEST_COUNT := 40
|
||||
const TEST_COUNT := 51
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
@@ -78,11 +78,15 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
|
||||
var instruments = composition.cockpit_instrument_panel()
|
||||
var adapter := composition.input_adapter() as ProductionInputAdapter
|
||||
var hud := composition.active_screen() as CockpitHud
|
||||
var sonar_service = composition.sonar_service()
|
||||
var sonar_display = composition.sonar_display()
|
||||
_expect(composition.active_world() != null, "vehicle test world is assembled")
|
||||
_expect(actor != null, "production submarine actor is assembled")
|
||||
_expect(camera != null, "cockpit camera is attached to the submarine")
|
||||
_expect(instruments != null, "physical cockpit instruments are assembled")
|
||||
_expect(hud != null, "critical HUD overlay is assembled")
|
||||
_expect(sonar_service != null, "production sonar service is assembled")
|
||||
_expect(sonar_display != null, "shared sonar presentation is assembled")
|
||||
_expect(composition.latest_update() != null, "dive session starts with a typed update")
|
||||
if actor == null or camera == null or instruments == null or adapter == null or hud == null:
|
||||
return
|
||||
@@ -94,6 +98,24 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
|
||||
_expect("L/min" in instruments.resources_text(), "oxygen instrument has volume and consumption")
|
||||
_expect("bar" in instruments.integrity_text(), "hull instrument has current and certified pressure")
|
||||
_expect(not hud.alert_overlay_visible(), "normal telemetry stays on physical instruments only")
|
||||
_expect(
|
||||
instruments.sonar_texture() == sonar_display.sonar_texture(),
|
||||
"physical and expanded sonar views share one texture"
|
||||
)
|
||||
|
||||
adapter.sonar_scan_requested.emit()
|
||||
var first_scan = sonar_service.current_scan()
|
||||
_expect(first_scan != null, "manual sonar command publishes a typed scan")
|
||||
_expect(first_scan.samples.observed_count() > 1000, "reference acquisition captures dense bathymetry")
|
||||
_expect(first_scan.contacts.size() >= 1, "metal calibration target becomes a distinct contact")
|
||||
_expect("RELEVÉ EN COURS" in sonar_display.status_text(), "sonar display reports progressive reveal")
|
||||
for frame in 8:
|
||||
await physics_frame
|
||||
_expect(sonar_display.built_row_count() > 0, "renderer reveals near rows incrementally")
|
||||
adapter.sonar_view_toggle_requested.emit()
|
||||
_expect(sonar_display.is_expanded(), "sonar can expand without pausing the dive")
|
||||
adapter.sonar_view_toggle_requested.emit()
|
||||
_expect(not sonar_display.is_expanded(), "expanded sonar returns to cockpit view")
|
||||
|
||||
var start_position: Vector3 = actor.global_position
|
||||
var starting_battery: float = composition.latest_update().snapshot.battery.remaining_kwh
|
||||
@@ -130,6 +152,7 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
|
||||
_expect(instruments.global_transform.is_equal_approx(cockpit_transform), "cockpit remains fixed while the player looks around")
|
||||
|
||||
var paused_sequence: int = composition.latest_update().snapshot.tick_sequence
|
||||
var paused_sonar_progress: float = sonar_service.current_scan().reveal_progress
|
||||
adapter.pause_requested.emit()
|
||||
await process_frame
|
||||
_expect(paused, "pause menu suspends the scene tree")
|
||||
@@ -141,13 +164,17 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
|
||||
composition.latest_update().snapshot.tick_sequence == paused_sequence,
|
||||
"dive ticks are frozen while paused"
|
||||
)
|
||||
_expect(
|
||||
is_equal_approx(sonar_service.current_scan().reveal_progress, paused_sonar_progress),
|
||||
"sonar reveal is frozen while paused"
|
||||
)
|
||||
|
||||
var controls_button := pause_menu.get_node("%ControlsButton") as Button
|
||||
controls_button.pressed.emit()
|
||||
await process_frame
|
||||
var controls := composition.active_overlay() as ControlsSettingsScreen
|
||||
_expect(controls != null, "controls settings open over the paused dive")
|
||||
_expect(adapter.binding_snapshots().size() == 9, "all gameplay keyboard bindings are exposed")
|
||||
_expect(adapter.binding_snapshots().size() == 11, "all gameplay keyboard bindings are exposed")
|
||||
|
||||
var rebind_results: Array = []
|
||||
adapter.rebind_result_available.connect(
|
||||
@@ -186,7 +213,10 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
|
||||
adapter.pause_requested.emit()
|
||||
await process_frame
|
||||
_expect(not paused, "second escape resumes the dive")
|
||||
await physics_frame
|
||||
for frame in 3:
|
||||
await physics_frame
|
||||
if composition.latest_update().snapshot.tick_sequence > paused_sequence:
|
||||
break
|
||||
_expect(
|
||||
composition.latest_update().snapshot.tick_sequence > paused_sequence,
|
||||
"fixed dive ticks resume after pause"
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user