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.
- Acteur sous-marin de production derrière des contrats de commande et de
télémétrie typés.
- Habitacle 3D solidaire de la coque : hublot occultant, consoles, commandes,
éclairage, structure de coque renforcée, conduites, jauges analogiques et
tableau d'instruments structuré, et emplacement réservé au sonar.
- Habitacle 3D solidaire de la coque : hublot à cloison annulaire bombée,
consoles, commandes, éclairage, structure de coque renforcée, conduites,
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,
à l'éjection et aux états terminaux.
- Pause qui suspend les ticks, confirmations de sortie et premier remappage avec
+7 -1
View File
@@ -3,7 +3,7 @@
## Statut
- 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.
- Jalon : verticale jouable, après validation des POC A et B.
- 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
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
du regard de la caméra.
- 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 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 _battery_needle: Node3D
var _pressure_needle: Node3D
var _indicator_materials: Array[StandardMaterial3D] = []
var _active_indicator_count := 0
func _init(materials: CockpitMaterialPalette) -> void:
_materials = materials
name = "AnalogGauges"
_build_binnacle()
_build_indicator_bank()
_battery_needle = _add_gauge(
"BatteryGauge",
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))
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(_pressure_needle, pressure_ratio)
_update_indicator_bank(alert_level)
func battery_needle_degrees() -> float:
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:
CockpitMeshFactory.add_box(
"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:
var label := Label3D.new()
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.position.z = 0.003
gauge.add_child(face)
_add_dial_ticks(gauge)
_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, "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
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:
var label := Label3D.new()
label.name = "Scale%s" % text
label.text = text
label.font_size = 9
label.pixel_size = 0.00048
label.font_size = 11
label.pixel_size = 0.00052
label.modulate = Color(0.09, 0.085, 0.065)
label.position = position
gauge.add_child(label)
@@ -30,6 +30,13 @@ func apply_screen_materials(dashboard: Node3D) -> void:
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:
CockpitMeshFactory.add_box(
"TopRail",
@@ -45,7 +45,8 @@ func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void:
/ maxf(snapshot.hull.certified_pressure_bar, 0.001),
0.0,
1.0
)
),
snapshot.alerts.maximum
)
var alert_level: DiveAlertLevel.Value = snapshot.alerts.maximum
_emergency_light.visible = alert_level >= DiveAlertLevel.Value.CRITICAL
@@ -315,6 +316,7 @@ func _build_dashboard_instrumentation() -> void:
_dashboard_detailing = CockpitDashboardDetailing.new(_materials)
dashboard.add_child(_dashboard_detailing)
_dashboard_detailing.apply_screen_materials(dashboard)
_dashboard_detailing.hide_placeholder_indicators(get_parent())
_analog_gauges = CockpitAnalogGauges.new(_materials)
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_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"]
albedo_color = Color(0.055, 0.07, 0.07, 1)
@@ -160,6 +161,10 @@ material_override = SubResource("BrassMaterial")
[node name="PortholeAssembly" type="Node3D" parent="."]
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"]
position = Vector3(-0.94, 0, 0)
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(
"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 ControlsSettingsScreen = preload(
"res://game/presentation/settings/controls_settings_screen.gd"
@@ -16,7 +19,7 @@ const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd"
)
const TEST_COUNT := 58
const TEST_COUNT := 64
var _failures: Array[String] = []
@@ -82,6 +85,16 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var detailing: CockpitDetailing = (
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 hud := composition.active_screen() as CockpitHud
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(instruments != null, "physical cockpit instruments are 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(sonar_service != null, "production sonar service 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 instruments == null
or detailing == null
or pressure_bulkhead == null
or analog_gauges == null
or adapter == 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.gauges_are_dashboard_mounted(), "analog gauges are mounted on the dashboard")
_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("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")