From c2b3ac42a39e533718f118e8138f2ec14171ef4b Mon Sep 17 00:00:00 2001 From: David Date: Sun, 4 Oct 2026 09:30:12 +0200 Subject: [PATCH] Refine cockpit porthole and indicators --- README.md | 7 +- docs/VERTICAL_SLICE_TECHNICAL_PLAN.md | 8 +- .../cockpit/cockpit_analog_gauges.gd | 108 ++++++++++++- .../cockpit/cockpit_dashboard_detailing.gd | 7 + .../presentation/cockpit/cockpit_detailing.gd | 4 +- .../cockpit/cockpit_interior.tscn | 7 +- .../cockpit/cockpit_pressure_bulkhead.gd | 144 ++++++++++++++++++ .../run_vertical_slice_foundation.gd | 23 ++- 8 files changed, 298 insertions(+), 10 deletions(-) create mode 100644 game/presentation/cockpit/cockpit_pressure_bulkhead.gd diff --git a/README.md b/README.md index 4a3277e..71cbb7f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md index f0bb514..f8fea55 100644 --- a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md +++ b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md @@ -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, diff --git a/game/presentation/cockpit/cockpit_analog_gauges.gd b/game/presentation/cockpit/cockpit_analog_gauges.gd index cf5e866..0808b55 100644 --- a/game/presentation/cockpit/cockpit_analog_gauges.gd +++ b/game/presentation/cockpit/cockpit_analog_gauges.gd @@ -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) diff --git a/game/presentation/cockpit/cockpit_dashboard_detailing.gd b/game/presentation/cockpit/cockpit_dashboard_detailing.gd index dffae64..e63e6a8 100644 --- a/game/presentation/cockpit/cockpit_dashboard_detailing.gd +++ b/game/presentation/cockpit/cockpit_dashboard_detailing.gd @@ -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", diff --git a/game/presentation/cockpit/cockpit_detailing.gd b/game/presentation/cockpit/cockpit_detailing.gd index fa904a7..c9123a7 100644 --- a/game/presentation/cockpit/cockpit_detailing.gd +++ b/game/presentation/cockpit/cockpit_detailing.gd @@ -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) diff --git a/game/presentation/cockpit/cockpit_interior.tscn b/game/presentation/cockpit/cockpit_interior.tscn index 95fe8f8..a4d0f03 100644 --- a/game/presentation/cockpit/cockpit_interior.tscn +++ b/game/presentation/cockpit/cockpit_interior.tscn @@ -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) diff --git a/game/presentation/cockpit/cockpit_pressure_bulkhead.gd b/game/presentation/cockpit/cockpit_pressure_bulkhead.gd new file mode 100644 index 0000000..7ae1aa0 --- /dev/null +++ b/game/presentation/cockpit/cockpit_pressure_bulkhead.gd @@ -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) + ) diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd index e81ab7e..14c4863 100644 --- a/tests/integration/run_vertical_slice_foundation.gd +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -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")