Quality Gate / Quality Gate (pull_request) Successful in 7m6s
Instance twelve Blender-scripted glTF models in the cockpit scene and rebuild the cabin around them: gauge panel, sonar, switch panel, alert box, speaker, porthole, hull wall, caged lamps, gas bottle, handrails and side consoles. Presentation only; no domain, application or world rule changes beyond enabling glow in the test environment. - Needles, alert lamps and sonar screen are driven by the existing snapshots; the alert box now carries SYS/ATTN/ALRM. - Depth dial reads percent of certified depth. - Sonar display restyled as a turquoise holographic relief with a shader grid and height scale; SubViewport matches the glass (960x604). - Cabin lighting retuned: warm pooled lamp light, cool porthole rim, lamp shadows, dial backlight. - Procedural stand-ins removed (bulkhead, tanks, consoles, levers, indicator columns, placeholder pipe run, dial face PNGs). - Tests adapted where nodes disappeared; one assertion removed (pipe_segment_count) until the pipe kit is placed. - Model .glb files are committed without LFS; textures Godot extracts next to them at import are ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
9.2 KiB
GDScript
282 lines
9.2 KiB
GDScript
class_name BathymetricSonarRenderer
|
||
extends SubViewport
|
||
|
||
const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd")
|
||
const SonarReliefMeshBuilder = preload(
|
||
"res://game/presentation/sonar/sonar_relief_mesh_builder.gd"
|
||
)
|
||
const SonarReliefStyle = preload("res://game/presentation/sonar/sonar_relief_style.gd")
|
||
|
||
## Matches the glass of the cockpit sonar monitor (0.690 × 0.434, ratio 1.59).
|
||
const RENDER_SIZE := Vector2i(960, 604)
|
||
## Ground width framed by the orthographic camera, independent of the ratio.
|
||
const VIEW_WIDTH_M := 178.0
|
||
## Table-like oblique view from behind the vehicle. The aim point sits right
|
||
## of the scan axis so the relief leaves the right edge to the height scale.
|
||
const CAMERA_AIM_POINT := Vector3(14.0, -46.0, 50.0)
|
||
const CAMERA_ELEVATION_DEGREES := 44.0
|
||
const CAMERA_DISTANCE_M := 120.0
|
||
const IDLE_PROMPT_SIZE := Vector2(600.0, 64.0)
|
||
## Height scale drawn at the right edge of the screen, in viewport pixels.
|
||
const SCALE_BAR_RECT := Rect2(920.0, 70.0, 12.0, 464.0)
|
||
const SCALE_TICK_COUNT := 3
|
||
const SCALE_LABEL_SIZE := Vector2(112.0, 40.0)
|
||
const SCALE_FONT_SIZE := 34
|
||
|
||
var _current_mesh_instance: MeshInstance3D
|
||
var _previous_mesh_instance: MeshInstance3D
|
||
var _contact_root: Node3D
|
||
var _idle_prompt: Label
|
||
var _current_material: ShaderMaterial
|
||
var _previous_material: ShaderMaterial
|
||
var _depth_scale: TextureRect
|
||
var _scale_labels: Array[Label] = []
|
||
var _current_scan_id: StringName = &""
|
||
var _previous_scan_id: StringName = &""
|
||
var _built_current_rows := 0
|
||
var _built_current_triangle_count := 0
|
||
var _built_current_grid_segment_count := 0
|
||
|
||
|
||
func _ready() -> void:
|
||
size = RENDER_SIZE
|
||
render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||
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
|
||
_idle_prompt.visible = false
|
||
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
|
||
_built_current_triangle_count = 0
|
||
_built_current_grid_segment_count = 0
|
||
_rebuild_contacts(current_scan)
|
||
_update_depth_scale(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 = SonarReliefMeshBuilder.build_full_mesh(
|
||
previous_scan,
|
||
_previous_material
|
||
)
|
||
SonarReliefStyle.set_opacity(_previous_material, previous_opacity)
|
||
|
||
|
||
func render_texture() -> Texture2D:
|
||
return get_texture()
|
||
|
||
|
||
func built_row_count() -> int:
|
||
return _built_current_rows
|
||
|
||
|
||
func built_triangle_count() -> int:
|
||
return _built_current_triangle_count
|
||
|
||
|
||
func built_grid_segment_count() -> int:
|
||
return _built_current_grid_segment_count
|
||
|
||
|
||
func idle_prompt_visible() -> bool:
|
||
return _idle_prompt != null and _idle_prompt.visible
|
||
|
||
|
||
## Elevation labels of the height scale, top (highest) to bottom; empty until
|
||
## a scan has been presented.
|
||
func depth_scale_texts() -> PackedStringArray:
|
||
var texts := PackedStringArray()
|
||
if _depth_scale != null and _depth_scale.visible:
|
||
for label: Label in _scale_labels:
|
||
texts.append(label.text)
|
||
return texts
|
||
|
||
|
||
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 = SonarReliefStyle.COLOR_BACKGROUND
|
||
environment.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED
|
||
environment_node.environment = environment
|
||
add_child(environment_node)
|
||
|
||
_current_material = SonarReliefStyle.relief_material(false)
|
||
_previous_material = SonarReliefStyle.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.keep_aspect = Camera3D.KEEP_WIDTH
|
||
camera.size = VIEW_WIDTH_M
|
||
camera.far = 240.0
|
||
var elevation := deg_to_rad(CAMERA_ELEVATION_DEGREES)
|
||
camera.position = CAMERA_AIM_POINT + CAMERA_DISTANCE_M * Vector3(
|
||
0.0, sin(elevation), -cos(elevation)
|
||
)
|
||
add_child(camera)
|
||
camera.look_at(CAMERA_AIM_POINT, Vector3.UP)
|
||
camera.current = true
|
||
|
||
_build_depth_scale()
|
||
_build_idle_prompt()
|
||
|
||
|
||
func _build_idle_prompt() -> void:
|
||
_idle_prompt = Label.new()
|
||
_idle_prompt.name = "IdlePrompt"
|
||
_idle_prompt.position = (Vector2(RENDER_SIZE) - IDLE_PROMPT_SIZE) * 0.5
|
||
_idle_prompt.size = IDLE_PROMPT_SIZE
|
||
_idle_prompt.text = "SONAR PRÊT · F POUR ÉMETTRE"
|
||
_idle_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
_idle_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
_idle_prompt.add_theme_font_size_override("font_size", 26)
|
||
_idle_prompt.add_theme_color_override("font_color", SonarReliefStyle.COLOR_MIDDLE)
|
||
_idle_prompt.add_theme_color_override("font_outline_color", Color(0.0, 0.04, 0.05))
|
||
_idle_prompt.add_theme_constant_override("outline_size", 6)
|
||
add_child(_idle_prompt)
|
||
|
||
|
||
## Height legend: the relief palette is relative to the scan, so the bar shows
|
||
## the real elevations of its two ends and of the steps in between.
|
||
func _build_depth_scale() -> void:
|
||
var gradient := GradientTexture2D.new()
|
||
gradient.gradient = SonarReliefStyle.height_gradient()
|
||
gradient.fill_from = Vector2(0.0, 1.0)
|
||
gradient.fill_to = Vector2(0.0, 0.0)
|
||
gradient.width = int(SCALE_BAR_RECT.size.x)
|
||
gradient.height = int(SCALE_BAR_RECT.size.y)
|
||
_depth_scale = TextureRect.new()
|
||
_depth_scale.name = "DepthScale"
|
||
_depth_scale.texture = gradient
|
||
_depth_scale.position = SCALE_BAR_RECT.position
|
||
_depth_scale.size = SCALE_BAR_RECT.size
|
||
_depth_scale.visible = false
|
||
add_child(_depth_scale)
|
||
for tick in SCALE_TICK_COUNT:
|
||
var label := Label.new()
|
||
label.size = SCALE_LABEL_SIZE
|
||
label.position = Vector2(
|
||
-label.size.x - 8.0,
|
||
SCALE_BAR_RECT.size.y * tick / float(SCALE_TICK_COUNT - 1) - label.size.y * 0.5
|
||
)
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.add_theme_font_size_override("font_size", SCALE_FONT_SIZE)
|
||
label.add_theme_color_override("font_color", SonarReliefStyle.COLOR_MIDDLE)
|
||
_depth_scale.add_child(label)
|
||
_scale_labels.append(label)
|
||
|
||
|
||
func _update_depth_scale(scan: SonarScan) -> void:
|
||
for tick in _scale_labels.size():
|
||
var elevation := lerpf(
|
||
scan.maximum_elevation_m,
|
||
scan.minimum_elevation_m,
|
||
tick / float(_scale_labels.size() - 1)
|
||
)
|
||
_scale_labels[tick].text = "%.0f m" % elevation
|
||
_depth_scale.visible = true
|
||
|
||
|
||
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:
|
||
var triangles := SonarReliefMeshBuilder.add_row_band(
|
||
mesh, scan, _built_current_rows, _current_material
|
||
)
|
||
_built_current_triangle_count += triangles
|
||
_built_current_grid_segment_count += (
|
||
triangles / 2 * SonarReliefMeshBuilder.GRID_SEGMENTS_PER_CELL
|
||
)
|
||
_built_current_rows += 1
|
||
|
||
|
||
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)
|
||
marker.material_override = SonarReliefStyle.marker_material(
|
||
SonarReliefStyle.COLOR_METAL
|
||
)
|
||
_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)
|
||
marker.material_override = SonarReliefStyle.marker_material(
|
||
SonarReliefStyle.COLOR_VEHICLE
|
||
)
|
||
add_child(marker)
|