From 5455ae7928b130b71cec372ca60d544c5dfc1f30 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 3 Oct 2026 09:17:27 +0200 Subject: [PATCH] Improve production cockpit realism --- README.md | 3 +- docs/VERTICAL_SLICE_TECHNICAL_PLAN.md | 8 +- .../cockpit/cockpit_analog_gauges.gd | 110 +++++++ .../presentation/cockpit/cockpit_detailing.gd | 311 ++++++++++++++++++ .../cockpit/cockpit_instrument_panel.gd | 9 + .../cockpit/cockpit_interior.tscn | 40 ++- .../cockpit/cockpit_material_palette.gd | 120 +++++++ .../cockpit/cockpit_mesh_factory.gd | 67 ++++ .../run_vertical_slice_foundation.gd | 21 +- 9 files changed, 668 insertions(+), 21 deletions(-) create mode 100644 game/presentation/cockpit/cockpit_analog_gauges.gd create mode 100644 game/presentation/cockpit/cockpit_detailing.gd create mode 100644 game/presentation/cockpit/cockpit_material_palette.gd create mode 100644 game/presentation/cockpit/cockpit_mesh_factory.gd diff --git a/README.md b/README.md index c203e3f..2308a84 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ production sous `game/` n'en dépend. - 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 et emplacement réservé au sonar. + éclairage, structure de coque renforcée, conduites, jauges analogiques 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 0dd2b7a..c1ee6e2 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 : 2 octobre 2026. +- Dernière révision d'implémentation : 3 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. @@ -801,6 +801,12 @@ Sortie : parcours de pilotage court avec ressources et retour immédiat lisible. 1280 × 720. Cette tranche corrige le graybox initial, qui ne contenait qu'une caméra, un cercle Canvas et des textes superposés. +Passe de réalisme du 3 octobre 2026 : l'habitacle reçoit des membrures de coque, +des conduites de support-vie, des boîtiers de service, des fixations, deux +jauges analogiques fonctionnelles, des matériaux métalliques patinés et un +éclairage pratique chaud/froid. La lisibilité du hublot et des instruments a +été revue nativement en Forward+ à 1280 × 720. + - 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 new file mode 100644 index 0000000..8f01501 --- /dev/null +++ b/game/presentation/cockpit/cockpit_analog_gauges.gd @@ -0,0 +1,110 @@ +class_name CockpitAnalogGauges +extends Node3D + +const CockpitMaterialPalette = preload("res://game/presentation/cockpit/cockpit_material_palette.gd") +const CockpitMeshFactory = preload("res://game/presentation/cockpit/cockpit_mesh_factory.gd") + +var _materials: CockpitMaterialPalette +var _battery_needle: Node3D +var _pressure_needle: Node3D + + +func _init(materials: CockpitMaterialPalette) -> void: + _materials = materials + name = "AnalogGauges" + _battery_needle = _add_gauge("BatteryGauge", Vector3(-0.34, 0.48, -1.14), "V") + _pressure_needle = _add_gauge("PressureGauge", Vector3(0.34, 0.48, -1.14), "BAR") + + +func present(battery_ratio: float, pressure_ratio: float) -> void: + _set_needle_ratio(_battery_needle, battery_ratio) + _set_needle_ratio(_pressure_needle, pressure_ratio) + + +func battery_needle_degrees() -> float: + return 0.0 if _battery_needle == null else _battery_needle.rotation_degrees.z + + +func _add_gauge(gauge_name: String, position: Vector3, caption: String) -> Node3D: + var gauge := Node3D.new() + gauge.name = gauge_name + gauge.position = position + gauge.scale = Vector3(0.78, 0.78, 0.78) + add_child(gauge) + CockpitMeshFactory.add_box( + "MountingPlate", + gauge, + Vector3(0.0, 0.0, -0.025), + Vector3(0.225, 0.225, 0.055), + _materials.dark_steel + ) + for screw_position: Vector2 in [ + Vector2(-0.086, -0.086), + Vector2(0.086, -0.086), + Vector2(-0.086, 0.086), + Vector2(0.086, 0.086), + ]: + CockpitMeshFactory.add_cylinder( + "MountingScrew", + gauge, + Vector3(screw_position.x, screw_position.y, 0.008), + 0.009, + 0.012, + _materials.brass, + Vector3(90.0, 0.0, 0.0) + ) + var bezel := MeshInstance3D.new() + var bezel_mesh := CylinderMesh.new() + bezel_mesh.top_radius = 0.095 + bezel_mesh.bottom_radius = 0.095 + bezel_mesh.height = 0.035 + bezel_mesh.radial_segments = 32 + bezel.mesh = bezel_mesh + bezel.material_override = _materials.brass + bezel.rotation_degrees.x = 90.0 + gauge.add_child(bezel) + var face := MeshInstance3D.new() + var face_mesh := CylinderMesh.new() + face_mesh.top_radius = 0.078 + face_mesh.bottom_radius = 0.078 + face_mesh.height = 0.039 + face_mesh.radial_segments = 32 + face.mesh = face_mesh + face.material_override = _materials.ivory + face.rotation_degrees.x = 90.0 + face.position.z = 0.003 + gauge.add_child(face) + var needle_root := Node3D.new() + needle_root.name = "Needle" + needle_root.position.z = 0.027 + gauge.add_child(needle_root) + CockpitMeshFactory.add_box( + "NeedleBlade", + needle_root, + Vector3(0.0, 0.033, 0.0), + Vector3(0.012, 0.064, 0.008), + _materials.red_emission + ) + CockpitMeshFactory.add_cylinder( + "NeedleHub", + needle_root, + Vector3.ZERO, + 0.018, + 0.012, + _materials.dark_steel, + Vector3(90.0, 0.0, 0.0) + ) + var label := Label3D.new() + label.name = "Caption" + label.text = caption + label.font_size = 12 + label.pixel_size = 0.0007 + label.modulate = Color(0.11, 0.12, 0.10) + label.position = Vector3(0.0, -0.045, 0.032) + gauge.add_child(label) + return needle_root + + +func _set_needle_ratio(needle: Node3D, ratio: float) -> void: + if needle != null: + needle.rotation_degrees.z = lerpf(125.0, -125.0, clampf(ratio, 0.0, 1.0)) diff --git a/game/presentation/cockpit/cockpit_detailing.gd b/game/presentation/cockpit/cockpit_detailing.gd new file mode 100644 index 0000000..a9ba5b9 --- /dev/null +++ b/game/presentation/cockpit/cockpit_detailing.gd @@ -0,0 +1,311 @@ +class_name CockpitDetailing +extends Node3D + +const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") +const CockpitAnalogGauges = preload("res://game/presentation/cockpit/cockpit_analog_gauges.gd") +const CockpitMaterialPalette = preload("res://game/presentation/cockpit/cockpit_material_palette.gd") +const CockpitMeshFactory = preload("res://game/presentation/cockpit/cockpit_mesh_factory.gd") + +var _materials: CockpitMaterialPalette +var _painted_steel: Material +var _dark_steel: Material +var _brass: Material +var _copper: Material +var _rubber: Material +var _warm_emission: StandardMaterial3D +var _analog_gauges: CockpitAnalogGauges +var _warm_practical: OmniLight3D +var _emergency_light: OmniLight3D + + +func _ready() -> void: + _create_materials() + _apply_materials_to_existing_cockpit() + _build_pressure_hull_ribs() + _build_life_support_pipes() + _build_overhead_console() + _build_panel_hardware() + _build_analog_gauges() + _build_practical_lights() + + +func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void: + if update == null or update.snapshot == null or telemetry == null: + return + var snapshot = update.snapshot + _analog_gauges.present( + snapshot.battery.ratio, + clampf( + snapshot.hull.exterior_pressure_bar + / maxf(snapshot.hull.certified_pressure_bar, 0.001), + 0.0, + 1.0 + ) + ) + var alert_level: DiveAlertLevel.Value = snapshot.alerts.maximum + _emergency_light.visible = alert_level >= DiveAlertLevel.Value.CRITICAL + _warm_practical.light_energy = ( + 0.28 if alert_level >= DiveAlertLevel.Value.CRITICAL else 0.72 + ) + + +func pressure_hull_rib_count() -> int: + var ribs := get_node_or_null("PressureHullRibs") + return 0 if ribs == null else ribs.get_child_count() + + +func pipe_segment_count() -> int: + var pipes := get_node_or_null("LifeSupportPipes") + return 0 if pipes == null else pipes.get_child_count() + + +func battery_needle_degrees() -> float: + return 0.0 if _analog_gauges == null else _analog_gauges.battery_needle_degrees() + + +func _create_materials() -> void: + _materials = CockpitMaterialPalette.new() + _painted_steel = _materials.painted_steel + _dark_steel = _materials.dark_steel + _brass = _materials.brass + _copper = _materials.copper + _rubber = _materials.rubber + _warm_emission = _materials.warm_emission + + +func _apply_materials_to_existing_cockpit() -> void: + _apply_material_to_paths( + _painted_steel, + [ + "CabinShell/LeftWall", + "CabinShell/RightWall", + "Dashboard/MainPanel", + "LeftConsole", + "RightConsole", + ] + ) + _apply_material_to_paths( + _dark_steel, + [ + "CabinShell/Ceiling", + "CabinShell/RearBulkhead", + "PortholeAssembly/LeftBulkhead", + "PortholeAssembly/RightBulkhead", + "PortholeAssembly/TopBulkhead", + "PortholeAssembly/BottomBulkhead", + "ThrottleLever", + "VerticalLever", + ] + ) + _apply_material_to_paths( + _brass, + [ + "PortholeAssembly/WindowFrame", + "Dashboard/ResourcesFrame", + "Dashboard/NavigationFrame", + "Dashboard/IntegrityFrame", + "ThrottleKnob", + "VerticalKnob", + ] + ) + + +func _apply_material_to_paths(material: Material, paths: Array[String]) -> void: + var cockpit := get_parent() + for path: String in paths: + var mesh := cockpit.get_node_or_null(path) as MeshInstance3D + if mesh != null: + mesh.material_override = material + + +func _build_pressure_hull_ribs() -> void: + var root := Node3D.new() + root.name = "PressureHullRibs" + add_child(root) + for rib_index in 2: + var rib := MeshInstance3D.new() + rib.name = "CircularRib%d" % (rib_index + 1) + var torus := TorusMesh.new() + torus.inner_radius = 1.00 + torus.outer_radius = 1.075 + torus.rings = 48 + torus.ring_segments = 10 + rib.mesh = torus + rib.material_override = _dark_steel + rib.position = Vector3(0.0, 0.22, -0.90 + rib_index * 1.35) + rib.rotation_degrees.x = 90.0 + root.add_child(rib) + + for side: float in [-1.0, 1.0]: + CockpitMeshFactory.add_box( + "AngledShoulder", + root, + Vector3(side * 0.88, 0.84, -0.36), + Vector3(0.11, 0.62, 1.52), + _painted_steel, + Vector3(0.0, 0.0, side * -18.0) + ) + CockpitMeshFactory.add_box( + "LowerStringer", + root, + Vector3(side * 0.91, -0.38, -0.18), + Vector3(0.10, 0.12, 1.70), + _dark_steel + ) + + +func _build_life_support_pipes() -> void: + var root := Node3D.new() + root.name = "LifeSupportPipes" + add_child(root) + for side: float in [-1.0, 1.0]: + var x: float = side * 0.83 + CockpitMeshFactory.add_cylinder( + "VerticalPipe", + root, + Vector3(x, 0.33, -1.12), + 0.027, + 1.18, + _copper + ) + CockpitMeshFactory.add_cylinder( + "ReturnPipe", + root, + Vector3(side * 0.73, 0.47, -1.09), + 0.019, + 0.92, + _brass + ) + for clamp_y: float in [-0.04, 0.35, 0.72]: + var clamp := MeshInstance3D.new() + clamp.name = "PipeClamp" + var torus := TorusMesh.new() + torus.inner_radius = 0.027 + torus.outer_radius = 0.041 + torus.rings = 16 + torus.ring_segments = 6 + clamp.mesh = torus + clamp.material_override = _dark_steel + clamp.position = Vector3(x, clamp_y, -1.12) + root.add_child(clamp) + CockpitMeshFactory.add_sphere( + "PipeJunction", + root, + Vector3(x, 0.91, -1.12), + 0.052, + _copper + ) + + CockpitMeshFactory.add_cylinder( + "OverheadCrossPipe", + root, + Vector3(0.0, 0.93, -1.12), + 0.031, + 1.82, + _copper, + Vector3(0.0, 0.0, 90.0) + ) + + +func _build_overhead_console() -> void: + var root := Node3D.new() + root.name = "OverheadConsole" + add_child(root) + CockpitMeshFactory.add_box( + "BreakerHousing", + root, + Vector3(0.0, 0.90, -1.08), + Vector3(0.66, 0.13, 0.27), + _painted_steel, + Vector3(-8.0, 0.0, 0.0) + ) + CockpitMeshFactory.add_box( + "WarmLightDiffuser", + root, + Vector3(0.0, 0.835, -0.89), + Vector3(0.38, 0.018, 0.065), + _warm_emission, + Vector3(-8.0, 0.0, 0.0) + ) + for switch_index in 7: + var x := -0.24 + switch_index * 0.08 + CockpitMeshFactory.add_cylinder( + "BreakerSwitch", + root, + Vector3(x, 0.83, -0.88), + 0.006, + 0.035, + _rubber, + Vector3(90.0, 0.0, 0.0) + ) + + +func _build_panel_hardware() -> void: + var root := Node3D.new() + root.name = "PanelHardware" + add_child(root) + for x: float in [-0.73, -0.49, -0.24, 0.24, 0.49, 0.73]: + for y: float in [-0.18, 0.22]: + CockpitMeshFactory.add_cylinder( + "PanelFastener", + root, + Vector3(x, y, -1.105), + 0.014, + 0.018, + _dark_steel, + Vector3(90.0, 0.0, 0.0) + ) + + for side: float in [-1.0, 1.0]: + CockpitMeshFactory.add_box( + "ServiceBox", + root, + Vector3(side * 0.89, 0.08, -1.10), + Vector3(0.18, 0.34, 0.19), + _painted_steel, + Vector3(0.0, side * -12.0, 0.0) + ) + var label := Label3D.new() + label.name = "ServicePlate" + label.text = "O₂" if side < 0.0 else "ELEC" + label.font_size = 16 + label.pixel_size = 0.0009 + label.modulate = Color(0.72, 0.69, 0.56) + label.position = Vector3(side * 0.89, 0.10, -0.99) + root.add_child(label) + + +func _build_analog_gauges() -> void: + _analog_gauges = CockpitAnalogGauges.new(_materials) + add_child(_analog_gauges) + + +func _build_practical_lights() -> void: + _warm_practical = OmniLight3D.new() + _warm_practical.name = "WarmPracticalLight" + _warm_practical.position = Vector3(0.0, 0.82, -0.50) + _warm_practical.light_color = Color(1.0, 0.52, 0.22) + _warm_practical.light_energy = 0.58 + _warm_practical.omni_range = 2.2 + _warm_practical.shadow_enabled = false + add_child(_warm_practical) + + var cool_fill := OmniLight3D.new() + cool_fill.name = "CoolRearFill" + cool_fill.position = Vector3(0.0, 0.42, 0.35) + cool_fill.light_color = Color(0.24, 0.46, 0.47) + cool_fill.light_energy = 0.48 + cool_fill.omni_range = 3.0 + cool_fill.shadow_enabled = false + add_child(cool_fill) + + _emergency_light = OmniLight3D.new() + _emergency_light.name = "EmergencyLight" + _emergency_light.position = Vector3(-0.58, 0.44, -0.83) + _emergency_light.light_color = Color(1.0, 0.04, 0.015) + _emergency_light.light_energy = 1.4 + _emergency_light.omni_range = 2.4 + _emergency_light.visible = false + add_child(_emergency_light) diff --git a/game/presentation/cockpit/cockpit_instrument_panel.gd b/game/presentation/cockpit/cockpit_instrument_panel.gd index 11667e1..c5d45b4 100644 --- a/game/presentation/cockpit/cockpit_instrument_panel.gd +++ b/game/presentation/cockpit/cockpit_instrument_panel.gd @@ -5,6 +5,9 @@ const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") const ReturnEstimate = preload("res://game/domain/dive/return_estimate.gd") const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") +const CockpitDetailing = preload( + "res://game/presentation/cockpit/cockpit_detailing.gd" +) const COLOR_NORMAL := Color(0.45, 0.89, 0.90, 1.0) const COLOR_ATTENTION := Color(1.0, 0.75, 0.20, 1.0) @@ -17,6 +20,7 @@ const COLOR_CRITICAL := Color(1.0, 0.42, 0.31, 1.0) @onready var _status_label: Label3D = %StatusLabel @onready var _sonar_screen: MeshInstance3D = %SonarScreen @onready var _sonar_placeholder: Label3D = %SonarPlaceholder +@onready var _detailing: CockpitDetailing = %CockpitDetailing var _sonar_texture: Texture2D @@ -65,6 +69,7 @@ func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void: maxi(snapshot.alerts.hull, snapshot.alerts.depth) ) _status_label.modulate = _alert_color(snapshot.alerts.maximum) + _detailing.present(update, telemetry) func resources_text() -> String: @@ -99,6 +104,10 @@ func sonar_texture() -> Texture2D: return _sonar_texture +func detailing() -> CockpitDetailing: + return _detailing + + func _return_text(estimate: ReturnEstimate) -> String: match estimate.status: ReturnEstimate.Status.SAFE: diff --git a/game/presentation/cockpit/cockpit_interior.tscn b/game/presentation/cockpit/cockpit_interior.tscn index d89dcae..95fe8f8 100644 --- a/game/presentation/cockpit/cockpit_interior.tscn +++ b/game/presentation/cockpit/cockpit_interior.tscn @@ -1,21 +1,22 @@ -[gd_scene load_steps=19 format=3] +[gd_scene load_steps=20 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"] [sub_resource type="StandardMaterial3D" id="SteelMaterial"] -albedo_color = Color(0.095, 0.12, 0.12, 1) -metallic = 0.82 -roughness = 0.34 +albedo_color = Color(0.055, 0.07, 0.07, 1) +metallic = 0.76 +roughness = 0.46 [sub_resource type="StandardMaterial3D" id="PaintedMaterial"] -albedo_color = Color(0.085, 0.185, 0.19, 1) -metallic = 0.58 -roughness = 0.48 +albedo_color = Color(0.045, 0.12, 0.125, 1) +metallic = 0.48 +roughness = 0.61 [sub_resource type="StandardMaterial3D" id="BrassMaterial"] -albedo_color = Color(0.36, 0.24, 0.075, 1) -metallic = 0.78 -roughness = 0.31 +albedo_color = Color(0.27, 0.17, 0.045, 1) +metallic = 0.74 +roughness = 0.43 [sub_resource type="StandardMaterial3D" id="RubberMaterial"] albedo_color = Color(0.018, 0.024, 0.025, 1) @@ -23,10 +24,9 @@ roughness = 0.88 [sub_resource type="StandardMaterial3D" id="GlassMaterial"] transparency = 1 -shading_mode = 0 -albedo_color = Color(0.12, 0.42, 0.47, 0.09) -metallic = 0.12 -roughness = 0.08 +albedo_color = Color(0.08, 0.29, 0.32, 0.12) +metallic = 0.05 +roughness = 0.16 cull_mode = 2 [sub_resource type="StandardMaterial3D" id="ScreenMaterial"] @@ -103,6 +103,10 @@ radial_segments = 32 [node name="CockpitInterior" type="Node3D"] script = ExtResource("1_instruments") +[node name="CockpitDetailing" type="Node3D" parent="."] +unique_name_in_owner = true +script = ExtResource("2_detailing") + [node name="CabinShell" type="Node3D" parent="."] [node name="LeftWall" type="MeshInstance3D" parent="CabinShell"] @@ -437,15 +441,17 @@ mesh = SubResource("KnobMesh") material_override = SubResource("RedMaterial") [node name="CabinLight" type="OmniLight3D" parent="."] +unique_name_in_owner = true position = Vector3(0, 0.92, -0.28) -light_color = Color(0.42, 0.75, 0.74, 1) -light_energy = 2.2 +light_color = Color(0.36, 0.68, 0.69, 1) +light_energy = 1.25 shadow_enabled = true omni_range = 3.3 [node name="PanelLight" type="OmniLight3D" parent="."] +unique_name_in_owner = true position = Vector3(0, 0.18, -0.63) light_color = Color(0.23, 0.72, 0.76, 1) -light_energy = 0.8 +light_energy = 0.58 shadow_enabled = false omni_range = 1.35 diff --git a/game/presentation/cockpit/cockpit_material_palette.gd b/game/presentation/cockpit/cockpit_material_palette.gd new file mode 100644 index 0000000..6853dd4 --- /dev/null +++ b/game/presentation/cockpit/cockpit_material_palette.gd @@ -0,0 +1,120 @@ +class_name CockpitMaterialPalette +extends RefCounted + +var painted_steel: Material +var dark_steel: Material +var brass: Material +var copper: Material +var rubber: Material +var ivory: Material +var warm_emission: StandardMaterial3D +var red_emission: StandardMaterial3D + + +func _init() -> void: + painted_steel = _weathered_material( + Color(0.045, 0.12, 0.125), + Color(0.16, 0.18, 0.17), + 0.52, + 0.58, + 0.13 + ) + dark_steel = _weathered_material( + Color(0.045, 0.055, 0.055), + Color(0.22, 0.24, 0.22), + 0.78, + 0.44, + 0.08 + ) + brass = _weathered_material( + Color(0.28, 0.18, 0.045), + Color(0.08, 0.16, 0.13), + 0.74, + 0.38, + 0.17 + ) + copper = _weathered_material( + Color(0.34, 0.12, 0.045), + Color(0.055, 0.18, 0.15), + 0.72, + 0.42, + 0.12 + ) + rubber = _plain_material(Color(0.012, 0.018, 0.018), 0.0, 0.92) + ivory = _plain_material(Color(0.72, 0.69, 0.56), 0.05, 0.63) + warm_emission = _emissive_material(Color(0.96, 0.52, 0.18), 2.4) + red_emission = _emissive_material(Color(0.92, 0.055, 0.025), 3.6) + + +func _weathered_material( + base_color: Color, + worn_color: Color, + metallic: float, + roughness: float, + wear_amount: float +) -> ShaderMaterial: + var shader := Shader.new() + shader.code = """ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; + +uniform vec4 base_color : source_color; +uniform vec4 worn_color : source_color; +uniform float metallic_value : hint_range(0.0, 1.0); +uniform float roughness_value : hint_range(0.0, 1.0); +uniform float wear_amount : hint_range(0.0, 0.4); + +float hash21(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float value_noise(vec2 p) { + vec2 cell = floor(p); + vec2 local = fract(p); + local = local * local * (3.0 - 2.0 * local); + float a = hash21(cell); + float b = hash21(cell + vec2(1.0, 0.0)); + float c = hash21(cell + vec2(0.0, 1.0)); + float d = hash21(cell + vec2(1.0, 1.0)); + return mix(mix(a, b, local.x), mix(c, d, local.x), local.y); +} + +void fragment() { + float broad = value_noise(UV * 11.0); + float grain = value_noise(UV * 73.0 + vec2(4.2, 8.7)); + float wear = smoothstep(0.76, 1.0, broad) * wear_amount; + ALBEDO = mix(base_color.rgb * mix(0.78, 1.08, broad), worn_color.rgb, wear); + METALLIC = metallic_value; + ROUGHNESS = clamp(roughness_value + (grain - 0.5) * 0.22, 0.16, 0.96); +} +""" + var material := ShaderMaterial.new() + material.shader = shader + material.set_shader_parameter("base_color", base_color) + material.set_shader_parameter("worn_color", worn_color) + material.set_shader_parameter("metallic_value", metallic) + material.set_shader_parameter("roughness_value", roughness) + material.set_shader_parameter("wear_amount", wear_amount) + return material + + +func _plain_material( + color: Color, + metallic: float, + roughness: float +) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.albedo_color = color + material.metallic = metallic + material.roughness = roughness + return material + + +func _emissive_material(color: Color, energy: float) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = color + material.emission_enabled = true + material.emission = color + material.emission_energy_multiplier = energy + return material diff --git a/game/presentation/cockpit/cockpit_mesh_factory.gd b/game/presentation/cockpit/cockpit_mesh_factory.gd new file mode 100644 index 0000000..dea27c4 --- /dev/null +++ b/game/presentation/cockpit/cockpit_mesh_factory.gd @@ -0,0 +1,67 @@ +class_name CockpitMeshFactory +extends RefCounted + + +static func add_box( + node_name: String, + parent: Node3D, + position: Vector3, + size: Vector3, + material: Material, + rotation_degrees: Vector3 = Vector3.ZERO +) -> MeshInstance3D: + var instance := MeshInstance3D.new() + instance.name = node_name + var mesh := BoxMesh.new() + mesh.size = size + instance.mesh = mesh + instance.material_override = material + instance.position = position + instance.rotation_degrees = rotation_degrees + parent.add_child(instance) + return instance + + +static func add_cylinder( + node_name: String, + parent: Node3D, + position: Vector3, + radius: float, + height: float, + material: Material, + rotation_degrees: Vector3 = Vector3.ZERO +) -> MeshInstance3D: + var instance := MeshInstance3D.new() + instance.name = node_name + var mesh := CylinderMesh.new() + mesh.top_radius = radius + mesh.bottom_radius = radius + mesh.height = height + mesh.radial_segments = 16 + instance.mesh = mesh + instance.material_override = material + instance.position = position + instance.rotation_degrees = rotation_degrees + parent.add_child(instance) + return instance + + +static func add_sphere( + node_name: String, + parent: Node3D, + position: Vector3, + radius: float, + material: Material +) -> MeshInstance3D: + var instance := MeshInstance3D.new() + instance.name = node_name + var mesh := SphereMesh.new() + mesh.radius = radius + mesh.height = radius * 2.0 + mesh.radial_segments = 16 + mesh.rings = 8 + instance.mesh = mesh + instance.material_override = material + instance.position = position + parent.add_child(instance) + return instance diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd index b386126..7f1595f 100644 --- a/tests/integration/run_vertical_slice_foundation.gd +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -5,6 +5,9 @@ const VerticalSliceRoot = preload( ) const MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd") const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd") +const CockpitDetailing = preload( + "res://game/presentation/cockpit/cockpit_detailing.gd" +) const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd") const ControlsSettingsScreen = preload( "res://game/presentation/settings/controls_settings_screen.gd" @@ -13,7 +16,7 @@ const ProductionInputAdapter = preload( "res://game/infrastructure/input/production_input_adapter.gd" ) -const TEST_COUNT := 51 +const TEST_COUNT := 55 var _failures: Array[String] = [] @@ -76,6 +79,9 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: var actor = composition.submarine_actor() var camera = composition.camera_controller() var instruments = composition.cockpit_instrument_panel() + var detailing: CockpitDetailing = ( + instruments.detailing() 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() @@ -84,16 +90,27 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: _expect(actor != null, "production submarine actor is assembled") _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(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") _expect(composition.latest_update() != null, "dive session starts with a typed update") - if actor == null or camera == null or instruments == null or adapter == null or hud == null: + if ( + actor == null + or camera == null + or instruments == null + or detailing == null + or adapter == null + or hud == null + ): return _expect(instruments.get_node("%WindowFrame") is MeshInstance3D, "cockpit has a physical porthole frame") _expect(instruments.get_node("%Dashboard") is Node3D, "cockpit has a physical dashboard") _expect(instruments.get_node("%SonarScreen") is MeshInstance3D, "cockpit reserves a physical sonar display") + _expect(detailing.pressure_hull_rib_count() >= 6, "cockpit exposes reinforced pressure-hull structure") + _expect(detailing.pipe_segment_count() >= 13, "cockpit exposes a complete life-support pipe run") + _expect(absf(detailing.battery_needle_degrees()) > 100.0, "analog battery gauge follows the dive snapshot") _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")