Files
Ransom-s-wake/game/presentation/sonar/sonar_relief_renderer.gd
OlivierandClaude Opus 5.5 2c1ea50aba
Quality Gate / Quality Gate (pull_request) Successful in 7m6s
feat(cockpit): replace procedural cockpit with twelve scripted models
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>
2026-10-06 21:46:46 +02:00

282 lines
9.2 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)