Refine cockpit porthole and indicators

This commit is contained in:
chindit committed 2026-10-04 09:30:12 +02:00
1 parent 53288b48c8
commit c2b3ac42a3
8 files changed
+298 -10

No files matched your search

+4 -3
View File
@@ -18,9 +18,10 @@ production sous `game/` n'en dépend.
- `DiveUpdate` cohérent composé d'un snapshot et d'événements typés. - `DiveUpdate` cohérent composé d'un snapshot et d'événements typés.
- Acteur sous-marin de production derrière des contrats de commande et de - Acteur sous-marin de production derrière des contrats de commande et de
télémétrie typés. télémétrie typés.
- Habitacle 3D solidaire de la coque : hublot occultant, consoles, commandes, - Habitacle 3D solidaire de la coque : hublot à cloison annulaire bombée,
éclairage, structure de coque renforcée, conduites, jauges analogiques et consoles, commandes, éclairage, structure de coque renforcée, conduites,
tableau d'instruments structuré, et emplacement réservé au sonar. jauges analogiques graduées, tableau d'instruments structuré et emplacement
réservé au sonar.
- Instruments physiques à valeurs réelles ; le HUD reste réservé aux alertes, - Instruments physiques à valeurs réelles ; le HUD reste réservé aux alertes,
à l'éjection et aux états terminaux. à l'éjection et aux états terminaux.
- Pause qui suspend les ticks, confirmations de sortie et premier remappage avec - Pause qui suspend les ticks, confirmations de sortie et premier remappage avec
+7 -1
View File
@@ -3,7 +3,7 @@
## Statut ## Statut
- Date : 10 août 2026. - Date : 10 août 2026.
- Dernière révision d'implémentation : 3 octobre 2026. - Dernière révision d'implémentation : 4 octobre 2026.
- État : cadrage approuvé, tranches 0, 1 et 1.5 implémentées ; tranche 2 en cours. - État : cadrage approuvé, tranches 0, 1 et 1.5 implémentées ; tranche 2 en cours.
- Jalon : verticale jouable, après validation des POC A et B. - Jalon : verticale jouable, après validation des POC A et B.
- Durée directrice du plan général : quatre à six mois. - Durée directrice du plan général : quatre à six mois.
@@ -815,6 +815,12 @@ Les jauges de charge batterie et de pression coque sont regroupées dans un
binnacle solidaire du tableau de bord, hors du hublot. La façade du tableau est binnacle solidaire du tableau de bord, hors du hublot. La façade du tableau est
structurée par des rails, séparateurs, vis et banques de commutateurs physiques. structurée par des rails, séparateurs, vis et banques de commutateurs physiques.
Passe du 4 octobre 2026 : les quatre volumes rectangulaires autour du hublot
sont remplacés par douze panneaux annulaires bombés, séparés par des joints fins
et ceinturés par un raidisseur. Les cadrans utilisent des graduations physiques
et les témoins colorés provisoires sont remplacés par trois voyants encastrés ;
un seul voyant reflète l'état d'alerte courant.
- Construire une coque intérieure 3D fixe par rapport au véhicule, indépendante - Construire une coque intérieure 3D fixe par rapport au véhicule, indépendante
du regard de la caméra. du regard de la caméra.
- Remplacer le cercle Canvas par un hublot 3D occultant avec cadre, joint, verre, - Remplacer le cercle Canvas par un hublot 3D occultant avec cadre, joint, verre,
@@ -3,16 +3,20 @@ extends Node3D
const CockpitMaterialPalette = preload("res://game/presentation/cockpit/cockpit_material_palette.gd") const CockpitMaterialPalette = preload("res://game/presentation/cockpit/cockpit_material_palette.gd")
const CockpitMeshFactory = preload("res://game/presentation/cockpit/cockpit_mesh_factory.gd") const CockpitMeshFactory = preload("res://game/presentation/cockpit/cockpit_mesh_factory.gd")
const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd")
var _materials: CockpitMaterialPalette var _materials: CockpitMaterialPalette
var _battery_needle: Node3D var _battery_needle: Node3D
var _pressure_needle: Node3D var _pressure_needle: Node3D
var _indicator_materials: Array[StandardMaterial3D] = []
var _active_indicator_count := 0
func _init(materials: CockpitMaterialPalette) -> void: func _init(materials: CockpitMaterialPalette) -> void:
_materials = materials _materials = materials
name = "AnalogGauges" name = "AnalogGauges"
_build_binnacle() _build_binnacle()
_build_indicator_bank()
_battery_needle = _add_gauge( _battery_needle = _add_gauge(
"BatteryGauge", "BatteryGauge",
Vector3(-0.095, 0.315, 0.145), Vector3(-0.095, 0.315, 0.145),
@@ -27,15 +31,28 @@ func _init(materials: CockpitMaterialPalette) -> void:
_add_binnacle_label("PRESSION COQUE", Vector3(0.095, 0.235, 0.160)) _add_binnacle_label("PRESSION COQUE", Vector3(0.095, 0.235, 0.160))
func present(battery_ratio: float, pressure_ratio: float) -> void: func present(
battery_ratio: float,
pressure_ratio: float,
alert_level: DiveAlertLevel.Value
) -> void:
_set_needle_ratio(_battery_needle, battery_ratio) _set_needle_ratio(_battery_needle, battery_ratio)
_set_needle_ratio(_pressure_needle, pressure_ratio) _set_needle_ratio(_pressure_needle, pressure_ratio)
_update_indicator_bank(alert_level)
func battery_needle_degrees() -> float: func battery_needle_degrees() -> float:
return 0.0 if _battery_needle == null else _battery_needle.rotation_degrees.z return 0.0 if _battery_needle == null else _battery_needle.rotation_degrees.z
func indicator_count() -> int:
return _indicator_materials.size()
func active_indicator_count() -> int:
return _active_indicator_count
func _build_binnacle() -> void: func _build_binnacle() -> void:
CockpitMeshFactory.add_box( CockpitMeshFactory.add_box(
"BinnacleHousing", "BinnacleHousing",
@@ -53,6 +70,72 @@ func _build_binnacle() -> void:
) )
func _build_indicator_bank() -> void:
var root := Node3D.new()
root.name = "IndicatorBank"
add_child(root)
var colors: Array[Color] = [
Color(0.08, 0.72, 0.38),
Color(1.0, 0.46, 0.035),
Color(0.92, 0.035, 0.018),
]
var captions: Array[String] = ["SYS", "ATTN", "ALRM"]
for indicator_index in colors.size():
var x := -0.055 + indicator_index * 0.055
CockpitMeshFactory.add_cylinder(
"%sBezel" % captions[indicator_index],
root,
Vector3(x, 0.198, 0.156),
0.016,
0.012,
_materials.brass,
Vector3(90.0, 0.0, 0.0)
)
var material := _indicator_material(colors[indicator_index])
_indicator_materials.append(material)
CockpitMeshFactory.add_cylinder(
"%sLens" % captions[indicator_index],
root,
Vector3(x, 0.198, 0.165),
0.009,
0.008,
material,
Vector3(90.0, 0.0, 0.0)
)
var label := Label3D.new()
label.name = "%sLabel" % captions[indicator_index]
label.text = captions[indicator_index]
label.font_size = 8
label.pixel_size = 0.00042
label.modulate = Color(0.70, 0.67, 0.56)
label.position = Vector3(x, 0.177, 0.168)
root.add_child(label)
func _indicator_material(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color * 0.12
material.metallic = 0.05
material.roughness = 0.16
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = 0.04
return material
func _update_indicator_bank(alert_level: DiveAlertLevel.Value) -> void:
var active_index := 0
if alert_level >= DiveAlertLevel.Value.CRITICAL:
active_index = 2
elif alert_level >= DiveAlertLevel.Value.ATTENTION:
active_index = 1
for indicator_index in _indicator_materials.size():
_indicator_materials[indicator_index].emission_energy_multiplier = (
2.8 if indicator_index == active_index else 0.04
)
_active_indicator_count = 1
func _add_binnacle_label(caption: String, position: Vector3) -> void: func _add_binnacle_label(caption: String, position: Vector3) -> void:
var label := Label3D.new() var label := Label3D.new()
label.name = "%sLabel" % caption.to_pascal_case().replace(" ", "") label.name = "%sLabel" % caption.to_pascal_case().replace(" ", "")
@@ -113,6 +196,7 @@ func _add_gauge(gauge_name: String, position: Vector3, caption: String) -> Node3
face.rotation_degrees.x = 90.0 face.rotation_degrees.x = 90.0
face.position.z = 0.003 face.position.z = 0.003
gauge.add_child(face) gauge.add_child(face)
_add_dial_ticks(gauge)
_add_scale_label(gauge, "0", Vector3(-0.052, -0.018, 0.028)) _add_scale_label(gauge, "0", Vector3(-0.052, -0.018, 0.028))
_add_scale_label(gauge, "50", Vector3(0.0, 0.052, 0.028)) _add_scale_label(gauge, "50", Vector3(0.0, 0.052, 0.028))
_add_scale_label(gauge, "100", Vector3(0.052, -0.018, 0.028)) _add_scale_label(gauge, "100", Vector3(0.052, -0.018, 0.028))
@@ -160,12 +244,30 @@ func _add_gauge(gauge_name: String, position: Vector3, caption: String) -> Node3
return needle_root return needle_root
func _add_dial_ticks(gauge: Node3D) -> void:
for tick_index in 11:
var ratio := float(tick_index) / 10.0
var angle_degrees := lerpf(125.0, -125.0, ratio)
var angle_radians := deg_to_rad(angle_degrees)
var direction := Vector2(-sin(angle_radians), cos(angle_radians))
var is_major := tick_index % 5 == 0
var tick_length := 0.015 if is_major else 0.009
var tick := CockpitMeshFactory.add_box(
"MajorTick" if is_major else "MinorTick",
gauge,
Vector3(direction.x * 0.064, direction.y * 0.064, 0.029),
Vector3(0.004 if is_major else 0.0025, tick_length, 0.003),
_materials.dark_steel
)
tick.rotation_degrees.z = angle_degrees
func _add_scale_label(gauge: Node3D, text: String, position: Vector3) -> void: func _add_scale_label(gauge: Node3D, text: String, position: Vector3) -> void:
var label := Label3D.new() var label := Label3D.new()
label.name = "Scale%s" % text label.name = "Scale%s" % text
label.text = text label.text = text
label.font_size = 9 label.font_size = 11
label.pixel_size = 0.00048 label.pixel_size = 0.00052
label.modulate = Color(0.09, 0.085, 0.065) label.modulate = Color(0.09, 0.085, 0.065)
label.position = position label.position = position
gauge.add_child(label) gauge.add_child(label)
@@ -30,6 +30,13 @@ func apply_screen_materials(dashboard: Node3D) -> void:
screen.material_override = _materials.instrument_screen screen.material_override = _materials.instrument_screen
func hide_placeholder_indicators(cockpit: Node3D) -> void:
for indicator_name: String in ["CyanIndicator", "AmberIndicator", "RedIndicator"]:
var indicator := cockpit.get_node_or_null(indicator_name) as MeshInstance3D
if indicator != null:
indicator.visible = false
func _build_structural_frame() -> void: func _build_structural_frame() -> void:
CockpitMeshFactory.add_box( CockpitMeshFactory.add_box(
"TopRail", "TopRail",
@@ -45,7 +45,8 @@ func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void:
/ maxf(snapshot.hull.certified_pressure_bar, 0.001), / maxf(snapshot.hull.certified_pressure_bar, 0.001),
0.0, 0.0,
1.0 1.0
) ),
snapshot.alerts.maximum
) )
var alert_level: DiveAlertLevel.Value = snapshot.alerts.maximum var alert_level: DiveAlertLevel.Value = snapshot.alerts.maximum
_emergency_light.visible = alert_level >= DiveAlertLevel.Value.CRITICAL _emergency_light.visible = alert_level >= DiveAlertLevel.Value.CRITICAL
@@ -315,6 +316,7 @@ func _build_dashboard_instrumentation() -> void:
_dashboard_detailing = CockpitDashboardDetailing.new(_materials) _dashboard_detailing = CockpitDashboardDetailing.new(_materials)
dashboard.add_child(_dashboard_detailing) dashboard.add_child(_dashboard_detailing)
_dashboard_detailing.apply_screen_materials(dashboard) _dashboard_detailing.apply_screen_materials(dashboard)
_dashboard_detailing.hide_placeholder_indicators(get_parent())
_analog_gauges = CockpitAnalogGauges.new(_materials) _analog_gauges = CockpitAnalogGauges.new(_materials)
dashboard.add_child(_analog_gauges) dashboard.add_child(_analog_gauges)
@@ -1,7 +1,8 @@
[gd_scene load_steps=20 format=3] [gd_scene load_steps=21 format=3]
[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_instrument_panel.gd" id="1_instruments"] [ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_instrument_panel.gd" id="1_instruments"]
[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_detailing.gd" id="2_detailing"] [ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_detailing.gd" id="2_detailing"]
[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_pressure_bulkhead.gd" id="3_bulkhead"]
[sub_resource type="StandardMaterial3D" id="SteelMaterial"] [sub_resource type="StandardMaterial3D" id="SteelMaterial"]
albedo_color = Color(0.055, 0.07, 0.07, 1) albedo_color = Color(0.055, 0.07, 0.07, 1)
@@ -160,6 +161,10 @@ material_override = SubResource("BrassMaterial")
[node name="PortholeAssembly" type="Node3D" parent="."] [node name="PortholeAssembly" type="Node3D" parent="."]
position = Vector3(0, 0.47, -1.32) position = Vector3(0, 0.47, -1.32)
[node name="CurvedPressureBulkhead" type="Node3D" parent="PortholeAssembly"]
unique_name_in_owner = true
script = ExtResource("3_bulkhead")
[node name="LeftBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] [node name="LeftBulkhead" type="MeshInstance3D" parent="PortholeAssembly"]
position = Vector3(-0.94, 0, 0) position = Vector3(-0.94, 0, 0)
scale = Vector3(0.6, 1.82, 0.2) scale = Vector3(0.6, 1.82, 0.2)
@@ -0,0 +1,144 @@
class_name CockpitPressureBulkhead
extends Node3D
const CockpitMaterialPalette = preload(
"res://game/presentation/cockpit/cockpit_material_palette.gd"
)
const CockpitMeshFactory = preload(
"res://game/presentation/cockpit/cockpit_mesh_factory.gd"
)
const PANEL_COUNT := 12
const ANGULAR_STEPS := 4
const RADIAL_STEPS := 3
const INNER_RADIUS := 0.72
const OUTER_RADIUS := 1.11
var _materials: CockpitMaterialPalette
var _panel_count := 0
func _ready() -> void:
_materials = CockpitMaterialPalette.new()
_hide_rectangular_whitebox()
_build_curved_panels()
_build_outer_rib()
_build_fasteners()
func panel_count() -> int:
return _panel_count
func rectangular_whitebox_is_hidden() -> bool:
for node_name: String in ["LeftBulkhead", "RightBulkhead", "TopBulkhead", "BottomBulkhead"]:
var whitebox := get_parent().get_node_or_null(node_name) as MeshInstance3D
if whitebox != null and whitebox.visible:
return false
return true
func _hide_rectangular_whitebox() -> void:
for node_name: String in ["LeftBulkhead", "RightBulkhead", "TopBulkhead", "BottomBulkhead"]:
var whitebox := get_parent().get_node_or_null(node_name) as MeshInstance3D
if whitebox != null:
whitebox.visible = false
func _build_curved_panels() -> void:
var panel_angle := TAU / float(PANEL_COUNT)
var seam_half_width := deg_to_rad(0.22)
for panel_index in PANEL_COUNT:
var start_angle := panel_index * panel_angle + seam_half_width
var end_angle := (panel_index + 1) * panel_angle - seam_half_width
var panel := MeshInstance3D.new()
panel.name = "PressurePanel%02d" % (panel_index + 1)
panel.mesh = _create_curved_panel_mesh(start_angle, end_angle)
panel.material_override = _materials.overhead_steel
panel.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
add_child(panel)
_panel_count += 1
func _create_curved_panel_mesh(start_angle: float, end_angle: float) -> ArrayMesh:
var surface := SurfaceTool.new()
surface.begin(Mesh.PRIMITIVE_TRIANGLES)
for angular_index in ANGULAR_STEPS:
var angle_ratio_0 := float(angular_index) / float(ANGULAR_STEPS)
var angle_ratio_1 := float(angular_index + 1) / float(ANGULAR_STEPS)
var angle_0 := lerpf(start_angle, end_angle, angle_ratio_0)
var angle_1 := lerpf(start_angle, end_angle, angle_ratio_1)
for radial_index in RADIAL_STEPS:
var radial_ratio_0 := float(radial_index) / float(RADIAL_STEPS)
var radial_ratio_1 := float(radial_index + 1) / float(RADIAL_STEPS)
_add_panel_quad(
surface,
angle_0,
angle_1,
angle_ratio_0,
angle_ratio_1,
radial_ratio_0,
radial_ratio_1
)
surface.generate_normals()
return surface.commit()
func _add_panel_quad(
surface: SurfaceTool,
angle_0: float,
angle_1: float,
angle_ratio_0: float,
angle_ratio_1: float,
radial_ratio_0: float,
radial_ratio_1: float
) -> void:
_add_panel_vertex(surface, angle_0, angle_ratio_0, radial_ratio_0)
_add_panel_vertex(surface, angle_1, angle_ratio_1, radial_ratio_0)
_add_panel_vertex(surface, angle_0, angle_ratio_0, radial_ratio_1)
_add_panel_vertex(surface, angle_0, angle_ratio_0, radial_ratio_1)
_add_panel_vertex(surface, angle_1, angle_ratio_1, radial_ratio_0)
_add_panel_vertex(surface, angle_1, angle_ratio_1, radial_ratio_1)
func _add_panel_vertex(
surface: SurfaceTool,
angle: float,
angle_ratio: float,
radial_ratio: float
) -> void:
var radius := lerpf(INNER_RADIUS, OUTER_RADIUS, radial_ratio)
var convex_depth := sin(radial_ratio * PI) * 0.075
surface.set_uv(Vector2(angle_ratio, radial_ratio))
surface.add_vertex(
Vector3(cos(angle) * radius, sin(angle) * radius, 0.045 + convex_depth)
)
func _build_outer_rib() -> void:
var rib := MeshInstance3D.new()
rib.name = "OuterPressureRib"
var torus := TorusMesh.new()
torus.inner_radius = 1.055
torus.outer_radius = 1.125
torus.rings = 64
torus.ring_segments = 12
rib.mesh = torus
rib.material_override = _materials.dark_steel
rib.rotation_degrees.x = 90.0
rib.position.z = 0.025
add_child(rib)
func _build_fasteners() -> void:
for fastener_index in PANEL_COUNT:
var angle := (fastener_index + 0.5) * TAU / float(PANEL_COUNT)
CockpitMeshFactory.add_cylinder(
"OuterFastener",
self,
Vector3(cos(angle) * 0.96, sin(angle) * 0.96, 0.105),
0.014,
0.018,
_materials.brass,
Vector3(90.0, 0.0, 0.0)
)
@@ -8,6 +8,9 @@ const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd")
const CockpitDetailing = preload( const CockpitDetailing = preload(
"res://game/presentation/cockpit/cockpit_detailing.gd" "res://game/presentation/cockpit/cockpit_detailing.gd"
) )
const CockpitPressureBulkhead = preload(
"res://game/presentation/cockpit/cockpit_pressure_bulkhead.gd"
)
const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd") const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd")
const ControlsSettingsScreen = preload( const ControlsSettingsScreen = preload(
"res://game/presentation/settings/controls_settings_screen.gd" "res://game/presentation/settings/controls_settings_screen.gd"
@@ -16,7 +19,7 @@ const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd" "res://game/infrastructure/input/production_input_adapter.gd"
) )
const TEST_COUNT := 58 const TEST_COUNT := 64
var _failures: Array[String] = [] var _failures: Array[String] = []
@@ -82,6 +85,16 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var detailing: CockpitDetailing = ( var detailing: CockpitDetailing = (
instruments.detailing() if instruments != null else null instruments.detailing() if instruments != null else null
) )
var pressure_bulkhead := (
instruments.get_node_or_null("%CurvedPressureBulkhead") as CockpitPressureBulkhead
if instruments != null
else null
)
var analog_gauges := (
instruments.get_node_or_null("Dashboard/AnalogGauges")
if instruments != null
else null
)
var adapter := composition.input_adapter() as ProductionInputAdapter var adapter := composition.input_adapter() as ProductionInputAdapter
var hud := composition.active_screen() as CockpitHud var hud := composition.active_screen() as CockpitHud
var sonar_service = composition.sonar_service() var sonar_service = composition.sonar_service()
@@ -91,6 +104,8 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
_expect(camera != null, "cockpit camera is attached to the submarine") _expect(camera != null, "cockpit camera is attached to the submarine")
_expect(instruments != null, "physical cockpit instruments are assembled") _expect(instruments != null, "physical cockpit instruments are assembled")
_expect(detailing != null, "cockpit structural detailing is assembled") _expect(detailing != null, "cockpit structural detailing is assembled")
_expect(pressure_bulkhead != null, "curved pressure bulkhead is assembled")
_expect(analog_gauges != null, "dashboard analog instruments are assembled")
_expect(hud != null, "critical HUD overlay is assembled") _expect(hud != null, "critical HUD overlay is assembled")
_expect(sonar_service != null, "production sonar service is assembled") _expect(sonar_service != null, "production sonar service is assembled")
_expect(sonar_display != null, "shared sonar presentation is assembled") _expect(sonar_display != null, "shared sonar presentation is assembled")
@@ -100,6 +115,8 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
or camera == null or camera == null
or instruments == null or instruments == null
or detailing == null or detailing == null
or pressure_bulkhead == null
or analog_gauges == null
or adapter == null or adapter == null
or hud == null or hud == null
): ):
@@ -114,6 +131,10 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
_expect(detailing.textures_are_loaded(), "cockpit loads its overhead and analog gauge textures") _expect(detailing.textures_are_loaded(), "cockpit loads its overhead and analog gauge textures")
_expect(detailing.gauges_are_dashboard_mounted(), "analog gauges are mounted on the dashboard") _expect(detailing.gauges_are_dashboard_mounted(), "analog gauges are mounted on the dashboard")
_expect(detailing.dashboard_control_count() >= 8, "dashboard exposes physical auxiliary controls") _expect(detailing.dashboard_control_count() >= 8, "dashboard exposes physical auxiliary controls")
_expect(pressure_bulkhead.panel_count() == 12, "porthole bulkhead uses twelve curved pressure panels")
_expect(pressure_bulkhead.rectangular_whitebox_is_hidden(), "porthole rectangular whitebox is hidden")
_expect(analog_gauges.indicator_count() == 3, "instrument binnacle exposes three inset pilot lights")
_expect(analog_gauges.active_indicator_count() == 1, "exactly one pilot light reflects the alert state")
_expect("kWh" in instruments.resources_text() and "kW" in instruments.resources_text(), "battery instrument has exact units") _expect("kWh" in instruments.resources_text() and "kW" in instruments.resources_text(), "battery instrument has exact units")
_expect("L/min" in instruments.resources_text(), "oxygen instrument has volume and consumption") _expect("L/min" in instruments.resources_text(), "oxygen instrument has volume and consumption")
_expect("bar" in instruments.integrity_text(), "hull instrument has current and certified pressure") _expect("bar" in instruments.integrity_text(), "hull instrument has current and certified pressure")