diff --git a/.gitignore b/.gitignore index 3cbbc7e..1f4f087 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ *.tmp *.log builds/ + +# Textures que Godot extrait des .glb a l import : regenerees, non versionnees +game/presentation/cockpit/assets/models/*.png diff --git a/docs/briefs/captures/.gdignore b/docs/briefs/captures/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/briefs/captures/lot7_after_1280x720.png b/docs/briefs/captures/lot7_after_1280x720.png new file mode 100644 index 0000000..a6e1f49 Binary files /dev/null and b/docs/briefs/captures/lot7_after_1280x720.png differ diff --git a/docs/briefs/captures/lot7_critical_1280x720.png b/docs/briefs/captures/lot7_critical_1280x720.png new file mode 100644 index 0000000..c008f20 Binary files /dev/null and b/docs/briefs/captures/lot7_critical_1280x720.png differ diff --git a/docs/briefs/captures/lot7_target_vs_after.jpg b/docs/briefs/captures/lot7_target_vs_after.jpg new file mode 100644 index 0000000..29750ef Binary files /dev/null and b/docs/briefs/captures/lot7_target_vs_after.jpg differ diff --git a/docs/briefs/captures/sonar_holo_before_after.jpg b/docs/briefs/captures/sonar_holo_before_after.jpg new file mode 100644 index 0000000..69f1a73 Binary files /dev/null and b/docs/briefs/captures/sonar_holo_before_after.jpg differ diff --git a/docs/briefs/cockpit-modeles-integration.md b/docs/briefs/cockpit-modeles-integration.md new file mode 100644 index 0000000..ba42bb1 --- /dev/null +++ b/docs/briefs/cockpit-modeles-integration.md @@ -0,0 +1,384 @@ +# Brief — intégrer les cinq modèles du cockpit + +**Statut : exécuté.** Ce brief a servi à l'intégration des cinq premiers modèles ; sept autres (hublot, coque, lampe, bouteille, mains courantes, console) ont suivi et la cabine a été reconstruite autour. Il reste comme trace des décisions (section 10) ; l'état réel est celui du code et de `assets/README.md`. + +Rédigé le 2026-10-06 à partir d'une lecture du dépôt (code non exécuté, barrière non lancée) et des +modèles produits dans une session Blender séparée. Tout ce qui est marqué « non vérifié » doit être +vérifié avant d'être cru. + +--- + +## 1. Objectif + +Le cockpit de production (`game/presentation/cockpit/`) fabrique aujourd'hui tous ses instruments en +code, à partir de primitives. Cinq modèles 3D texturés existent maintenant pour les instruments +principaux. Le travail : les importer, les placer dans `cockpit_interior.tscn` à la place de la géométrie +procédurale qu'ils remplacent, et les brancher sur les mêmes données qu'aujourd'hui, sans déplacer une +seule règle de jeu. + +Ce n'est pas une refonte du cockpit. Coque, hublot, équipements latéraux, tuyauterie, lampes en cage, +consoles, leviers et HUD restent tels quels. + +Terminé quand : +- les cinq modèles sont dans la scène, à l'échelle, orientés vers la caméra du pilote ; +- les quatre aiguilles, les voyants d'alerte et l'écran du sonar affichent les mêmes données qu'avant ; +- `bash scripts/quality_gate.sh` passe, sans contrôle affaibli ; +- des captures à 1280 × 720 et 1920 × 1080 sont jointes, à côté de + `docs/visual_targets/cockpit-objective-v1.jpg`. + +## 2. À lire avant de toucher au code + +Obligatoire par `AGENTS.md` : `docs/ENGINEERING_GUARDRAILS.md`, puis : +- `docs/adr/0004-forward-plus-only.md` — matériaux PBR, post-traitement en réglage de qualité ; +- `docs/adr/0006-sonar-contract-and-acquisition-gate.md` — une seule texture sonar, un seul renderer ; +- `docs/adr/0008-composition-owned-service-lifetimes.md` — la composition câble, la présentation affiche ; +- `docs/visual_targets/README.md` et l'image cible ; +- `docs/VERTICAL_SLICE_TECHNICAL_PLAN.md` L823-842 — hiérarchie visuelle du cockpit, ce qui relève de la + tranche 6 ; +- `game/presentation/cockpit/assets/README.md` — registre de provenance, à compléter. + +## 3. Les cinq modèles + +### 3.1 Où ils sont + +Copiés le 2026-10-06 dans `game/presentation/cockpit/assets/models/`, **non suivis par git** (décision 8 : +ne pas les commiter sans l'accord d'Olivier). Jamais ouverts dans Godot. + +| Fichier | Poids | Pièce | +|---|---|---| +| `gauge_panel.glb` | ~20 Mo | tableau de jauges | +| `sonar.glb` | ~15 Mo | écran sonar | +| `switch_panel.glb` | ~16 Mo | panneau d'interrupteurs | +| `alert_box.glb` | ~4,5 Mo (atlas 1024²) | boîtier d'alerte à trois voyants | +| `speaker.glb` | ~4,5 Mo (atlas 1024²) | boîtier haut-parleur | + +Les sources (scripts Blender, `.blend`) restent dans un dossier temporaire de session, hors dépôt : +`C:\Users\olivi\AppData\Local\Temp\claude\C--Users-olivi-Projects-Ares-Shift--claude-worktrees-blender-game-piece-c61349\7aedfdfa-be9c-40ed-8dc7-d4243ff07bc3\scratchpad\`. +Ne pas les modifier ; une retouche de modèle se demande à Olivier. + +Environ 60 Mo au total, presque entièrement des textures PNG embarquées, 2048² sauf mention (couleur, ORM, normales, +parfois émission). **Les textures bougent encore** : des passes d'équilibrage sont en cours. Les noms de +fichiers, les noms de nœuds et les pivots sont stables. Importer par référence au `.glb`, sans recopier +maillages ni matériaux dans la scène, pour qu'un fichier remplacé se mette à jour seul. + +Ce seront les **premiers assets 3D importés du dépôt** : aucun `.glb`, `.gltf`, `.obj` ni `.fbx` n'y existe. + +### 3.2 Conventions communes + +- glTF 2.0, mètres, exporté de Blender avec +Y vers le haut. Dans Blender la façade regarde −Y, donc + **dans Godot la façade regarde +Z** — même convention que le `Dashboard` actuel. Non vérifié à l'import. +- Chaque modèle a une racine maillée et, selon les cas, des enfants séparés pour ce qui s'anime ou + s'allume. Les enfants n'ont ni rotation ni échelle propres, sauf les aiguilles (voir 5.1). +- Échelle de création arbitraire, différente par pièce : voir section 4. +- Matériaux simple face, texture d'occlusion déclarée (canal rouge de l'ORM), tangentes exportées. +- Extension glTF utilisée : `KHR_materials_emissive_strength` sur les matériaux émissifs. + +### 3.3 Contrat de chaque modèle + +**Tableau de jauges — `gauge_panel.glb`** (créé à 1,2 m de large, origine au bas-centre de la façade) +- `GaugePanel` : boîtier, panneaux, lunettes, vis, interrupteurs décoratifs, barre à deux manchons. +- `GaugeDials` : les quatre cadrans imprimés, un matériau, une texture 4096 × 1024. +- `Needle_Oxygen`, `Needle_Battery`, `Needle_Hull`, `Needle_Depth` : une aiguille chacune, origine sur + l'axe du cadran, deux surfaces (lame, axe). Voir 5.1 pour l'angle. +- Six voyants, enfants séparés de `GaugePanel`, chacun avec son matériau émissif `GP_` : + `Light_OxygenOk` (vert), `Light_OxygenWarn`, `Light_BatteryWarn`, `Light_HullWarn`, `Light_Strip_0`, + `Light_Strip_1` (ambre). **Propriété à piloter : `emission_energy_multiplier`**, 0 = éteint (verre + sombre coloré), 1 = allumé. **Ne pas masquer un nœud `Light_*`** : les faces du boîtier sous la + lentille sont supprimées, on verrait un trou. Éteindre par l'émission seulement. +- Le boîtier `Gauges_Body` n'a plus aucune émission. Les cadrans `Gauges_Dials` portent un léger + rétro-éclairage (facteur d'émission 0,2 sur leur propre texture), pilotable aussi. + +**Écran sonar — `sonar.glb`** (créé à 1,0 m de large, origine au centre du boîtier) +- `Sonar` : boîtier, renfoncement, trois boutons ambre (émission cuite, toujours allumés), deux molettes, + deux poignées laiton, presse-étoupe. +- `SonarScreen` : la vitre, maillage plat séparé, matériau émissif propre portant une image fixe de + démonstration. **UV de 0 à 1 sur l'image visible.** Vitre visible : 0,690 × 0,434 à l'échelle de + création, soit un rapport de **1,59**. + +**Panneau d'interrupteurs — `switch_panel.glb`** (créé à 0,7 m de large, origine au bas-centre de la façade, +façade inclinée de 27° vers l'arrière) +- `SwitchPanel` : boîtier, plaque, cadres. `SwitchLabels` : étiquettes, matériau et texture propres + (POMPE 1, POMPE 2, SPOT, ALARME, ECLAIRAGE, VENTILATION, SONAR, NAVIGATION, AUXILIAIRE). +- Dix enfants animables, pose de repos à rotation nulle, **rotation autour de X** : + +| Nœuds | Plage (`rotation_degrees.x`) | +|---|---| +| `Toggle_Eclairage`, `Toggle_Ventilation`, `Toggle_Sonar`, `Toggle_Navigation`, `Toggle_Auxiliaire` | 0 (bas) → −70 (haut) | +| `Rocker_Pompe1`, `Rocker_Pompe2`, `Rocker_Spot` | 0 → +16 | +| `Cover_Alarme` (capot rouge) | 0 (fermé) → −100 (relevé) | +| `Lever_Alarme` | 0 → −100 environ ; traverse le capot s'il est levé capot fermé | + +Plages vérifiées en pose dans Blender, pas dans Godot. + +**Boîtier d'alerte — `alert_box.glb`** (créé à 0,30 m de large hors pattes, origine au bas-centre) +- `AlertBox` : boîtier, grille, plaque, étiquettes laiton ALERTE / ATTENTION / DANGER, presse-étoupes. +- `Lamp_Alerte` (vert), `Lamp_Attention` (ambre), `Lamp_Danger` (rouge) : une lentille chacune, matériaux + `AB_Lamp_Alerte`, `AB_Lamp_Attention`, `AB_Lamp_Danger`, texture d'émission propre. + **Propriété à piloter : `emission_energy_multiplier`** du matériau de la lentille. 0 = éteint + (la lentille reste lisible en verre coloré), 1,0 = allumé comme conçu. + +**Haut-parleur — `speaker.glb`** (créé à 0,26 m de large hors pattes, origine au bas-centre) +- `Speaker`, un nœud, matériau `Speaker_Body`. Rien d'animé, rien d'allumé. + +## 4. Échelle et placement + +**Se caler sur le cockpit existant, pas sur une maquette extérieure.** Le cockpit de production a déjà +ses cotes et sa caméra ; les modèles s'y adaptent. + +Repères du cockpit actuel (`cockpit_interior.tscn`, mètres, avant = −Z) : +- caméra : `(0, 0.52, −0.48)`, FOV 82° ; regard libre ±30° en lacet, −17° à +11,5° en tangage, plus un + retour de mouvement (jusqu'à environ −35° / +29,5° en tangage, ±8,8° en roulis). **Aucune translation.** +- `%Dashboard` : `(0, 0.05, −1.22)`, incliné de −12°, dalle de 1,56 × 0,44 × 0,14 ; +- bloc d'instruments actuel : 1,42 m de large ; cadrans de 0,203 / 0,203 / 0,186 / 0,242 m de diamètre ; +- `SonarMonitor` : `(0.54, 0.59, −1.04)`, rotation `(−5°, −42°, −2°)`, écran actuel de 0,216 × 0,141 m ; +- hublot : centre `(0, 0.47, −1.32)`, vitre de 1,12 m. + +Conséquences, à vérifier une fois les modèles importés : +- **Tableau de jauges** : ses cadrans font environ 0,21 m (petits) et 0,27 m (profondeur) à l'échelle de + création. Il tombe donc presque juste **à l'échelle 1** sur le bloc d'instruments actuel. Le poser sur + `%Dashboard`, nom et chemin à décider (voir 7). +- **Sonar** : pour retrouver un écran d'environ 0,216 m de large, échelle d'environ **0,31**. Garder la + position et l'orientation de `SonarMonitor`, tourné vers la caméra. +- **Panneau d'interrupteurs, boîtier d'alerte, haut-parleur** : pas d'équivalent exact aujourd'hui. + Tailles réelles plausibles proposées par l'étude d'ensemble : 0,26 m, 0,135 m et 0,145 m de large + (échelles 0,37, 0,45 et 0,56). Emplacements d'après l'image cible : interrupteurs en bas à gauche, + boîtier d'alerte sur la paroi gauche, haut-parleur sur la paroi droite. Ajuster à l'œil contre la cible. + +**Contrainte de point de vue, importante.** Les modèles ont été faits pour une caméra à position fixe : +dos ouverts, faces invisibles supprimées. Ils sont sains tant que la caméra reste dans un cône d'environ +35° (40° pour le sonar) autour de la normale de leur façade. La caméra du jeu ne se translate pas, donc +une fois chaque pièce tournée vers elle, tourner la tête ne change rien. À faire : après placement, +mesurer l'angle entre la normale de chaque façade et la direction pièce → caméra ; au-delà de 30°, +réorienter la pièce plutôt que d'accepter le risque. Regarder aussi chaque pièce en tangage extrême. + +Une maquette Blender du cockpit entier existe (`…\scratchpad\capsule\capsule_layout.json`), dérivée de +la seule image cible, avec ses propres cotes. Elle ne fait pas foi ici : elle suppose un cockpit plus +grand et une caméra différente. Utile seulement comme idée de disposition relative. + +## 5. Brancher les instruments + +Règle du dépôt : la présentation affiche des instantanés, elle ne calcule aucune règle. Le chemin de +données ne change pas — `VerticalSliceRoot` appelle `present(update, telemetry)` à chaque pas physique, +`CockpitInstrumentPanel` puis `CockpitDetailing` redistribuent des valeurs simples. Seul le dernier +maillon change : ce qui tourne et ce qui s'allume. + +### 5.1 Aiguilles + +Aujourd'hui (`cockpit_analog_gauges.gd` L263-265) : +`needle.rotation_degrees.z = lerpf(125.0, −125.0, ratio)`, balayage de 250°. + +Les cadrans des nouveaux modèles sont imprimés pour un balayage de **240°** : + +`rotation_degrees.z = 120 − 240 × ratio` + +- ratio 0 → +120° (en bas à gauche), ratio 1 → −120° (en bas à droite), sens horaire vu du pilote. +- **Les aiguilles ne sont pas à zéro dans le fichier** : elles sont exportées dans une pose de + démonstration (ratios 0,70 / 0,50 / 0,90 / 0,54). Écrire l'angle absolu, ne pas additionner. +- Formule vérifiée par lecture du `.glb` et dans Blender. **Signe non vérifié dans Godot** : poser les + quatre aiguilles à 0, 0,5 et 1 et regarder où elles pointent avant de conclure. +- Les ratios restent ceux choisis dans `CockpitDetailing.present` (L49-60). Ne pas les recalculer ailleurs. + +Graduations imprimées : oxygène, batterie et coque de 0 à 100 %, avec bande rouge 0-17, jaune 17-29, +verte 29-100. **Profondeur imprimée de 0 à 100 %** (décision 1), sans bande de couleur, avec un repère +rouge juste après 100 : l'aiguille trace `profondeur courante / profondeur certifiée`, comme aujourd'hui. + +### 5.2 Voyants d'alerte + +Aujourd'hui `CockpitAnalogGauges._update_indicator_bank` (L140-150) allume exactement un voyant sur trois +selon `snapshot.alerts.maximum` : vert sous ATTENTION, ambre pour ATTENTION et RETURN, rouge pour +CRITICAL et FAILURE ; allumé = énergie 2,8, éteint = 0,04. + +Le boîtier d'alerte porte exactement ces trois voyants. Même correspondance, en pilotant +`emission_energy_multiplier` de chaque lentille. Qui reprend ce rôle — boîtier d'alerte, voyants du +tableau, ou les deux — est la décision 2. + +Rappel du plan de jeu (`GAME_DESIGN_PLAN.md` L258-268) : aucune information critique ne dépend de la +seule couleur. Les étiquettes ALERTE / ATTENTION / DANGER et la position des voyants y contribuent ; +le panneau d'alerte du HUD reste. + +### 5.3 Écran du sonar + +Aujourd'hui `CockpitInstrumentPanel.attach_sonar_texture` (L85-94) pose sur `%SonarScreen` un +`StandardMaterial3D` non éclairé portant la texture du `SubViewport` (960 × 540), en `material_override`. + +Avec le modèle : même méthode, sur le nœud `SonarScreen` importé. Points à respecter : +- le test exige que `%SonarScreen` soit un `MeshInstance3D` à nom unique, dont le +Z local est la normale + sortante, tourné vers la caméra (voir 7). La vitre du modèle regarde +Z après import ; à vérifier ; +- le `material_override` remplace l'image de démonstration embarquée : voulu ; +- une seule texture partagée entre vue compacte et vue agrandie (ADR 0006), asserté par le test ; +- **rapport d'aspect** : la texture est en 16:9 (1,78), la vitre du modèle en 1,59, l'ancien quad en 1,53. + L'image est déjà écrasée d'environ 14 % aujourd'hui. Décision 3. +- ne pas ajouter de grille ni de tracé factice par-dessus : le test interdit tout nœud `SonarReferenceTrace`, + et l'ancienne grille en boîtes émissives (`SonarGridVertical/Horizontal`) disparaît avec l'ancienne console. + +### 5.4 Interrupteurs + +Purement décoratifs aujourd'hui, et ils le restent dans ce lot : aucune interaction, aucun raycast, +aucune action d'entrée n'existe. Les laisser en pose de repos. Les rendre manipulables serait un nouveau +périmètre traversant entrées, application et présentation (décision 4). + +### 5.5 Haut-parleur + +Décor. Il n'y a aucun audio dans `game/` aujourd'hui. + +## 6. Rendu : ce qui conditionne l'aspect + +Les modèles ont été réglés et jugés sous une transformation de couleur linéaire (« Standard » de +Blender). Bonne nouvelle : le projet est au tonemapper par défaut de Godot, **Linear**, donc proche. +Sous une courbe filmique ou AgX, les mêmes modèles sortent pâles et délavés ; ce piège a déjà coûté +plusieurs corrections à tort. Ne pas toucher aux textures pour compenser un réglage d'environnement. + +État actuel, relevé dans `game/world/sites/vehicle_test_environment.tscn` : tonemap non réglé (Linear), +**glow éteint, SSAO éteint**, brouillard et brouillard volumétrique actifs, ambiance +`ambient_light_source = 3` sans ressource Sky. Conséquences : +- **Voyants** : sans glow, pas de halo. Les lentilles se lisent par leur seule surface. Les halos de + l'image cible n'ont pas été peints dans les textures, exprès. Décision 5. +- **Forces d'émission** : réglées sur un éclairage d'essai. À recaler ici, par `emission_energy_multiplier` + ou l'énergie d'émission du matériau, pas en retouchant les textures. +- **Occlusion** : chaque modèle porte une occlusion cuite, qui n'agit que sur la lumière ambiante, et une + crasse de contact cuite dans la couleur de base. Si le SSAO est activé un jour, surveiller le cumul. +- **Ambiance** : vérifier à l'image ce que `ambient_light_source = 3` donne réellement sans Sky avant de + juger les matériaux. +- **Noirs métalliques** : cadres, lunettes, fond de grille et renfoncement de l'écran sont des noirs à + `metallic = 1`. C'est voulu : un diélectrique renvoie 4 % et passait au gris sous éclairage. +- **Éclairage non peint** : aucun reflet n'est cuit dans les textures (une exception possible sur le + boîtier d'alerte, en cours de reprise). Les reflets viennent de la rugosité, du métal et des normales, + donc des lampes de `cockpit_practical_lighting.gd`. + +L'environnement appartient à la couche `world`, pas au cockpit. Le modifier sort du périmètre de ce lot +sauf décision contraire. + +## 7. Tests : ce qui casse, ce qui doit tenir + +Un seul test touche le cockpit : `tests/integration/run_vertical_slice_foundation.gd`. Il asserte sur des +noms, des types et des comptes de nœuds. `AGENTS.md` interdit d'affaiblir un contrôle pour faire passer +un changement ; quand la structure change légitimement, le test est **réécrit pour exprimer le même +comportement sur la nouvelle structure**, dans le même commit, et le changement est justifié. + +| Ligne | Assertion | Effet du remplacement | +|---|---|---| +| 93-97, 108 | un nœud existe à `Dashboard/AnalogGauges` | garder ce chemin pour le conteneur des jauges, ou adapter | +| 125-127 | `%SonarScreen` est un `MeshInstance3D` | le maillage importé doit porter ce nom unique | +| 130, 142-145, 330-343 | `SonarMonitor/Housing` est un `BoxMesh`, écran en avant du boîtier | **casse** : le boîtier importé est un `ArrayMesh`. Réécrire la mesure (bornes du maillage) | +| 134-137, 305-314 | `global_basis.z` de l'écran · direction caméra > 0,85 | l'écran doit rester tourné vers la caméra, +Z sortant | +| 152 | `abs(battery_needle_degrees()) > 100` au départ | tient avec la nouvelle formule (batterie pleine → −120°) | +| 153 | `textures_are_loaded()` : acier + quatre faces de cadran | **casse** si les PNG de faces sont retirés (décision 6) | +| 155 | `dashboard_control_count() >= 8` | le nouveau panneau porte 10 commandes | +| 158 | `sonar_control_count() >= 4` | le modèle a 3 boutons et 2 molettes : redéfinir ce que la méthode compte | +| 162-164 | 4 jauges, 3 indicateurs, exactement 1 actif | dépend de la décision 2 | +| 165-167 | textes des afficheurs cachés (kWh, kW, L/min, bar) | les `Label3D` cachés doivent continuer d'être mis à jour | +| 169-172 | texture sonar partagée | inchangé | +| 150-151, 156-157, 159-161 | couples, tuyaux, équipements latéraux, 2 lampes, cloison | ne pas y toucher | + +`tests/run_load_checks.gd` instancie toute scène sous `game/` : `cockpit_interior.tscn` doit s'instancier +seule, modèles compris. + +**La barrière échoue sur toute ligne `ERROR:`, `SCRIPT ERROR:` ou `WARNING:`** dans la sortie des tests. +Un glTF qui émet un avertissement au chargement (extension inconnue, texture manquante, tangentes) la +fait donc tomber. Contrôler les journaux d'import et de chargement des cinq fichiers **en premier**, +avant tout travail de scène. Le script d'export a signalé un avertissement connu côté Blender +(« More than one shader node tex image used for a texture ») ; savoir s'il a un équivalent côté Godot +n'est pas vérifié. + +Tout comportement nouveau reçoit son test : angle des aiguilles aux ratios 0, 0,5 et 1 ; un seul voyant +allumé par niveau d'alerte ; écran tourné vers la caméra. + +## 8. Architecture et conventions + +- Tout ce lot vit dans `game/presentation/cockpit/`. Aucune dépendance vers `world`, `infrastructure` ni + `composition` (`check_architecture.sh` L93-99). Rien dans `src/` ni `scenes/`. +- Suivre le patron existant : une classe par fichier, `class_name` en PascalCase préfixé `Cockpit`, + fichier en snake_case préfixé `cockpit_`, dépendances redéclarées en `const X = preload(...)`, GDScript + typé, membres privés en `_`, garde de nullité en tête de chaque `present`, petites méthodes de lecture + pour les tests (`gauge_count()`, `indicator_count()`…). +- Une seule implémentation par concept : la conversion ratio → angle d'aiguille a **un** propriétaire. + En remplaçant `CockpitAnalogGauges`, supprimer l'ancienne, ne pas laisser deux formules. +- Supprimer la géométrie procédurale remplacée, ne pas la cacher. Le dépôt cache déjà neuf nœuds + d'afficheurs par `hide_obsolete_elements` ; ne pas allonger cette liste. +- Ce qui est remplacé : tout `AnalogGauges` (boîtier, quatre jauges, aiguilles, verre, `IndicatorBank` + selon la décision 2) ; `SonarMonitor/Housing`, le quad `%SonarScreen`, tout `SonarConsoleDetailing` ; + les deux bancs `SwitchPlate` / `ToggleSwitch` de `DashboardDetailing`. +- Ce qui reste : coque, hublot et cloison, `SideEquipment` en entier, tuyauterie, couples, + `PracticalLighting`, consoles, leviers, cadre structurel du tableau (`TopRail`, `BottomRail`, + `SideRail`, `PanelScrew`), `%ReturnLabel`, `%StatusLabel`, `EmergencyDial` (décision 7), HUD. +- Vérifier les collisions avec ce qui reste : le cadre structurel et le rail inférieur + (`LowerSteelRail`, `LeftGrip`, `RightGrip`) occupent la zone de la barre du nouveau tableau, qui porte + déjà ses propres manchons. Probable doublon à retirer ; à juger à l'image. +- Fichiers de plus de 350 lignes et fonctions de plus de 40 : seuils de revue du dépôt. + +## 9. Import et dépôt + +- **Emplacement** : `game/presentation/cockpit/assets/models/`, à côté du code qui les utilise, comme + les textures actuelles. Noms en snake_case, déjà appliqués (section 3.1). +- **Les `.import` ne sont pas versionnés** dans ce dépôt ; la barrière les régénère par `--import`. + Tout réglage d'import non par défaut serait donc perdu en CI. S'en tenir aux réglages par défaut et + faire toute adaptation dans la scène ou le code, sauf décision de versionner les `.glb.import`. +- **60 Mo de binaires** dans un dépôt sans Git LFS ni `.gitattributes`, alors que le plan de jeu nomme + LFS pour les gros assets. Décision 8 : pas de LFS, et pas de commit des `.glb` sans accord d'Olivier. + Conséquence à signaler dans le compte rendu : tant que les `.glb` ne sont pas commités, la scène ne se + charge que sur cette machine ; un clone neuf échouerait à la barrière. +- **Budget de textures** (`GAME_DESIGN_PLAN.md` L958-960) : atlas 1K pour les kits courants, 2K réservé + au sous-marin et aux éléments héroïques. Le cockpit relève du 2K. Boîtier d'alerte et haut-parleur sont + passés en 1024 (aucune différence visible à leur taille d'écran). +- **Provenance** : compléter `assets/README.md` dans sa forme actuelle. Les modèles sont générés par + scripts Blender écrits pour le projet, d'après des planches de concept générées pour le projet ; + aucune ressource tierce. Polices des cadrans et des étiquettes : Arial et Impact, rendues en texture. + **Point de licence à vérifier** avant diffusion : ce sont des polices système de Windows. +- `project.godot` porte une **modification non commitée** qui supprime `[physics]` et `[rendering]` + (ré-enregistrement par l'éditeur, selon toute apparence). Ne pas la commiter par accident avec ce lot. +- Ne jamais modifier `.godot/` à la main. + +## 10. Décisions prises + +Tranchées par Olivier le 2026-10-06. Elles priment sur toute formulation conditionnelle des sections +précédentes (« voir la décision N », « dépend de la décision N »). + +1. **Cadran de profondeur : pourcentage de la profondeur certifiée.** Le cadran du modèle est regradué de + 0 à 100 % (texture régénérée par le script du modèle, livrée avec le `.glb`). L'aiguille trace + `courante / certifiée`, comme aujourd'hui : aucun changement de domaine. Aucun seuil de jeu n'est + imprimé sur le cadran au-delà du repère de fin de course. +2. **L'alerte SYS / ATTN / ALRM est portée par le boîtier d'alerte** : `Lamp_Alerte` (verte) = SYS, + `Lamp_Attention` (ambre) = ATTN, `Lamp_Danger` (rouge) = ALRM, exactement une allumée. Les six voyants + `Light_*` du tableau de jauges ne portent pas ce rôle ; faute de donnée par instrument dans le + snapshot, ils restent dans un état fixe de repos (à proposer, pas à inventer une règle). +3. **Sonar : le `SubViewport` passe au rapport de la vitre** (1,59 ; 960 × 604). Vérifier que le rendu + sonar ne suppose pas 16:9 ; adapter le test qui lit sa taille s'il y en a un. +4. **Interrupteurs décoratifs.** Posés au repos (rotations nulles). Aucune entrée, aucune commande. +5. **Glow activé, discret.** Réglage d'environnement traité en réglage de qualité selon l'ADR 0004. Seuil + réglé pour que seuls voyants et écran sonar débordent, pas le papier des cadrans. Livrer une capture + avec et sans. +6. **Faces de cadran : celles du modèle.** Les PNG `*_gauge_face_albedo*.png` sont retirés et + `textures_are_loaded()` adapté à ce qui est réellement chargé. +7. **`EmergencyDial` / « ÉJECTION · X » est conservé.** Le levier d'alarme sous capot rouge reste + décoratif, capot fermé. +8. **Dépôt : pas de Git LFS.** Git ordinaire, sans `.gitattributes`. Comme chaque version d'un `.glb` + reste à jamais dans l'historique et que les textures bougent encore, **ne pas commiter les `.glb` + sans l'accord explicite d'Olivier** : travailler avec les fichiers en place, non suivis, et commiter + scène, code et tests à part. Fichiers sous `game/presentation/cockpit/assets/models/` + (à côté des textures de cockpit existantes), noms en snake_case : `gauge_panel.glb`, `sonar.glb`, + `switch_panel.glb`, `alert_box.glb`, `speaker.glb`. Les `.glb.import` ne sont pas versionnés, comme le + reste de `.godot/` et des imports du dépôt. + +## 11. Ce qui n'a jamais été vérifié + +- Aucun des cinq `.glb` n'a été ouvert dans Godot. Import, orientation, échelle, matériaux, sens de + rotation des aiguilles et des interrupteurs : tout est à constater. +- L'aspect sous l'éclairage réel du cockpit : les modèles n'ont été vus que dans Blender. +- La barrière qualité n'a pas été lancée pendant la rédaction de ce brief. +- Les numéros de ligne cités datent du 2026-10-06 ; les relire avant de s'y fier. + +## 12. Ordre de travail conseillé + +1. Copier les `.glb` (hors commit), lancer `--import`, lire les journaux : zéro avertissement, sinon + s'arrêter et le signaler — le correctif est côté modèles. +2. Instancier les cinq modèles dans une scène d'essai, constater axes, échelle, hiérarchie et matériaux + contre la section 3. Signaler tout écart avant d'aller plus loin. +3. Placer dans `cockpit_interior.tscn` (section 4), mesurer les angles de vue. +4. Brancher aiguilles, voyants, écran (section 5), avec leurs tests. +5. Supprimer la géométrie procédurale remplacée, adapter le test d'intégration (section 7). +6. Captures aux deux résolutions, à côté de l'image cible. Barrière verte. +7. Compte rendu : ce qui est fait, ce qui diffère de ce brief, ce qui reste, ce qui n'a pas pu être vérifié. + +En cas de défaut d'un modèle (trou visible, mauvais axe, texture fausse, nœud manquant) : ne pas le +réparer dans Godot. Le décrire précisément, capture à l'appui ; il se corrige dans le script Blender qui +a produit le fichier. diff --git a/game/presentation/cockpit/assets/README.md b/game/presentation/cockpit/assets/README.md index c0cc8bf..2fd72f3 100644 --- a/game/presentation/cockpit/assets/README.md +++ b/game/presentation/cockpit/assets/README.md @@ -1,37 +1,54 @@ -# Cockpit texture provenance +# Cockpit asset provenance -These textures were generated specifically for Ransom's Wake on 3 October 2026 +## Textures + +The texture below was generated specifically for Ransom's Wake on 3 October 2026 with OpenAI's built-in image generation tool. No third-party image was used as an input or reference. -## `naval_painted_steel_albedo.png` +### `naval_painted_steel_albedo.png` Prompt summary: seamless, orthographic PBR-style albedo of dark blue-green naval painted steel, with restrained salt-air oxidation, micro-scratches, paint chips and grime; neutral illumination; no borders, rivets, text or watermark. -## `analog_gauge_face_albedo.png` +The source output was resized to 1024 × 1024 and stripped of metadata for +runtime use. The original generated output remains in the local Codex image +generation store and is not required by the game. -Prompt summary: orthographic vintage naval gauge face in aged ivory enamel, -with fine crazing, soot, speckling and a faint printed arc; no bezel, needle, -hub, glass, text, numbers, logo or watermark. +The generated gauge faces (`analog_gauge_face_albedo.png` and the four +`*_gauge_face_albedo_v2.png`) were removed on 6 October 2026: the dials are now +printed on the gauge panel model below. -Both source outputs were resized to 1024 × 1024 and stripped of metadata for -runtime use. The original generated outputs remain in the local Codex image -generation store and are not required by the game. +## Models -## Dedicated instrument faces +`models/gauge_panel.glb`, `models/sonar.glb`, `models/switch_panel.glb`, +`models/alert_box.glb`, `models/speaker.glb`, `models/porthole.glb`, +`models/hull_wall.glb`, `models/cage_lamp.glb`, `models/gas_bottle.glb`, +`models/handrail_l.glb`, `models/handrail_r.glb` and `models/side_console.glb` +were produced for Ransom's Wake +by Blender scripts written for the project, from concept sheets generated for +the project. No third-party mesh, texture or scan was used. Their textures +(base colour, ORM, normal and, where relevant, emission) are embedded in the +`.glb` files: 2048² for the gauge panel, sonar and switch panel, a 4096 × 1024 +strip for the four printed dials, 2048² for the porthole and hull wall, 1024² +for the other props. -`oxygen_gauge_face_albedo_v2.png`, `battery_gauge_face_albedo_v2.png`, -`hull_gauge_face_albedo_v2.png` and `depth_gauge_face_albedo_v2.png` were -generated on 4 October 2026 with the same built-in tool. The approved cockpit -target and then the oxygen face were used only as project-owned style -references. +Dial and label lettering is rendered into the textures with Arial and Impact, +which are Windows system fonts. **Their licence must be checked before the game +is distributed.** -Prompt summary: orthographic mechanical submarine gauges in aged ivory enamel, -with exact French labels and units, dense numerical scales, restrained patina, -and green, amber and red operating sectors. The generated needles, bezels and -glass were explicitly excluded because those parts are modeled and animated in -Godot. The source outputs were resized to 1024 × 1024 and given a circular alpha -mask for flat, distortion-free mounting inside the physical bezels. +The Blender sources are not in this repository. A model is changed by editing +its generating script and re-exporting, never by editing the import in Godot. +The cockpit scene references the `.glb` files directly, so a replaced file +updates the cockpit without touching the scene. + +With Godot's default import settings the embedded textures are extracted as +`models/_.png` next to each `.glb`. Those PNG files are import +by-products, regenerated from the `.glb`, and are not sources. + +As of 6 October 2026 the `.glb` files are deliberately **not committed** +(their textures are still being balanced, and the repository uses plain Git +without LFS). Until they are, `cockpit_interior.tscn` only loads on a machine +that has them in `models/`. diff --git a/game/presentation/cockpit/assets/analog_gauge_face_albedo.png b/game/presentation/cockpit/assets/analog_gauge_face_albedo.png deleted file mode 100644 index 0b69e18..0000000 Binary files a/game/presentation/cockpit/assets/analog_gauge_face_albedo.png and /dev/null differ diff --git a/game/presentation/cockpit/assets/battery_gauge_face_albedo_v2.png b/game/presentation/cockpit/assets/battery_gauge_face_albedo_v2.png deleted file mode 100644 index c22a1fe..0000000 Binary files a/game/presentation/cockpit/assets/battery_gauge_face_albedo_v2.png and /dev/null differ diff --git a/game/presentation/cockpit/assets/depth_gauge_face_albedo_v2.png b/game/presentation/cockpit/assets/depth_gauge_face_albedo_v2.png deleted file mode 100644 index bd43d15..0000000 Binary files a/game/presentation/cockpit/assets/depth_gauge_face_albedo_v2.png and /dev/null differ diff --git a/game/presentation/cockpit/assets/hull_gauge_face_albedo_v2.png b/game/presentation/cockpit/assets/hull_gauge_face_albedo_v2.png deleted file mode 100644 index ba4b7d4..0000000 Binary files a/game/presentation/cockpit/assets/hull_gauge_face_albedo_v2.png and /dev/null differ diff --git a/game/presentation/cockpit/assets/models/alert_box.glb b/game/presentation/cockpit/assets/models/alert_box.glb new file mode 100644 index 0000000..c647c73 Binary files /dev/null and b/game/presentation/cockpit/assets/models/alert_box.glb differ diff --git a/game/presentation/cockpit/assets/models/cage_lamp.glb b/game/presentation/cockpit/assets/models/cage_lamp.glb new file mode 100644 index 0000000..899767f Binary files /dev/null and b/game/presentation/cockpit/assets/models/cage_lamp.glb differ diff --git a/game/presentation/cockpit/assets/models/gas_bottle.glb b/game/presentation/cockpit/assets/models/gas_bottle.glb new file mode 100644 index 0000000..de5bca7 Binary files /dev/null and b/game/presentation/cockpit/assets/models/gas_bottle.glb differ diff --git a/game/presentation/cockpit/assets/models/gauge_panel.glb b/game/presentation/cockpit/assets/models/gauge_panel.glb new file mode 100644 index 0000000..a686ffa Binary files /dev/null and b/game/presentation/cockpit/assets/models/gauge_panel.glb differ diff --git a/game/presentation/cockpit/assets/models/handrail_l.glb b/game/presentation/cockpit/assets/models/handrail_l.glb new file mode 100644 index 0000000..1b20508 Binary files /dev/null and b/game/presentation/cockpit/assets/models/handrail_l.glb differ diff --git a/game/presentation/cockpit/assets/models/handrail_r.glb b/game/presentation/cockpit/assets/models/handrail_r.glb new file mode 100644 index 0000000..a7b970b Binary files /dev/null and b/game/presentation/cockpit/assets/models/handrail_r.glb differ diff --git a/game/presentation/cockpit/assets/models/hull_wall.glb b/game/presentation/cockpit/assets/models/hull_wall.glb new file mode 100644 index 0000000..3b99f78 Binary files /dev/null and b/game/presentation/cockpit/assets/models/hull_wall.glb differ diff --git a/game/presentation/cockpit/assets/models/porthole.glb b/game/presentation/cockpit/assets/models/porthole.glb new file mode 100644 index 0000000..5821623 Binary files /dev/null and b/game/presentation/cockpit/assets/models/porthole.glb differ diff --git a/game/presentation/cockpit/assets/models/side_console.glb b/game/presentation/cockpit/assets/models/side_console.glb new file mode 100644 index 0000000..75e79f4 Binary files /dev/null and b/game/presentation/cockpit/assets/models/side_console.glb differ diff --git a/game/presentation/cockpit/assets/models/sonar.glb b/game/presentation/cockpit/assets/models/sonar.glb new file mode 100644 index 0000000..aa2deee Binary files /dev/null and b/game/presentation/cockpit/assets/models/sonar.glb differ diff --git a/game/presentation/cockpit/assets/models/speaker.glb b/game/presentation/cockpit/assets/models/speaker.glb new file mode 100644 index 0000000..428c388 Binary files /dev/null and b/game/presentation/cockpit/assets/models/speaker.glb differ diff --git a/game/presentation/cockpit/assets/models/switch_panel.glb b/game/presentation/cockpit/assets/models/switch_panel.glb new file mode 100644 index 0000000..f042e2c Binary files /dev/null and b/game/presentation/cockpit/assets/models/switch_panel.glb differ diff --git a/game/presentation/cockpit/assets/oxygen_gauge_face_albedo_v2.png b/game/presentation/cockpit/assets/oxygen_gauge_face_albedo_v2.png deleted file mode 100644 index 34a3c02..0000000 Binary files a/game/presentation/cockpit/assets/oxygen_gauge_face_albedo_v2.png and /dev/null differ diff --git a/game/presentation/cockpit/cockpit_alert_box.gd b/game/presentation/cockpit/cockpit_alert_box.gd new file mode 100644 index 0000000..bed29c7 --- /dev/null +++ b/game/presentation/cockpit/cockpit_alert_box.gd @@ -0,0 +1,62 @@ +class_name CockpitAlertBox +extends Node3D + +## Drives the three lamps of the imported alert box model. Exactly one lamp is +## lit for the alert level carried by the dive snapshot; the lamps are also +## distinguished by position and by their engraved ALERTE / ATTENTION / DANGER +## plates, so the reading does not depend on colour alone. + +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const CockpitMeshFactory = preload("res://game/presentation/cockpit/cockpit_mesh_factory.gd") + +const LAMP_NAMES: Array[String] = ["Lamp_Alerte", "Lamp_Attention", "Lamp_Danger"] +const LIT_ENERGY := 4.0 +const UNLIT_ENERGY := 0.0 + +var _lamp_materials: Array[BaseMaterial3D] = [] + + +func _ready() -> void: + for lamp_name: String in LAMP_NAMES: + var lamp := find_child(lamp_name, true, false) as MeshInstance3D + var material := CockpitMeshFactory.own_surface_material(lamp) + if material == null: + push_error("Alert box model has no usable %s lamp" % lamp_name) + continue + material.emission_energy_multiplier = UNLIT_ENERGY + _lamp_materials.append(material) + + +func present(alert_level: DiveAlertLevel.Value) -> void: + var active_index := _lamp_index_for(alert_level) + for lamp_index in _lamp_materials.size(): + _lamp_materials[lamp_index].emission_energy_multiplier = ( + LIT_ENERGY if lamp_index == active_index else UNLIT_ENERGY + ) + + +func indicator_count() -> int: + return _lamp_materials.size() + + +func active_indicator_count() -> int: + var count := 0 + for material: BaseMaterial3D in _lamp_materials: + if material.emission_energy_multiplier > UNLIT_ENERGY: + count += 1 + return count + + +func active_indicator_name() -> String: + for lamp_index in _lamp_materials.size(): + if _lamp_materials[lamp_index].emission_energy_multiplier > UNLIT_ENERGY: + return LAMP_NAMES[lamp_index] + return "" + + +func _lamp_index_for(alert_level: DiveAlertLevel.Value) -> int: + if alert_level >= DiveAlertLevel.Value.CRITICAL: + return 2 + if alert_level >= DiveAlertLevel.Value.ATTENTION: + return 1 + return 0 diff --git a/game/presentation/cockpit/cockpit_analog_gauges.gd b/game/presentation/cockpit/cockpit_analog_gauges.gd index 03a1efb..a1ec4f5 100644 --- a/game/presentation/cockpit/cockpit_analog_gauges.gd +++ b/game/presentation/cockpit/cockpit_analog_gauges.gd @@ -1,265 +1,101 @@ class_name CockpitAnalogGauges extends Node3D -const CockpitMaterialPalette = preload("res://game/presentation/cockpit/cockpit_material_palette.gd") +## Drives the imported gauge panel model: four needles and its six pilot lights. +## The model scene is instanced by `cockpit_interior.tscn`; this script only +## rotates needles and sets emission, it never rebuilds geometry. + 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 _oxygen_needle: Node3D -var _battery_needle: Node3D -var _hull_needle: Node3D -var _depth_needle: Node3D -var _indicator_materials: Array[StandardMaterial3D] = [] -var _active_indicator_count := 0 +## Printed dial sweep: ratio 0 points lower-left, ratio 1 lower-right, clockwise. +const NEEDLE_DEGREES_AT_ZERO := 120.0 +const NEEDLE_DEGREES_AT_FULL := -120.0 +## The snapshot carries no per-instrument lamp state, so the panel pilot lights +## stay dark; the alert level is reported by `CockpitAlertBox`. +const PILOT_LIGHT_REST_ENERGY := 0.0 +## Scales the dial backlight baked in the model (1.0 = as exported), so the +## paper reads as lit ivory without outshining the cabin's warm lamps. +const DIAL_BACKLIGHT_ENERGY := 0.7 + +const NEEDLE_NAMES: Array[String] = [ + "Needle_Oxygen", + "Needle_Battery", + "Needle_Hull", + "Needle_Depth", +] + +var _needles: Dictionary[String, Node3D] = {} +var _dials: MeshInstance3D +var _pilot_light_materials: Array[BaseMaterial3D] = [] + + +func _ready() -> void: + for needle_name: String in NEEDLE_NAMES: + var needle := find_child(needle_name, true, false) as Node3D + if needle == null: + push_error("Gauge panel model has no %s node" % needle_name) + continue + _needles[needle_name] = needle + _dials = find_child("GaugeDials", true, false) as MeshInstance3D + var dial_material := CockpitMeshFactory.own_surface_material(_dials) + if dial_material != null: + dial_material.emission_energy_multiplier = DIAL_BACKLIGHT_ENERGY + for light: Node in find_children("Light_*", "MeshInstance3D", true, false): + var material := CockpitMeshFactory.own_surface_material(light as MeshInstance3D) + if material != null: + material.emission_energy_multiplier = PILOT_LIGHT_REST_ENERGY + _pilot_light_materials.append(material) -func _init(materials: CockpitMaterialPalette) -> void: - _materials = materials - name = "AnalogGauges" - _build_instrument_cluster() - _build_indicator_bank() - _oxygen_needle = _add_gauge( - "OxygenGauge", - Vector3(-0.52, 0.12, 0.145), - 0.94, - _materials.oxygen_gauge_face - ) - _battery_needle = _add_gauge( - "BatteryGauge", - Vector3(-0.22, 0.12, 0.145), - 0.94, - _materials.battery_gauge_face - ) - _hull_needle = _add_gauge( - "HullGauge", - Vector3(0.09, 0.12, 0.145), - 0.86, - _materials.hull_gauge_face - ) - _depth_needle = _add_gauge( - "DepthGauge", - Vector3(0.47, 0.12, 0.145), - 1.12, - _materials.depth_gauge_face - ) func present( oxygen_ratio: float, battery_ratio: float, hull_ratio: float, - depth_ratio: float, - alert_level: DiveAlertLevel.Value + depth_ratio: float ) -> void: - _set_needle_ratio(_oxygen_needle, oxygen_ratio) - _set_needle_ratio(_battery_needle, battery_ratio) - _set_needle_ratio(_hull_needle, hull_ratio) - _set_needle_ratio(_depth_needle, depth_ratio) - _update_indicator_bank(alert_level) + _set_needle_ratio("Needle_Oxygen", oxygen_ratio) + _set_needle_ratio("Needle_Battery", battery_ratio) + _set_needle_ratio("Needle_Hull", hull_ratio) + _set_needle_ratio("Needle_Depth", depth_ratio) + + +func gauge_count() -> int: + return _needles.size() + + +func pilot_light_count() -> int: + return _pilot_light_materials.size() + + +func lit_pilot_light_count() -> int: + var count := 0 + for material: BaseMaterial3D in _pilot_light_materials: + if material.emission_energy_multiplier > 0.0: + count += 1 + return count + + +func needle_degrees(needle_name: String) -> float: + var needle: Node3D = _needles.get(needle_name) + return 0.0 if needle == null else needle.rotation_degrees.z 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 gauge_count() -> int: - return 4 - -func active_indicator_count() -> int: - return _active_indicator_count + return needle_degrees("Needle_Battery") -func _build_instrument_cluster() -> void: - CockpitMeshFactory.add_box( - "InstrumentClusterHousing", - self, - Vector3(-0.04, 0.105, 0.095), - Vector3(1.42, 0.31, 0.105), - _materials.overhead_steel - ) - CockpitMeshFactory.add_box( - "InstrumentClusterFoot", - self, - Vector3(-0.04, -0.025, 0.075), - Vector3(1.48, 0.040, 0.14), - _materials.dark_steel - ) - -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 y := 0.165 - indicator_index * 0.050 - CockpitMeshFactory.add_cylinder( - "%sBezel" % captions[indicator_index], - root, - Vector3(0.68, y, 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(0.68, y, 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(0.71, y, 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 dial_texture_is_loaded() -> bool: + if _dials == null or _dials.mesh == null or _dials.mesh.get_surface_count() == 0: + return false + var material := _dials.mesh.surface_get_material(0) as BaseMaterial3D + return material != null and material.albedo_texture != null -func _add_gauge( - gauge_name: String, - position: Vector3, - gauge_scale: float, - face_material: Material -) -> Node3D: - var gauge := Node3D.new() - gauge.name = gauge_name - gauge.position = position - gauge.scale = Vector3(gauge_scale, gauge_scale, gauge_scale) - add_child(gauge) - CockpitMeshFactory.add_box( - "MountingPlate", - gauge, - Vector3(0.0, 0.0, -0.025), - Vector3(0.225, 0.225, 0.055), - _materials.overhead_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) - ) - _add_bezel(gauge, 0.108, 0.046, _materials.dark_steel) - _add_bezel(gauge, 0.099, 0.041, _materials.brushed_steel) - var face := MeshInstance3D.new() - var printed_face := QuadMesh.new() - printed_face.size = Vector2(0.178, 0.178) - face.mesh = printed_face - face.position.z = 0.031 - face.material_override = face_material - gauge.add_child(face) - var needle_root := Node3D.new() - needle_root.name = "Needle" - needle_root.position.z = 0.038 - gauge.add_child(needle_root) - CockpitMeshFactory.add_box( - "NeedleBlade", - needle_root, - Vector3(0.0, 0.030, 0.0), - Vector3(0.006, 0.060, 0.006), - _materials.dark_steel - ) - CockpitMeshFactory.add_box( - "NeedleTip", - needle_root, - Vector3(0.0, 0.061, 0.001), - Vector3(0.004, 0.024, 0.006), - _materials.needle_paint - ) - CockpitMeshFactory.add_box( - "NeedleCounterweight", - needle_root, - Vector3(0.0, -0.014, -0.001), - Vector3(0.013, 0.030, 0.007), - _materials.dark_steel - ) - CockpitMeshFactory.add_cylinder( - "NeedleHub", - needle_root, - Vector3.ZERO, - 0.018, - 0.012, - _materials.dark_steel, - Vector3(90.0, 0.0, 0.0) - ) - var glass := MeshInstance3D.new() - glass.name = "GaugeGlass" - var glass_mesh := CylinderMesh.new() - glass_mesh.top_radius = 0.089 - glass_mesh.bottom_radius = 0.089 - glass_mesh.height = 0.004 - glass_mesh.radial_segments = 40 - glass.mesh = glass_mesh - glass.material_override = _materials.gauge_glass - glass.rotation_degrees.x = 90.0 - glass.position.z = 0.050 - glass.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - gauge.add_child(glass) - return needle_root - - -func _add_bezel( - gauge: Node3D, - radius: float, - height: float, - material: Material -) -> void: - var bezel := MeshInstance3D.new() - var bezel_mesh := CylinderMesh.new() - bezel_mesh.top_radius = radius - bezel_mesh.bottom_radius = radius - bezel_mesh.height = height - bezel_mesh.radial_segments = 48 - bezel.mesh = bezel_mesh - bezel.material_override = material - bezel.rotation_degrees.x = 90.0 - gauge.add_child(bezel) - - -func _set_needle_ratio(needle: Node3D, ratio: float) -> void: +func _set_needle_ratio(needle_name: String, ratio: float) -> void: + var needle: Node3D = _needles.get(needle_name) if needle != null: - needle.rotation_degrees.z = lerpf(125.0, -125.0, clampf(ratio, 0.0, 1.0)) + needle.rotation_degrees.z = lerpf( + NEEDLE_DEGREES_AT_ZERO, + NEEDLE_DEGREES_AT_FULL, + clampf(ratio, 0.0, 1.0) + ) diff --git a/game/presentation/cockpit/cockpit_dashboard_detailing.gd b/game/presentation/cockpit/cockpit_dashboard_detailing.gd index 36e7061..df52a87 100644 --- a/game/presentation/cockpit/cockpit_dashboard_detailing.gd +++ b/game/presentation/cockpit/cockpit_dashboard_detailing.gd @@ -1,26 +1,12 @@ class_name CockpitDashboardDetailing 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 _control_count := 0 +## The dashboard keeps its hidden digital readouts (their labels still carry the +## exact values); this hides the placeholder parts the gauge panel replaced. -func _init(materials: CockpitMaterialPalette) -> void: - _materials = materials +func _init() -> void: name = "DashboardDetailing" - _build_structural_frame() - _build_switch_banks() - - -func control_count() -> int: - return _control_count func hide_obsolete_elements(cockpit: Node3D) -> void: @@ -42,79 +28,3 @@ func hide_obsolete_elements(cockpit: Node3D) -> void: var readout := cockpit.get_node_or_null("Dashboard/%s" % readout_name) as Node3D if readout != null: readout.visible = false - - -func _build_structural_frame() -> void: - CockpitMeshFactory.add_box( - "TopRail", - self, - Vector3(0.0, 0.255, 0.115), - Vector3(1.50, 0.055, 0.055), - _materials.dark_steel - ) - CockpitMeshFactory.add_box( - "BottomRail", - self, - Vector3(0.0, -0.205, 0.115), - Vector3(1.50, 0.050, 0.055), - _materials.dark_steel - ) - for x: float in [-0.755, 0.755]: - CockpitMeshFactory.add_box( - "SideRail", - self, - Vector3(x, 0.025, 0.115), - Vector3(0.050, 0.46, 0.055), - _materials.dark_steel - ) - for screw_position: Vector2 in [ - Vector2(-0.72, -0.18), - Vector2(-0.72, 0.23), - Vector2(0.72, -0.18), - Vector2(0.72, 0.23), - Vector2(-0.24, -0.18), - Vector2(0.24, -0.18), - ]: - CockpitMeshFactory.add_cylinder( - "PanelScrew", - self, - Vector3(screw_position.x, screw_position.y, 0.151), - 0.010, - 0.010, - _materials.brass, - Vector3(90.0, 0.0, 0.0) - ) - - -func _build_switch_banks() -> void: - _add_switch_bank(-0.59, "AUX") - _add_switch_bank(0.59, "POMPES") - - -func _add_switch_bank(center_x: float, caption: String) -> void: - CockpitMeshFactory.add_box( - "SwitchPlate", - self, - Vector3(center_x, -0.205, 0.145), - Vector3(0.25, 0.060, 0.020), - _materials.overhead_steel - ) - for offset: float in [-0.075, -0.025, 0.025, 0.075]: - CockpitMeshFactory.add_cylinder( - "ToggleSwitch", - self, - Vector3(center_x + offset, -0.195, 0.171), - 0.006, - 0.030, - _materials.dark_steel, - Vector3(90.0, 0.0, 0.0) - ) - _control_count += 1 - var label := Label3D.new() - label.name = "%sLabel" % caption.to_pascal_case() - label.text = caption - label.font_size = 10 - label.pixel_size = 0.00055 - label.modulate = Color(0.72, 0.69, 0.56) - label.position = Vector3(center_x, -0.235, 0.165) - add_child(label) diff --git a/game/presentation/cockpit/cockpit_detailing.gd b/game/presentation/cockpit/cockpit_detailing.gd index 7374145..acc8115 100644 --- a/game/presentation/cockpit/cockpit_detailing.gd +++ b/game/presentation/cockpit/cockpit_detailing.gd @@ -4,6 +4,7 @@ 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 CockpitAlertBox = preload("res://game/presentation/cockpit/cockpit_alert_box.gd") const CockpitAnalogGauges = preload("res://game/presentation/cockpit/cockpit_analog_gauges.gd") const CockpitDashboardDetailing = preload( "res://game/presentation/cockpit/cockpit_dashboard_detailing.gd" @@ -19,13 +20,17 @@ const CockpitSonarConsole = preload( const CockpitPracticalLighting = preload( "res://game/presentation/cockpit/cockpit_practical_lighting.gd" ) +const CockpitSwitchPanel = preload( + "res://game/presentation/cockpit/cockpit_switch_panel.gd" +) var _materials: CockpitMaterialPalette var _painted_steel: Material var _dark_steel: Material -var _brass: Material -var _copper: Material var _analog_gauges: CockpitAnalogGauges +var _alert_box: CockpitAlertBox +var _switch_panel: CockpitSwitchPanel +var _gas_bottle: Node3D var _dashboard_detailing: CockpitDashboardDetailing var _side_equipment: CockpitSideEquipment var _sonar_console: CockpitSonarConsole @@ -35,9 +40,6 @@ var _practical_lighting: CockpitPracticalLighting func _ready() -> void: _create_materials() _apply_materials_to_existing_cockpit() - _build_pressure_hull_ribs() - _build_life_support_pipes() - _build_panel_hardware() _build_dashboard_instrumentation() _build_equipment_assemblies() @@ -46,32 +48,24 @@ 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.oxygen.ratio, - snapshot.battery.ratio, - snapshot.hull.ratio, - clampf( - snapshot.depth.current_m - / maxf(snapshot.depth.certified_m, 0.001), - 0.0, - 1.0 - ), - snapshot.alerts.maximum - ) var alert_level: DiveAlertLevel.Value = snapshot.alerts.maximum + if _analog_gauges != null: + _analog_gauges.present( + snapshot.oxygen.ratio, + snapshot.battery.ratio, + snapshot.hull.ratio, + clampf( + snapshot.depth.current_m + / maxf(snapshot.depth.certified_m, 0.001), + 0.0, + 1.0 + ) + ) + if _alert_box != null: + _alert_box.present(alert_level) _practical_lighting.present(alert_level) -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() @@ -80,25 +74,30 @@ func textures_are_loaded() -> bool: return ( _materials != null and _materials.overhead_steel.albedo_texture != null - and _materials.oxygen_gauge_face.albedo_texture != null - and _materials.battery_gauge_face.albedo_texture != null - and _materials.hull_gauge_face.albedo_texture != null - and _materials.depth_gauge_face.albedo_texture != null + and _analog_gauges != null + and _analog_gauges.dial_texture_is_loaded() ) func dashboard_control_count() -> int: - return 0 if _dashboard_detailing == null else _dashboard_detailing.control_count() + return 0 if _switch_panel == null else _switch_panel.control_count() func side_equipment_count() -> int: if _side_equipment == null: return 0 - return _side_equipment.tank_count() + _side_equipment.junction_box_count() + return _side_equipment.junction_box_count() + (0 if _gas_bottle == null else 1) func side_control_count() -> int: - return 0 if _side_equipment == null else _side_equipment.control_count() + if _side_equipment == null: + return 0 + var bottle_valve_count := ( + 0 + if _gas_bottle == null + else _gas_bottle.find_children("*ValveWheel", "Node3D", true, false).size() + ) + return _side_equipment.control_count() + bottle_valve_count func sonar_control_count() -> int: @@ -117,8 +116,6 @@ func _create_materials() -> void: _materials = CockpitMaterialPalette.new() _painted_steel = _materials.painted_steel _dark_steel = _materials.dark_steel - _brass = _materials.brass - _copper = _materials.copper func _apply_materials_to_existing_cockpit() -> void: @@ -127,39 +124,12 @@ func _apply_materials_to_existing_cockpit() -> void: [ "CabinShell/LeftWall", "CabinShell/RightWall", - "LeftConsole", - "RightConsole", - ] - ) - _apply_material_to_paths( - _dark_steel, - [ - "CabinShell/RearBulkhead", - "PortholeAssembly/LeftBulkhead", - "PortholeAssembly/RightBulkhead", - "PortholeAssembly/BottomBulkhead", - "ThrottleLever", - "VerticalLever", ] ) + _apply_material_to_paths(_dark_steel, ["CabinShell/RearBulkhead"]) _apply_material_to_paths( _materials.overhead_steel, - [ - "CabinShell/Ceiling", - "PortholeAssembly/TopBulkhead", - "Dashboard/MainPanel", - ] - ) - _apply_material_to_paths( - _brass, - [ - "PortholeAssembly/WindowFrame", - "Dashboard/ResourcesFrame", - "Dashboard/NavigationFrame", - "Dashboard/IntegrityFrame", - "ThrottleKnob", - "VerticalKnob", - ] + ["CabinShell/Ceiling"] ) func _apply_material_to_paths(material: Material, paths: Array[String]) -> void: var cockpit := get_parent() @@ -169,128 +139,26 @@ func _apply_material_to_paths(material: Material, paths: Array[String]) -> void: 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_between( - "UpperBranchPipe", - root, - Vector3(x, 0.84, -1.12), - Vector3(side * 0.72, 0.77, -1.10), - 0.019, - _brass - ) - - -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) - ) - - - func _build_dashboard_instrumentation() -> void: var dashboard := get_parent().get_node_or_null("Dashboard") as Node3D if dashboard == null: return - _dashboard_detailing = CockpitDashboardDetailing.new(_materials) + _dashboard_detailing = CockpitDashboardDetailing.new() dashboard.add_child(_dashboard_detailing) _dashboard_detailing.hide_obsolete_elements(get_parent()) - _analog_gauges = CockpitAnalogGauges.new(_materials) - dashboard.add_child(_analog_gauges) + _analog_gauges = dashboard.get_node_or_null("AnalogGauges") as CockpitAnalogGauges func _build_equipment_assemblies() -> void: _side_equipment = CockpitSideEquipment.new(_materials) add_child(_side_equipment) - var sonar_monitor := get_parent().get_node_or_null("SonarMonitor") as Node3D - if sonar_monitor != null: - _sonar_console = CockpitSonarConsole.new(_materials) - sonar_monitor.add_child(_sonar_console) + var cockpit := get_parent() + _sonar_console = cockpit.get_node_or_null("SonarMonitor") as CockpitSonarConsole + _alert_box = cockpit.get_node_or_null("AlertBox") as CockpitAlertBox + _switch_panel = cockpit.get_node_or_null("SwitchPanel") as CockpitSwitchPanel + _gas_bottle = cockpit.get_node_or_null("GasBottle") as Node3D _practical_lighting = CockpitPracticalLighting.new(_materials) add_child(_practical_lighting) + # The alert box hangs between the left lamp and the gas bottle: casting, it + # puts the bottle in full shadow. + CockpitMeshFactory.disable_shadow_casting(_alert_box) diff --git a/game/presentation/cockpit/cockpit_instrument_panel.gd b/game/presentation/cockpit/cockpit_instrument_panel.gd index 45c2d98..13bc191 100644 --- a/game/presentation/cockpit/cockpit_instrument_panel.gd +++ b/game/presentation/cockpit/cockpit_instrument_panel.gd @@ -12,6 +12,9 @@ const CockpitDetailing = preload( const COLOR_NORMAL := Color(0.45, 0.89, 0.90, 1.0) const COLOR_ATTENTION := Color(1.0, 0.75, 0.20, 1.0) const COLOR_CRITICAL := Color(1.0, 0.42, 0.31, 1.0) +## 1.0 shows the sonar picture as rendered; slightly above lets its brightest +## lines bloom without clipping the surface colours. +const SONAR_SCREEN_ENERGY := 1.25 @onready var _resources_label: Label3D = %ResourcesLabel @onready var _navigation_label: Label3D = %NavigationLabel @@ -84,12 +87,18 @@ func return_text() -> String: func attach_sonar_texture(texture: Texture2D) -> void: _sonar_texture = texture + # The glass shows the picture by emission only: black albedo, no specular, + # so cabin lights add nothing to it, and the energy can lift the brightest + # lines just above 1.0 for the environment glow to pick up. var material := StandardMaterial3D.new() - material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.albedo_texture = texture + material.albedo_color = Color.BLACK + material.metallic_specular = 0.0 + material.roughness = 1.0 material.emission_enabled = true + material.emission = Color.WHITE + material.emission_operator = BaseMaterial3D.EMISSION_OP_MULTIPLY material.emission_texture = texture - material.emission_energy_multiplier = 1.7 + material.emission_energy_multiplier = SONAR_SCREEN_ENERGY _sonar_screen.material_override = material _sonar_placeholder.visible = false diff --git a/game/presentation/cockpit/cockpit_interior.tscn b/game/presentation/cockpit/cockpit_interior.tscn index 6910937..687dd3a 100644 --- a/game/presentation/cockpit/cockpit_interior.tscn +++ b/game/presentation/cockpit/cockpit_interior.tscn @@ -1,8 +1,23 @@ -[gd_scene load_steps=21 format=3] +[gd_scene load_steps=31 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"] +[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_analog_gauges.gd" id="4_gauges"] +[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_sonar_console.gd" id="5_sonar_console"] +[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_alert_box.gd" id="6_alert_box"] +[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_switch_panel.gd" id="7_switch_panel"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/gauge_panel.glb" id="8_gauge_panel_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/sonar.glb" id="9_sonar_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/switch_panel.glb" id="10_switch_panel_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/alert_box.glb" id="11_alert_box_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/speaker.glb" id="12_speaker_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/hull_wall.glb" id="13_hull_wall_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/porthole.glb" id="14_porthole_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/gas_bottle.glb" id="15_gas_bottle_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/handrail_l.glb" id="16_handrail_l_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/handrail_r.glb" id="17_handrail_r_model"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/assets/models/side_console.glb" id="18_side_console_model"] [sub_resource type="StandardMaterial3D" id="SteelMaterial"] albedo_color = Color(0.055, 0.07, 0.07, 1) @@ -23,13 +38,6 @@ roughness = 0.43 albedo_color = Color(0.018, 0.024, 0.025, 1) roughness = 0.88 -[sub_resource type="StandardMaterial3D" id="GlassMaterial"] -transparency = 1 -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"] albedo_color = Color(0.006, 0.025, 0.028, 1) metallic = 0.15 @@ -62,33 +70,9 @@ emission_energy_multiplier = 2.8 [sub_resource type="BoxMesh" id="UnitBoxMesh"] size = Vector3(1, 1, 1) -[sub_resource type="TorusMesh" id="PortholeTorusMesh"] -inner_radius = 0.56 -outer_radius = 0.71 -rings = 48 -ring_segments = 16 - -[sub_resource type="CylinderMesh" id="GlassCylinderMesh"] -top_radius = 0.56 -bottom_radius = 0.56 -height = 0.012 -radial_segments = 64 - -[sub_resource type="CylinderMesh" id="BoltMesh"] -top_radius = 0.026 -bottom_radius = 0.026 -height = 0.045 -radial_segments = 12 - [sub_resource type="QuadMesh" id="ScreenQuadMesh"] size = Vector2(1, 1) -[sub_resource type="CylinderMesh" id="LeverMesh"] -top_radius = 0.018 -bottom_radius = 0.023 -height = 0.24 -radial_segments = 12 - [sub_resource type="SphereMesh" id="KnobMesh"] radius = 0.046 height = 0.092 @@ -111,20 +95,20 @@ script = ExtResource("2_detailing") [node name="CabinShell" type="Node3D" parent="."] [node name="LeftWall" type="MeshInstance3D" parent="CabinShell"] -position = Vector3(-1.03, 0.24, -0.15) -scale = Vector3(0.16, 1.82, 2.7) +position = Vector3(-1.25, 0.47, 0.53) +scale = Vector3(0.16, 2.62, 1.1) mesh = SubResource("UnitBoxMesh") material_override = SubResource("PaintedMaterial") [node name="RightWall" type="MeshInstance3D" parent="CabinShell"] -position = Vector3(1.03, 0.24, -0.15) -scale = Vector3(0.16, 1.82, 2.7) +position = Vector3(1.25, 0.47, 0.53) +scale = Vector3(0.16, 2.62, 1.1) mesh = SubResource("UnitBoxMesh") material_override = SubResource("PaintedMaterial") [node name="Ceiling" type="MeshInstance3D" parent="CabinShell"] -position = Vector3(0, 1.22, -0.2) -scale = Vector3(2.15, 0.16, 2.6) +position = Vector3(0, 1.72, 0.53) +scale = Vector3(2.66, 0.16, 1.1) mesh = SubResource("UnitBoxMesh") material_override = SubResource("SteelMaterial") @@ -135,29 +119,11 @@ mesh = SubResource("UnitBoxMesh") material_override = SubResource("RubberMaterial") [node name="RearBulkhead" type="MeshInstance3D" parent="CabinShell"] -position = Vector3(0, 0.22, 1.08) -scale = Vector3(2.08, 1.75, 0.16) +position = Vector3(0, 0.47, 1.08) +scale = Vector3(2.66, 2.62, 0.16) mesh = SubResource("UnitBoxMesh") material_override = SubResource("SteelMaterial") -[node name="LeftRib" type="MeshInstance3D" parent="CabinShell"] -position = Vector3(-0.88, 0.26, -0.45) -scale = Vector3(0.11, 1.75, 0.18) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("BrassMaterial") - -[node name="RightRib" type="MeshInstance3D" parent="CabinShell"] -position = Vector3(0.88, 0.26, -0.45) -scale = Vector3(0.11, 1.75, 0.18) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("BrassMaterial") - -[node name="CeilingRib" type="MeshInstance3D" parent="CabinShell"] -position = Vector3(0, 1.05, -0.45) -scale = Vector3(1.82, 0.1, 0.18) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("BrassMaterial") - [node name="PortholeAssembly" type="Node3D" parent="."] position = Vector3(0, 0.47, -1.32) @@ -165,107 +131,15 @@ position = Vector3(0, 0.47, -1.32) 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) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("SteelMaterial") +[node name="HullWall" parent="PortholeAssembly/CurvedPressureBulkhead" instance=ExtResource("13_hull_wall_model")] -[node name="RightBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0.94, 0, 0) -scale = Vector3(0.6, 1.82, 0.2) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("SteelMaterial") - -[node name="TopBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0, 0.91, 0) -scale = Vector3(1.55, 0.52, 0.2) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("SteelMaterial") - -[node name="BottomBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0, -0.91, 0) -scale = Vector3(1.55, 0.52, 0.2) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("SteelMaterial") - -[node name="WindowFrame" type="MeshInstance3D" parent="PortholeAssembly"] -unique_name_in_owner = true -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("PortholeTorusMesh") -material_override = SubResource("BrassMaterial") - -[node name="RubberSeal" type="MeshInstance3D" parent="PortholeAssembly"] -scale = Vector3(0.91, 0.91, 0.91) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("PortholeTorusMesh") -material_override = SubResource("RubberMaterial") - -[node name="WindowGlass" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0, 0, -0.01) -rotation_degrees = Vector3(90, 0, 0) -cast_shadow = 0 -mesh = SubResource("GlassCylinderMesh") -material_override = SubResource("GlassMaterial") - -[node name="BoltTop" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0, 0.665, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") - -[node name="BoltBottom" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0, -0.665, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") - -[node name="BoltLeft" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(-0.665, 0, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") - -[node name="BoltRight" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0.665, 0, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") - -[node name="BoltUpperLeft" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(-0.47, 0.47, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") - -[node name="BoltUpperRight" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0.47, 0.47, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") - -[node name="BoltLowerLeft" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(-0.47, -0.47, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") - -[node name="BoltLowerRight" type="MeshInstance3D" parent="PortholeAssembly"] -position = Vector3(0.47, -0.47, 0.1) -rotation_degrees = Vector3(90, 0, 0) -mesh = SubResource("BoltMesh") -material_override = SubResource("SteelMaterial") +[node name="Porthole" parent="PortholeAssembly/CurvedPressureBulkhead" instance=ExtResource("14_porthole_model")] [node name="Dashboard" type="Node3D" parent="."] unique_name_in_owner = true position = Vector3(0, 0.05, -1.22) rotation_degrees = Vector3(-12, 0, 0) -[node name="MainPanel" type="MeshInstance3D" parent="Dashboard"] -scale = Vector3(1.56, 0.44, 0.14) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("PaintedMaterial") - [node name="ResourcesFrame" type="MeshInstance3D" parent="Dashboard"] position = Vector3(-0.49, -0.115, 0.14) scale = Vector3(0.34, 0.065, 0.025) @@ -331,7 +205,7 @@ modulate = Color(0.76, 0.74, 0.6, 1) [node name="ReturnLabel" type="Label3D" parent="Dashboard"] unique_name_in_owner = true -position = Vector3(0.61, 0.245, 0.17) +position = Vector3(0.22, 0.266, 0.158) pixel_size = 0.00062 font_size = 12 outline_size = 2 @@ -340,89 +214,86 @@ modulate = Color(1, 0.75, 0.2, 1) [node name="StatusLabel" type="Label3D" parent="Dashboard"] unique_name_in_owner = true -position = Vector3(-0.61, 0.245, 0.17) +position = Vector3(-0.36, 0.266, 0.158) pixel_size = 0.00062 font_size = 12 outline_size = 2 text = "SYSTÈMES · NORMAL" modulate = Color(0.45, 0.89, 0.9, 1) -[node name="LeftConsole" type="MeshInstance3D" parent="."] -position = Vector3(-0.82, -0.23, -1.19) -rotation_degrees = Vector3(-8, 18, 4) -scale = Vector3(0.32, 0.16, 0.56) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("PaintedMaterial") +[node name="AnalogGauges" type="Node3D" parent="Dashboard"] +position = Vector3(0, -0.06, 0.15) +script = ExtResource("4_gauges") -[node name="RightConsole" type="MeshInstance3D" parent="."] -position = Vector3(0.82, -0.23, -1.19) -rotation_degrees = Vector3(-8, -18, -4) -scale = Vector3(0.32, 0.16, 0.56) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("PaintedMaterial") - -[node name="ThrottleLever" type="MeshInstance3D" parent="."] -position = Vector3(-0.79, -0.06, -1.13) -rotation_degrees = Vector3(18, 0, -10) -scale = Vector3(0.72, 0.72, 0.72) -mesh = SubResource("LeverMesh") -material_override = SubResource("SteelMaterial") - -[node name="ThrottleKnob" type="MeshInstance3D" parent="."] -position = Vector3(-0.81, 0.025, -1.1) -scale = Vector3(0.72, 0.72, 0.72) -mesh = SubResource("KnobMesh") -material_override = SubResource("BrassMaterial") - -[node name="VerticalLever" type="MeshInstance3D" parent="."] -position = Vector3(0.79, -0.06, -1.13) -rotation_degrees = Vector3(18, 0, 10) -scale = Vector3(0.72, 0.72, 0.72) -mesh = SubResource("LeverMesh") -material_override = SubResource("SteelMaterial") - -[node name="VerticalKnob" type="MeshInstance3D" parent="."] -position = Vector3(0.81, 0.025, -1.1) -scale = Vector3(0.72, 0.72, 0.72) -mesh = SubResource("KnobMesh") -material_override = SubResource("BrassMaterial") +[node name="GaugePanelModel" parent="Dashboard/AnalogGauges" instance=ExtResource("8_gauge_panel_model")] [node name="SonarMonitor" type="Node3D" parent="."] -position = Vector3(0.54, 0.59, -1.04) +position = Vector3(0.54, 0.48, -1.04) rotation_degrees = Vector3(-5, -42, -2) -scale = Vector3(0.6, 0.6, 0.6) +script = ExtResource("5_sonar_console") -[node name="Housing" type="MeshInstance3D" parent="SonarMonitor"] -scale = Vector3(0.6, 0.37, 0.1) -mesh = SubResource("UnitBoxMesh") -material_override = SubResource("PaintedMaterial") - -[node name="SonarScreen" type="MeshInstance3D" parent="SonarMonitor"] -unique_name_in_owner = true -position = Vector3(-0.025, 0.012, 0.06) -scale = Vector3(0.36, 0.235, 1) -mesh = SubResource("ScreenQuadMesh") -material_override = SubResource("ScreenMaterial") +[node name="SonarModel" parent="SonarMonitor" instance=ExtResource("9_sonar_model")] +scale = Vector3(0.31, 0.31, 0.31) [node name="SonarPlaceholder" type="Label3D" parent="SonarMonitor"] unique_name_in_owner = true -position = Vector3(-0.025, 0.012, 0.068) -pixel_size = 0.0009 +position = Vector3(-0.0164, 0.002, 0.026) +pixel_size = 0.00054 font_size = 16 outline_size = 2 -text = "SONAR\nMODULE EN ATTENTE" +text = "SONAR +MODULE EN ATTENTE" modulate = Color(0.34, 0.6, 0.6, 1) +[node name="AlertBox" type="Node3D" parent="."] +position = Vector3(-0.86, 0.555, -1.214) +rotation_degrees = Vector3(0, 15, 0) +script = ExtResource("6_alert_box") + +[node name="AlertBoxModel" parent="AlertBox" instance=ExtResource("11_alert_box_model")] +scale = Vector3(0.5, 0.5, 0.5) + +[node name="SwitchPanel" type="Node3D" parent="."] +position = Vector3(-0.74, -0.125, -0.97) +rotation_degrees = Vector3(0, 30, 0) +script = ExtResource("7_switch_panel") + +[node name="SwitchPanelModel" parent="SwitchPanel" instance=ExtResource("10_switch_panel_model")] +scale = Vector3(0.37, 0.37, 0.37) + +[node name="Speaker" parent="." instance=ExtResource("12_speaker_model")] +position = Vector3(0.83, 0.84, -1.22) +rotation_degrees = Vector3(0, -15, 0) +scale = Vector3(0.56, 0.56, 0.56) + +[node name="GasBottle" parent="." instance=ExtResource("15_gas_bottle_model")] +position = Vector3(-1.02, 0.36, -1.225) +rotation_degrees = Vector3(0, 53.3, 0) +scale = Vector3(0.65, 0.65, 0.65) + +[node name="HandrailLeft" parent="." instance=ExtResource("16_handrail_l_model")] +position = Vector3(-0.6, 0.24, -1.1) + +[node name="HandrailRight" parent="." instance=ExtResource("17_handrail_r_model")] +position = Vector3(0.6, 0.24, -1.1) + +[node name="SideConsoleLeft" parent="." instance=ExtResource("18_side_console_model")] +position = Vector3(-0.632, -0.61, -1.215) + +[node name="SideConsoleRight" parent="." instance=ExtResource("18_side_console_model")] +position = Vector3(0.632, -0.61, -1.215) +scale = Vector3(-1, 1, 1) + [node name="EmergencyDial" type="MeshInstance3D" parent="."] -position = Vector3(-0.7, 0.32, -1.15) -scale = Vector3(0.18, 0.18, 0.18) +position = Vector3(-0.845, 0.47, -1.283) +scale = Vector3(0.3, 0.3, 0.3) rotation_degrees = Vector3(90, 0, 0) mesh = SubResource("DialMesh") material_override = SubResource("RedMaterial") [node name="EmergencyLabel" type="Label3D" parent="."] -position = Vector3(-0.7, 0.275, -1.125) -pixel_size = 0.00072 +position = Vector3(-0.845, 0.418, -1.28) +pixel_size = 0.0015 font_size = 12 outline_size = 2 text = "ÉJECTION · X" @@ -449,15 +320,15 @@ 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.36, 0.68, 0.69, 1) -light_energy = 1.25 +light_color = Color(1, 0.7, 0.46, 1) +light_energy = 0.04 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.58 +light_color = Color(1, 0.72, 0.5, 1) +light_energy = 0.4 shadow_enabled = false -omni_range = 1.35 +omni_range = 0.8 diff --git a/game/presentation/cockpit/cockpit_material_palette.gd b/game/presentation/cockpit/cockpit_material_palette.gd index 5797657..951a1e7 100644 --- a/game/presentation/cockpit/cockpit_material_palette.gd +++ b/game/presentation/cockpit/cockpit_material_palette.gd @@ -4,18 +4,6 @@ extends RefCounted const NAVAL_STEEL_TEXTURE_PATH := ( "res://game/presentation/cockpit/assets/naval_painted_steel_albedo.png" ) -const OXYGEN_GAUGE_FACE_TEXTURE_PATH := ( - "res://game/presentation/cockpit/assets/oxygen_gauge_face_albedo_v2.png" -) -const BATTERY_GAUGE_FACE_TEXTURE_PATH := ( - "res://game/presentation/cockpit/assets/battery_gauge_face_albedo_v2.png" -) -const HULL_GAUGE_FACE_TEXTURE_PATH := ( - "res://game/presentation/cockpit/assets/hull_gauge_face_albedo_v2.png" -) -const DEPTH_GAUGE_FACE_TEXTURE_PATH := ( - "res://game/presentation/cockpit/assets/depth_gauge_face_albedo_v2.png" -) var painted_steel: Material var dark_steel: Material @@ -24,21 +12,8 @@ var brushed_steel: StandardMaterial3D var brass: Material var copper: Material var rubber: Material -var oxygen_gauge_face: StandardMaterial3D -var battery_gauge_face: StandardMaterial3D -var hull_gauge_face: StandardMaterial3D -var depth_gauge_face: StandardMaterial3D -var gauge_glass: StandardMaterial3D -var needle_paint: StandardMaterial3D var instrument_screen: ShaderMaterial var warm_emission: StandardMaterial3D -var safety_yellow: StandardMaterial3D -var signal_red: StandardMaterial3D -var indicator_green: StandardMaterial3D -var indicator_amber: StandardMaterial3D -var indicator_red: StandardMaterial3D -var sonar_grid: StandardMaterial3D -var warm_glass: StandardMaterial3D func _init() -> void: @@ -79,21 +54,8 @@ func _init() -> void: 0.12 ) rubber = _plain_material(Color(0.012, 0.018, 0.018), 0.0, 0.92) - oxygen_gauge_face = _dedicated_gauge_material(OXYGEN_GAUGE_FACE_TEXTURE_PATH) - battery_gauge_face = _dedicated_gauge_material(BATTERY_GAUGE_FACE_TEXTURE_PATH) - hull_gauge_face = _dedicated_gauge_material(HULL_GAUGE_FACE_TEXTURE_PATH) - depth_gauge_face = _dedicated_gauge_material(DEPTH_GAUGE_FACE_TEXTURE_PATH) - gauge_glass = _glass_material() - needle_paint = _plain_material(Color(0.68, 0.055, 0.022), 0.12, 0.44) instrument_screen = _screen_material() warm_emission = _emissive_material(Color(0.96, 0.52, 0.18), 2.4) - safety_yellow = _plain_material(Color(0.48, 0.31, 0.035), 0.48, 0.53) - signal_red = _plain_material(Color(0.42, 0.025, 0.015), 0.38, 0.48) - indicator_green = _emissive_material(Color(0.05, 0.82, 0.24), 2.8) - indicator_amber = _emissive_material(Color(1.0, 0.43, 0.035), 2.8) - indicator_red = _emissive_material(Color(0.9, 0.045, 0.018), 2.8) - sonar_grid = _emissive_material(Color(0.02, 0.20, 0.22), 0.55) - warm_glass = _warm_glass_material() func _weathered_material( @@ -185,27 +147,6 @@ func _load_texture(resource_path: String) -> Texture2D: return texture -func _dedicated_gauge_material(resource_path: String) -> StandardMaterial3D: - var material := _textured_material( - _load_texture(resource_path), - Color(0.94, 0.92, 0.86), - 0.02, - 0.66 - ) - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - return material - - -func _glass_material() -> StandardMaterial3D: - var material := StandardMaterial3D.new() - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - material.albedo_color = Color(0.42, 0.68, 0.66, 0.13) - material.metallic = 0.12 - material.roughness = 0.08 - material.cull_mode = BaseMaterial3D.CULL_DISABLED - return material - - func _screen_material() -> ShaderMaterial: var shader := Shader.new() shader.code = """ @@ -238,14 +179,3 @@ func _emissive_material(color: Color, energy: float) -> StandardMaterial3D: material.emission = color material.emission_energy_multiplier = energy return material - - -func _warm_glass_material() -> StandardMaterial3D: - var material := StandardMaterial3D.new() - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - material.albedo_color = Color(1.0, 0.48, 0.12, 0.72) - material.roughness = 0.18 - material.emission_enabled = true - material.emission = Color(1.0, 0.28, 0.045) - material.emission_energy_multiplier = 2.6 - return material diff --git a/game/presentation/cockpit/cockpit_mesh_factory.gd b/game/presentation/cockpit/cockpit_mesh_factory.gd index 0e204e1..7b4ebc1 100644 --- a/game/presentation/cockpit/cockpit_mesh_factory.gd +++ b/game/presentation/cockpit/cockpit_mesh_factory.gd @@ -87,3 +87,27 @@ static func add_sphere( instance.position = position parent.add_child(instance) return instance + + +## Gives an imported mesh instance its own copy of its first surface material, +## so driving a property (emission) never edits the resource shared by the model. +static func own_surface_material(instance: MeshInstance3D) -> BaseMaterial3D: + if instance == null or instance.mesh == null or instance.mesh.get_surface_count() == 0: + return null + var shared := instance.mesh.surface_get_material(0) as BaseMaterial3D + if shared == null: + return null + var owned := shared.duplicate() as BaseMaterial3D + instance.set_surface_override_material(0, owned) + return owned + + +## Stops every mesh under `root` (root included) from casting shadows. +static func disable_shadow_casting(root: Node) -> void: + if root == null: + return + var meshes: Array[Node] = root.find_children("*", "MeshInstance3D", true, false) + meshes.append(root) + for node: Node in meshes: + if node is MeshInstance3D: + (node as MeshInstance3D).cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF diff --git a/game/presentation/cockpit/cockpit_practical_lighting.gd b/game/presentation/cockpit/cockpit_practical_lighting.gd index 77814cd..3d2d5d8 100644 --- a/game/presentation/cockpit/cockpit_practical_lighting.gd +++ b/game/presentation/cockpit/cockpit_practical_lighting.gd @@ -9,6 +9,28 @@ const CockpitMeshFactory = preload( "res://game/presentation/cockpit/cockpit_mesh_factory.gd" ) +const CAGE_LAMP_SCENE = preload("res://game/presentation/cockpit/assets/models/cage_lamp.glb") + +## Flange centre of the right-hand lamp, on the flat end cap of the pressure +## hull beside the porthole (the left lamp mirrors x). The cap is flat there, so +## the lamp is seated square to it, facing the cabin. +const LAMP_MOUNT_OFFSET := Vector3(0.636, 1.106, -1.3) +## Centre of the glass globe in the lamp model: where the light is emitted. +const LAMP_GLOBE_CENTRE := Vector3(0.0, 0.0, 0.064) +## Globe emission (1.5 as exported): lower, so the globe does not bloom into a blob. +const LAMP_GLASS_ENERGY := 1.0 +## Tungsten-like caged lamps: the cabin's main light. +const WARM_LAMP_COLOR := Color(1.0, 0.66, 0.4) +const WARM_LAMP_ENERGY := 0.5 +## Same dimming ratio as before when the red emergency light takes over. +const WARM_LAMP_CRITICAL_ENERGY := 0.23 +## Short range and a steep falloff: each lamp makes a pool on the wall around +## it and leaves the lower and outer hull dark. +const WARM_LAMP_RANGE_M := 1.6 +const WARM_LAMP_ATTENUATION := 2.0 +## Cabin parts are centimetre-scale: the default bias detaches their shadows. +const WARM_LAMP_SHADOW_BIAS := 0.03 + var _materials: CockpitMaterialPalette var _warm_lights: Array[OmniLight3D] = [] var _emergency_light: OmniLight3D @@ -17,8 +39,8 @@ var _emergency_light: OmniLight3D func _init(materials: CockpitMaterialPalette) -> void: _materials = materials name = "PracticalLighting" - _add_caged_lamp(-0.76, -8.0) - _add_caged_lamp(0.76, 8.0) + _add_caged_lamp(-1.0) + _add_caged_lamp(1.0) _add_fill_light() _add_emergency_light() @@ -26,75 +48,69 @@ func _init(materials: CockpitMaterialPalette) -> void: func present(alert_level: DiveAlertLevel.Value) -> void: _emergency_light.visible = alert_level >= DiveAlertLevel.Value.CRITICAL for light: OmniLight3D in _warm_lights: - light.light_energy = 0.22 if alert_level >= DiveAlertLevel.Value.CRITICAL else 0.48 + light.light_energy = ( + WARM_LAMP_CRITICAL_ENERGY + if alert_level >= DiveAlertLevel.Value.CRITICAL + else WARM_LAMP_ENERGY + ) func practical_light_count() -> int: return _warm_lights.size() -func _add_caged_lamp(x: float, roll_degrees: float) -> void: +func _add_caged_lamp(side: float) -> void: var root := Node3D.new() root.name = "CagedLamp" - root.position = Vector3(x, 0.88, -1.16) - root.rotation_degrees.z = roll_degrees + root.position = Vector3( + side * LAMP_MOUNT_OFFSET.x, + LAMP_MOUNT_OFFSET.y, + LAMP_MOUNT_OFFSET.z + ) add_child(root) - CockpitMeshFactory.add_box( - "LampHousing", - root, - Vector3(0.0, 0.0, -0.025), - Vector3(0.14, 0.11, 0.055), - _materials.dark_steel + var model := CAGE_LAMP_SCENE.instantiate() as Node3D + model.name = "CageLampModel" + root.add_child(model) + # The light sits inside the cage, 3 cm from its bars: casting, the lamp's + # own body would swallow it. + CockpitMeshFactory.disable_shadow_casting(model) + var glass := CockpitMeshFactory.own_surface_material( + model.find_child("CageLamp_Glass", true, false) as MeshInstance3D ) - CockpitMeshFactory.add_sphere( - "WarmBulb", - root, - Vector3(0.0, 0.0, 0.04), - 0.038, - _materials.warm_glass - ) - for x_offset: float in [-0.048, -0.024, 0.0, 0.024, 0.048]: - CockpitMeshFactory.add_box( - "LampGuard", - root, - Vector3(x_offset, 0.0, 0.095), - Vector3(0.007, 0.10, 0.007), - _materials.brushed_steel - ) - for y_offset: float in [-0.04, 0.04]: - CockpitMeshFactory.add_box( - "LampGuardRail", - root, - Vector3(0.0, y_offset, 0.095), - Vector3(0.115, 0.007, 0.007), - _materials.brushed_steel - ) + if glass != null: + glass.emission_energy_multiplier = LAMP_GLASS_ENERGY var light := OmniLight3D.new() light.name = "WarmPracticalLight" - light.position = Vector3(0.0, -0.03, 0.16) - light.light_color = Color(1.0, 0.55, 0.26) - light.light_energy = 0.48 - light.omni_range = 1.75 - light.shadow_enabled = false + light.position = LAMP_GLOBE_CENTRE + light.light_color = WARM_LAMP_COLOR + light.light_energy = WARM_LAMP_ENERGY + light.omni_range = WARM_LAMP_RANGE_M + light.omni_attenuation = WARM_LAMP_ATTENUATION + light.shadow_enabled = true + light.shadow_bias = WARM_LAMP_SHADOW_BIAS root.add_child(light) _warm_lights.append(light) func _add_fill_light() -> void: - 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.36 - cool_fill.omni_range = 3.0 - cool_fill.shadow_enabled = false - add_child(cool_fill) + # The only cool light in the cabin: sea light entering through the porthole, + # placed just inside the glass so it rims the ring, its bolts and the first + # band of wall, and reaches the dashboard only from behind. + var porthole_fill := OmniLight3D.new() + porthole_fill.name = "PortholeFill" + porthole_fill.position = Vector3(0.0, 0.47, -1.2) + porthole_fill.light_color = Color(0.30, 0.56, 0.78) + porthole_fill.light_energy = 1.0 + porthole_fill.omni_range = 1.1 + porthole_fill.shadow_enabled = false + add_child(porthole_fill) func _add_emergency_light() -> void: _emergency_light = OmniLight3D.new() _emergency_light.name = "EmergencyLight" - _emergency_light.position = Vector3(-0.58, 0.44, -0.83) + # High and central, so no single dial sits right under it and washes out. + _emergency_light.position = Vector3(0.0, 1.1, -0.95) _emergency_light.light_color = Color(1.0, 0.04, 0.015) _emergency_light.light_energy = 1.4 _emergency_light.omni_range = 2.4 diff --git a/game/presentation/cockpit/cockpit_pressure_bulkhead.gd b/game/presentation/cockpit/cockpit_pressure_bulkhead.gd index a712b7d..d9d3479 100644 --- a/game/presentation/cockpit/cockpit_pressure_bulkhead.gd +++ b/game/presentation/cockpit/cockpit_pressure_bulkhead.gd @@ -1,192 +1,57 @@ 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" -) +## Imported pressure hull (end cap, knuckle and barrel in one shell) and the +## porthole bolted into it. Both models are instanced by `cockpit_interior.tscn`. -const PANEL_COUNT := 1 -const FASTENER_COUNT := 16 -const ANGULAR_STEPS := 64 -const RADIAL_STEPS := 4 -const INNER_RADIUS := 0.72 -const OUTER_RADIUS := 1.11 +const WHITEBOX_NAMES: Array[String] = [ + "LeftBulkhead", + "RightBulkhead", + "TopBulkhead", + "BottomBulkhead", +] -var _materials: CockpitMaterialPalette -var _panel_count := 0 +var _hull_shells: Array[Node] = [] +var _porthole_frame: MeshInstance3D +var _porthole_glass: MeshInstance3D func _ready() -> void: - _materials = CockpitMaterialPalette.new() - _hide_rectangular_whitebox() - _style_existing_frame() - _build_curved_panels() - _build_outer_rib() - _build_fasteners() - _build_clamp_plates() + var hull := get_node_or_null("HullWall") + if hull != null: + _hull_shells = hull.find_children("*", "MeshInstance3D", true, false) + _porthole_frame = _find_mesh("Porthole") + _porthole_glass = _find_mesh("PortholeGlass") + if _hull_shells.is_empty() or _porthole_frame == null or _porthole_glass == null: + push_error("Pressure hull or porthole model is missing a node") + return + _porthole_glass.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF func panel_count() -> int: - return _panel_count + return _hull_shells.size() + + +func hull_shell() -> MeshInstance3D: + return null if _hull_shells.is_empty() else _hull_shells[0] as MeshInstance3D + + +func porthole_frame() -> MeshInstance3D: + return _porthole_frame + + +func porthole_glass() -> MeshInstance3D: + return _porthole_glass func rectangular_whitebox_is_hidden() -> bool: - for node_name: String in ["LeftBulkhead", "RightBulkhead", "TopBulkhead", "BottomBulkhead"]: + for node_name: String in WHITEBOX_NAMES: 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 _style_existing_frame() -> void: - var frame := get_parent().get_node_or_null("WindowFrame") as MeshInstance3D - if frame != null: - frame.material_override = _materials.brushed_steel - for child: Node in get_parent().get_children(): - if child is MeshInstance3D and child.name.begins_with("Bolt"): - (child as MeshInstance3D).material_override = _materials.brass - - -func _build_curved_panels() -> void: - var panel_angle := TAU / float(PANEL_COUNT) - for panel_index in PANEL_COUNT: - var start_angle := panel_index * panel_angle - var end_angle := (panel_index + 1) * panel_angle - 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) - var inner_rib := MeshInstance3D.new() - inner_rib.name = "InnerPressureRib" - var inner_torus := TorusMesh.new() - inner_torus.inner_radius = 0.695 - inner_torus.outer_radius = 0.755 - inner_torus.rings = 64 - inner_torus.ring_segments = 12 - inner_rib.mesh = inner_torus - inner_rib.material_override = _materials.brushed_steel - inner_rib.rotation_degrees.x = 90.0 - inner_rib.position.z = 0.075 - add_child(inner_rib) - - -func _build_fasteners() -> void: - for fastener_index in FASTENER_COUNT: - var angle := (fastener_index + 0.5) * TAU / float(FASTENER_COUNT) - var fastener_position := Vector3( - cos(angle) * 0.96, - sin(angle) * 0.96, - 0.12 - ) - CockpitMeshFactory.add_cylinder( - "OuterFastener", - self, - fastener_position, - 0.020, - 0.018, - _materials.brass, - Vector3(90.0, 0.0, 0.0) - ) - CockpitMeshFactory.add_sphere( - "FastenerCap", - self, - fastener_position + Vector3(0.0, 0.0, 0.012), - 0.017, - _materials.brushed_steel - ) - - -func _build_clamp_plates() -> void: - for plate_index in 12: - var angle := (plate_index + 0.5) * TAU / 12.0 - var plate := CockpitMeshFactory.add_box( - "PressureClamp", - self, - Vector3(cos(angle) * 0.88, sin(angle) * 0.88, 0.105), - Vector3(0.11, 0.055, 0.028), - _materials.brushed_steel - ) - plate.rotation_degrees.z = rad_to_deg(angle) + 90.0 +func _find_mesh(mesh_name: String) -> MeshInstance3D: + var matches := find_children(mesh_name, "MeshInstance3D", true, false) + return null if matches.is_empty() else matches[0] as MeshInstance3D diff --git a/game/presentation/cockpit/cockpit_side_equipment.gd b/game/presentation/cockpit/cockpit_side_equipment.gd index 90b9bf7..e344de2 100644 --- a/game/presentation/cockpit/cockpit_side_equipment.gd +++ b/game/presentation/cockpit/cockpit_side_equipment.gd @@ -9,7 +9,6 @@ const CockpitMeshFactory = preload( ) var _materials: CockpitMaterialPalette -var _tank_count := 0 var _junction_box_count := 0 var _control_count := 0 @@ -17,14 +16,7 @@ var _control_count := 0 func _init(materials: CockpitMaterialPalette) -> void: _materials = materials name = "SideEquipment" - _build_left_life_support_bank() _build_right_electrical_bank() - _build_cable_looms() - _build_control_rails() - - -func tank_count() -> int: - return _tank_count func junction_box_count() -> int: @@ -35,87 +27,11 @@ func control_count() -> int: return _control_count -func _build_left_life_support_bank() -> void: - var root := Node3D.new() - root.name = "LifeSupportBank" - add_child(root) - for tank_data: Vector3 in [ - Vector3(-0.78, 0.55, -1.17), - Vector3(-0.68, 0.47, -1.19), - ]: - _add_tank(root, tank_data) - - _add_service_box(root, Vector3(-0.61, 0.42, -1.14), "O₂", -10.0) - _add_indicator_column(root, Vector3(-0.55, 0.39, -1.055)) - _add_valve_wheel(root, Vector3(-0.66, 0.12, -1.08)) - - CockpitMeshFactory.add_cylinder_between( - "ManifoldPipe", - root, - Vector3(-0.78, 0.24, -1.13), - Vector3(-0.67, 0.09, -1.11), - 0.018, - _materials.copper - ) - CockpitMeshFactory.add_cylinder_between( - "ManifoldReturn", - root, - Vector3(-0.68, 0.25, -1.15), - Vector3(-0.59, 0.02, -1.10), - 0.014, - _materials.brass - ) - - -func _add_tank(root: Node3D, tank_position: Vector3) -> void: - CockpitMeshFactory.add_cylinder( - "OxygenTank", - root, - tank_position, - 0.055, - 0.43, - _materials.safety_yellow - ) - CockpitMeshFactory.add_sphere( - "TankTop", - root, - tank_position + Vector3(0.0, 0.215, 0.0), - 0.055, - _materials.safety_yellow - ) - CockpitMeshFactory.add_sphere( - "TankBottom", - root, - tank_position - Vector3(0.0, 0.215, 0.0), - 0.055, - _materials.safety_yellow - ) - for offset_y: float in [-0.13, 0.13]: - var strap := MeshInstance3D.new() - strap.name = "TankStrap" - var torus := TorusMesh.new() - torus.inner_radius = 0.054 - torus.outer_radius = 0.066 - torus.rings = 20 - torus.ring_segments = 6 - strap.mesh = torus - strap.material_override = _materials.dark_steel - strap.position = tank_position + Vector3(0.0, offset_y, 0.0) - root.add_child(strap) - CockpitMeshFactory.add_cylinder( - "TankValve", - root, - tank_position + Vector3(0.0, 0.29, 0.0), - 0.017, - 0.075, - _materials.brass - ) - _tank_count += 1 - - func _build_right_electrical_bank() -> void: var root := Node3D.new() root.name = "ElectricalBank" + # Seated on the flat end cap of the pressure hull, behind the sonar. + root.position = Vector3(0.14, -0.18, -0.055) add_child(root) _add_service_box(root, Vector3(0.72, 0.65, -1.17), "ELEC", 11.0) _add_service_box(root, Vector3(0.73, 0.36, -1.16), "POMPES", 8.0) @@ -123,17 +39,6 @@ func _build_right_electrical_bank() -> void: _add_vent(root, Vector3(0.715, 0.40, -1.085)) _add_knob(root, Vector3(0.66, 0.55, -1.08)) _add_knob(root, Vector3(0.68, 0.25, -1.07)) - _add_indicator_column(root, Vector3(0.795, 0.49, -1.075)) - - for pipe_x: float in [0.64, 0.82]: - CockpitMeshFactory.add_cylinder( - "ElectricalConduit", - root, - Vector3(pipe_x, 0.70, -1.20), - 0.017, - 0.58, - _materials.copper - ) func _add_service_box( @@ -198,123 +103,3 @@ func _add_knob(root: Node3D, knob_position: Vector3) -> void: Vector3(90.0, 0.0, 0.0) ) _control_count += 1 - - -func _add_indicator_column(root: Node3D, center: Vector3) -> void: - var indicator_materials: Array[StandardMaterial3D] = [ - _materials.indicator_green, - _materials.indicator_amber, - _materials.indicator_red, - ] - for index in 3: - CockpitMeshFactory.add_box( - "PilotLightBezel", - root, - center + Vector3(0.0, (1 - index) * 0.055, -0.004), - Vector3(0.038, 0.038, 0.018), - _materials.dark_steel - ) - CockpitMeshFactory.add_sphere( - "PilotLight", - root, - center + Vector3(0.0, (1 - index) * 0.055, 0.010), - 0.013, - indicator_materials[index] - ) - _control_count += 1 - - -func _add_valve_wheel(root: Node3D, wheel_position: Vector3) -> void: - var wheel := MeshInstance3D.new() - wheel.name = "ValveWheel" - var torus := TorusMesh.new() - torus.inner_radius = 0.034 - torus.outer_radius = 0.046 - torus.rings = 20 - torus.ring_segments = 7 - wheel.mesh = torus - wheel.material_override = _materials.signal_red - wheel.position = wheel_position - wheel.rotation_degrees.x = 90.0 - root.add_child(wheel) - for angle_degrees: float in [0.0, 45.0, 90.0, 135.0]: - CockpitMeshFactory.add_box( - "ValveSpoke", - root, - wheel_position + Vector3(0.0, 0.0, 0.002), - Vector3(0.075, 0.007, 0.008), - _materials.signal_red, - Vector3(0.0, 0.0, angle_degrees) - ) - _control_count += 1 - - -func _build_cable_looms() -> void: - var root := Node3D.new() - root.name = "CableLooms" - add_child(root) - var left_points: Array[Vector3] = [ - Vector3(-0.82, 0.93, -1.15), - Vector3(-0.72, 0.87, -1.16), - Vector3(-0.64, 0.73, -1.14), - Vector3(-0.61, 0.58, -1.12), - ] - var right_points: Array[Vector3] = [ - Vector3(0.84, 0.91, -1.15), - Vector3(0.75, 0.86, -1.16), - Vector3(0.68, 0.73, -1.14), - Vector3(0.65, 0.59, -1.12), - ] - _add_cable_path(root, left_points) - _add_cable_path(root, right_points) - - -func _add_cable_path(root: Node3D, points: Array[Vector3]) -> void: - for point_index in points.size() - 1: - CockpitMeshFactory.add_cylinder_between( - "ArmouredCable", - root, - points[point_index], - points[point_index + 1], - 0.012, - _materials.rubber - ) - - -func _build_control_rails() -> void: - var root := Node3D.new() - root.name = "ControlRails" - add_child(root) - CockpitMeshFactory.add_cylinder_between( - "LowerSteelRail", - root, - Vector3(-0.82, -0.34, -1.12), - Vector3(0.82, -0.34, -1.12), - 0.026, - _materials.brushed_steel - ) - CockpitMeshFactory.add_cylinder_between( - "LeftGrip", - root, - Vector3(-0.58, -0.34, -1.11), - Vector3(-0.18, -0.34, -1.11), - 0.034, - _materials.rubber - ) - CockpitMeshFactory.add_cylinder_between( - "RightGrip", - root, - Vector3(0.18, -0.34, -1.11), - Vector3(0.58, -0.34, -1.11), - 0.034, - _materials.rubber - ) - for x: float in [-0.82, 0.82]: - CockpitMeshFactory.add_cylinder_between( - "RailSupport", - root, - Vector3(x, -0.34, -1.12), - Vector3(x, -0.20, -1.08), - 0.022, - _materials.brushed_steel - ) diff --git a/game/presentation/cockpit/cockpit_sonar_console.gd b/game/presentation/cockpit/cockpit_sonar_console.gd index aded011..5e548cb 100644 --- a/game/presentation/cockpit/cockpit_sonar_console.gd +++ b/game/presentation/cockpit/cockpit_sonar_console.gd @@ -1,145 +1,36 @@ class_name CockpitSonarConsole extends Node3D -const CockpitMaterialPalette = preload( - "res://game/presentation/cockpit/cockpit_material_palette.gd" -) -const CockpitMeshFactory = preload( - "res://game/presentation/cockpit/cockpit_mesh_factory.gd" -) +## Imported sonar monitor. Exposes its glass as the cockpit's `%SonarScreen` so +## the instrument panel can put the shared sonar texture on it. -var _materials: CockpitMaterialPalette -var _control_count := 0 +## Lamps and knobs are modelled into the housing mesh, not separate nodes: +## three amber push-buttons and two knurled knobs (model contract). +const HOUSING_CONTROL_COUNT := 5 + +var _housing: MeshInstance3D +var _screen: MeshInstance3D -func _init(materials: CockpitMaterialPalette) -> void: - _materials = materials - name = "SonarConsoleDetailing" - _build_bezel() - _build_grid() - _build_control_strip() - _build_mounting_hardware() +func _ready() -> void: + _housing = find_child("Sonar", true, false) as MeshInstance3D + _screen = find_child("SonarScreen", true, false) as MeshInstance3D + if _housing == null or _screen == null: + push_error("Sonar model is missing its Sonar or SonarScreen node") + return + # The glass belongs to the imported scene; adopt it so the cockpit scene + # can address it by unique name like any other instrument. + _screen.owner = owner + _screen.unique_name_in_owner = true + + +func housing() -> MeshInstance3D: + return _housing + + +func screen() -> MeshInstance3D: + return _screen func control_count() -> int: - return _control_count - - -func _build_bezel() -> void: - CockpitMeshFactory.add_box( - "UpperBezel", - self, - Vector3(-0.025, 0.151, 0.073), - Vector3(0.41, 0.034, 0.035), - _materials.brushed_steel - ) - CockpitMeshFactory.add_box( - "LowerBezel", - self, - Vector3(-0.025, -0.126, 0.073), - Vector3(0.41, 0.034, 0.035), - _materials.brushed_steel - ) - for x: float in [-0.225, 0.175]: - CockpitMeshFactory.add_box( - "SideBezel", - self, - Vector3(x, 0.012, 0.073), - Vector3(0.035, 0.31, 0.035), - _materials.brushed_steel - ) - var label := Label3D.new() - label.name = "SonarPlate" - label.text = "SONAR · BATHYMÉTRIE" - label.font_size = 12 - label.pixel_size = 0.00062 - label.modulate = Color(0.7, 0.73, 0.64) - label.position = Vector3(-0.04, 0.156, 0.097) - add_child(label) - - -func _build_control_strip() -> void: - CockpitMeshFactory.add_box( - "ControlStrip", - self, - Vector3(0.235, 0.012, 0.072), - Vector3(0.075, 0.31, 0.04), - _materials.dark_steel - ) - var lamps: Array[StandardMaterial3D] = [ - _materials.indicator_amber, - _materials.indicator_amber, - _materials.indicator_green, - ] - for index in 3: - var y := 0.095 - index * 0.073 - CockpitMeshFactory.add_box( - "SonarLampBezel", - self, - Vector3(0.235, y, 0.101), - Vector3(0.044, 0.044, 0.018), - _materials.brass - ) - CockpitMeshFactory.add_sphere( - "SonarLamp", - self, - Vector3(0.235, y, 0.116), - 0.013, - lamps[index] - ) - _control_count += 1 - CockpitMeshFactory.add_cylinder( - "SonarGainKnob", - self, - Vector3(0.235, -0.102, 0.108), - 0.024, - 0.026, - _materials.rubber, - Vector3(90.0, 0.0, 0.0) - ) - _control_count += 1 - - -func _build_grid() -> void: - for x: float in [-0.145, -0.085, -0.025, 0.035, 0.095]: - CockpitMeshFactory.add_box( - "SonarGridVertical", - self, - Vector3(x, 0.012, 0.076), - Vector3(0.0015, 0.215, 0.002), - _materials.sonar_grid - ) - for y: float in [-0.075, -0.025, 0.025, 0.075]: - CockpitMeshFactory.add_box( - "SonarGridHorizontal", - self, - Vector3(-0.025, y, 0.076), - Vector3(0.33, 0.0015, 0.002), - _materials.sonar_grid - ) - - -func _build_mounting_hardware() -> void: - for corner: Vector2 in [ - Vector2(-0.205, -0.105), - Vector2(-0.205, 0.13), - Vector2(0.195, -0.105), - Vector2(0.195, 0.13), - ]: - CockpitMeshFactory.add_cylinder( - "SonarFastener", - self, - Vector3(corner.x, corner.y, 0.103), - 0.008, - 0.012, - _materials.brass, - Vector3(90.0, 0.0, 0.0) - ) - CockpitMeshFactory.add_cylinder_between( - "LeftSonarHandle", - self, - Vector3(-0.255, -0.09, 0.06), - Vector3(-0.255, 0.11, 0.06), - 0.013, - _materials.brass - ) + return 0 if _housing == null else HOUSING_CONTROL_COUNT diff --git a/game/presentation/cockpit/cockpit_switch_panel.gd b/game/presentation/cockpit/cockpit_switch_panel.gd new file mode 100644 index 0000000..cb0dc85 --- /dev/null +++ b/game/presentation/cockpit/cockpit_switch_panel.gd @@ -0,0 +1,26 @@ +class_name CockpitSwitchPanel +extends Node3D + +## Imported switch panel. Decorative in this slice: every control stays in its +## exported rest pose and no input reaches it. + +const CONTROL_PREFIXES: Array[String] = ["Toggle_", "Rocker_", "Cover_", "Lever_"] + +var _controls: Array[Node3D] = [] + + +func _ready() -> void: + for prefix: String in CONTROL_PREFIXES: + for control: Node in find_children("%s*" % prefix, "Node3D", true, false): + _controls.append(control as Node3D) + + +func control_count() -> int: + return _controls.size() + + +func controls_are_at_rest() -> bool: + for control: Node3D in _controls: + if not control.rotation.is_zero_approx(): + return false + return true diff --git a/game/presentation/sonar/sonar_display.gd b/game/presentation/sonar/sonar_display.gd index fdec735..46ec08f 100644 --- a/game/presentation/sonar/sonar_display.gd +++ b/game/presentation/sonar/sonar_display.gd @@ -61,6 +61,10 @@ func built_grid_segment_count() -> int: return _renderer.built_grid_segment_count() +func depth_scale_texts() -> PackedStringArray: + return _renderer.depth_scale_texts() + + func idle_prompt_visible() -> bool: return _renderer.idle_prompt_visible() diff --git a/game/presentation/sonar/sonar_display.tscn b/game/presentation/sonar/sonar_display.tscn index 162032b..9efef51 100644 --- a/game/presentation/sonar/sonar_display.tscn +++ b/game/presentation/sonar/sonar_display.tscn @@ -27,7 +27,7 @@ script = ExtResource("1_display") [node name="SonarReliefRenderer" type="SubViewport" parent="."] unique_name_in_owner = true -size = Vector2i(960, 540) +size = Vector2i(960, 604) render_target_update_mode = 4 script = ExtResource("2_renderer") @@ -77,4 +77,4 @@ text = "SONAR PRÊT · F POUR ÉMETTRE" [node name="Legend" type="Label" parent="ExpandedPanel/Layout"] layout_mode = 2 theme_override_colors/font_color = Color(0.58, 0.7, 0.71, 1) -text = "BLEU PROFOND · VERT/JAUNE HAUTEUR · ORANGE ANOMALIE · NOIR INCONNU · TAB FERMER" +text = "TURQUOISE SOMBRE PROFOND · CYAN CLAIR HAUTEUR · ORANGE ANOMALIE · NOIR INCONNU · TAB FERMER" diff --git a/game/presentation/sonar/sonar_relief_mesh_builder.gd b/game/presentation/sonar/sonar_relief_mesh_builder.gd index 1ca823a..f40053a 100644 --- a/game/presentation/sonar/sonar_relief_mesh_builder.gd +++ b/game/presentation/sonar/sonar_relief_mesh_builder.gd @@ -5,53 +5,59 @@ const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd") const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") -const COLOR_DEEP := Color("173f91") -const COLOR_LOW := Color("168fc1") -const COLOR_MIDDLE := Color("36b979") -const COLOR_HIGH := Color("d7d93d") -const COLOR_PEAK := Color("ee5734") -const COLOR_METAL := Color("ff6b22") +## Grid lines are drawn by the relief shader on the cell edges: every built +## cell carries one row edge and one column edge of the grid. +const GRID_SEGMENTS_PER_CELL := 2 +## Two triangles per cell, wound like the sample grid. +const CELL_CORNER_ORDER: Array[int] = [0, 2, 1, 1, 2, 3] -static func build_full_mesh( - scan: SonarScan, - material: StandardMaterial3D -) -> ArrayMesh: +static func build_full_mesh(scan: SonarScan, material: Material) -> ArrayMesh: var mesh := ArrayMesh.new() for row in range(1, scan.samples.rows): add_row_band(mesh, scan, row, material) return mesh +## Adds the cells between `row - 1` and `row`; returns the triangle count. +## Per vertex: UV = (column, row) of the sample grid, COLOR.r = height relative +## to the scan (0 low, 1 peak), COLOR.g = 1 on metal, COLOR.b = echo intensity. static func add_row_band( mesh: ArrayMesh, scan: SonarScan, row: int, - material: StandardMaterial3D + material: Material ) -> int: var vertices := PackedVector3Array() var normals := PackedVector3Array() var colors := PackedColorArray() + var uvs := PackedVector2Array() for column in range(scan.samples.columns - 1): - var top_left: SonarSample = scan.samples.sample_at(row - 1, column) - var top_right: SonarSample = scan.samples.sample_at(row - 1, column + 1) - var bottom_left: SonarSample = scan.samples.sample_at(row, column) - var bottom_right: SonarSample = scan.samples.sample_at(row, column + 1) + var corners: Array[SonarSample] = [ + scan.samples.sample_at(row - 1, column), + scan.samples.sample_at(row - 1, column + 1), + scan.samples.sample_at(row, column), + scan.samples.sample_at(row, column + 1), + ] if not ( - top_left.observed - and top_right.observed - and bottom_left.observed - and bottom_right.observed + corners[0].observed + and corners[1].observed + and corners[2].observed + and corners[3].observed ): continue - _append_triangle( - vertices, normals, colors, scan, - top_left, bottom_left, top_right - ) - _append_triangle( - vertices, normals, colors, scan, - top_right, bottom_left, bottom_right - ) + var grid: Array[Vector2] = [ + Vector2(column, row - 1), + Vector2(column + 1, row - 1), + Vector2(column, row), + Vector2(column + 1, row), + ] + for corner: int in CELL_CORNER_ORDER: + var sample := corners[corner] + vertices.append(sample.position_m) + normals.append(sample.normal) + colors.append(_sample_data(scan, sample)) + uvs.append(grid[corner]) if vertices.is_empty(): return 0 var arrays: Array = [] @@ -59,93 +65,21 @@ static func add_row_band( arrays[Mesh.ARRAY_VERTEX] = vertices arrays[Mesh.ARRAY_NORMAL] = normals arrays[Mesh.ARRAY_COLOR] = colors + arrays[Mesh.ARRAY_TEX_UV] = uvs mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) mesh.surface_set_material(mesh.get_surface_count() - 1, material) return vertices.size() / 3 -static func add_grid_band( - mesh: ArrayMesh, - scan: SonarScan, - row: int, - material: StandardMaterial3D -) -> int: - var vertices := PackedVector3Array() - for column in scan.samples.columns: - if column % 6 != 0 and column != scan.samples.columns - 1: - continue - _append_grid_segment( - vertices, - scan.samples.sample_at(row - 1, column), - scan.samples.sample_at(row, column) - ) - if row == 1 or row % 4 == 0 or row == scan.samples.rows - 1: - for column in range(scan.samples.columns - 1): - _append_grid_segment( - vertices, - scan.samples.sample_at(row, column), - scan.samples.sample_at(row, column + 1) - ) - if vertices.is_empty(): - return 0 - var arrays: Array = [] - arrays.resize(Mesh.ARRAY_MAX) - arrays[Mesh.ARRAY_VERTEX] = vertices - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_LINES, arrays) - mesh.surface_set_material(mesh.get_surface_count() - 1, material) - return vertices.size() / 2 - - -static func _append_grid_segment( - vertices: PackedVector3Array, - a: SonarSample, - b: SonarSample -) -> void: - if not a.observed or not b.observed: - return - vertices.append(a.position_m + a.normal * 0.08) - vertices.append(b.position_m + b.normal * 0.08) - - -static func _append_triangle( - vertices: PackedVector3Array, - normals: PackedVector3Array, - colors: PackedColorArray, - scan: SonarScan, - a: SonarSample, - b: SonarSample, - c: SonarSample -) -> void: - for sample: SonarSample in [a, b, c]: - vertices.append(sample.position_m) - normals.append(sample.normal) - colors.append(_sample_color(scan, sample)) - - -static func _sample_color(scan: SonarScan, sample: SonarSample) -> Color: - if sample.material == SonarMaterial.Value.METAL: - return COLOR_METAL - var height := inverse_lerp( - scan.minimum_elevation_m, - scan.maximum_elevation_m, - sample.elevation_m - ) - var color: Color - if height <= 0.28: - color = COLOR_DEEP.lerp(COLOR_LOW, height / 0.28) - elif height <= 0.52: - color = COLOR_LOW.lerp(COLOR_MIDDLE, (height - 0.28) / 0.24) - elif height <= 0.76: - color = COLOR_MIDDLE.lerp(COLOR_HIGH, (height - 0.52) / 0.24) - else: - color = COLOR_HIGH.lerp(COLOR_PEAK, (height - 0.76) / 0.24) - var light_direction := Vector3(-0.35, 0.82, -0.45).normalized() - var slope_light := clampf(sample.normal.dot(light_direction), 0.0, 1.0) - var shade := lerpf(0.68, 1.0, slope_light) - var lit_color := color.lerp(Color.WHITE, sample.intensity * 0.08) - return Color( - lit_color.r * shade, - lit_color.g * shade, - lit_color.b * shade, - lit_color.a +static func _sample_data(scan: SonarScan, sample: SonarSample) -> Color: + var height := clampf( + inverse_lerp( + scan.minimum_elevation_m, + scan.maximum_elevation_m, + sample.elevation_m + ), + 0.0, + 1.0 ) + var metal := 1.0 if sample.material == SonarMaterial.Value.METAL else 0.0 + return Color(height, metal, clampf(sample.intensity, 0.0, 1.0)) diff --git a/game/presentation/sonar/sonar_relief_renderer.gd b/game/presentation/sonar/sonar_relief_renderer.gd index 8dc4f10..ed40d37 100644 --- a/game/presentation/sonar/sonar_relief_renderer.gd +++ b/game/presentation/sonar/sonar_relief_renderer.gd @@ -5,15 +5,32 @@ 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 _current_grid_instance: MeshInstance3D var _previous_mesh_instance: MeshInstance3D var _contact_root: Node3D var _idle_prompt: Label -var _current_material: StandardMaterial3D -var _grid_material: StandardMaterial3D -var _previous_material: StandardMaterial3D +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 @@ -22,9 +39,8 @@ var _built_current_grid_segment_count := 0 func _ready() -> void: - size = Vector2i(960, 540) + size = RENDER_SIZE render_target_update_mode = SubViewport.UPDATE_ALWAYS - positional_shadow_atlas_size = 1024 own_world_3d = true _build_render_world() @@ -41,11 +57,11 @@ func present( if current_scan.scan_id != _current_scan_id: _current_scan_id = current_scan.scan_id _current_mesh_instance.mesh = ArrayMesh.new() - _current_grid_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) @@ -61,7 +77,7 @@ func present( previous_scan, _previous_material ) - _previous_material.albedo_color.a = clampf(previous_opacity, 0.0, 1.0) + SonarReliefStyle.set_opacity(_previous_material, previous_opacity) func render_texture() -> Texture2D: @@ -84,6 +100,16 @@ 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(): @@ -96,24 +122,17 @@ func _build_render_world() -> void: var environment_node := WorldEnvironment.new() var environment := Environment.new() environment.background_mode = Environment.BG_COLOR - environment.background_color = Color("020407") - environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - environment.ambient_light_color = Color(0.18, 0.31, 0.34) - environment.ambient_light_energy = 0.42 + environment.background_color = SonarReliefStyle.COLOR_BACKGROUND + environment.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED environment_node.environment = environment add_child(environment_node) - _current_material = _relief_material(false) - _grid_material = _surface_grid_material() - _previous_material = _relief_material(true) + _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) - _current_grid_instance = MeshInstance3D.new() - _current_grid_instance.name = "CurrentReliefGrid" - _current_grid_instance.mesh = ArrayMesh.new() - add_child(_current_grid_instance) _previous_mesh_instance = MeshInstance3D.new() _previous_mesh_instance.name = "PreviousRelief" _previous_mesh_instance.mesh = ArrayMesh.new() @@ -128,72 +147,88 @@ func _build_render_world() -> void: var camera := Camera3D.new() camera.name = "SonarCamera" camera.projection = Camera3D.PROJECTION_ORTHOGONAL - camera.size = 116.0 + camera.keep_aspect = Camera3D.KEEP_WIDTH + camera.size = VIEW_WIDTH_M camera.far = 240.0 - camera.position = Vector3(0.0, 65.0, -30.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(Vector3(0.0, -38.0, 58.0), Vector3.UP) + camera.look_at(CAMERA_AIM_POINT, Vector3.UP) camera.current = true - var key_light := DirectionalLight3D.new() - key_light.rotation_degrees = Vector3(-52.0, -28.0, 0.0) - key_light.light_energy = 1.25 - key_light.shadow_enabled = true - add_child(key_light) - var fill_light := DirectionalLight3D.new() - fill_light.rotation_degrees = Vector3(-28.0, 145.0, 18.0) - fill_light.light_energy = 0.38 - add_child(fill_light) + _build_depth_scale() _build_idle_prompt() func _build_idle_prompt() -> void: _idle_prompt = Label.new() _idle_prompt.name = "IdlePrompt" - _idle_prompt.position = Vector2(180.0, 238.0) - _idle_prompt.size = Vector2(600.0, 64.0) + _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", Color(0.38, 0.88, 0.86)) + _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) -func _relief_material(transparent: bool) -> StandardMaterial3D: - var material := StandardMaterial3D.new() - material.vertex_color_use_as_albedo = true - material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.cull_mode = BaseMaterial3D.CULL_DISABLED - if transparent: - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - material.albedo_color = Color(1.0, 1.0, 1.0, 0.0) - return material +## 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 _surface_grid_material() -> StandardMaterial3D: - var material := StandardMaterial3D.new() - material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - material.albedo_color = Color(0.32, 0.95, 0.91, 0.34) - material.emission_enabled = true - material.emission = Color(0.18, 0.78, 0.76) - material.emission_energy_multiplier = 1.35 - return material +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 - var grid_mesh := _current_grid_instance.mesh as ArrayMesh while _built_current_rows < visible_rows: if _built_current_rows > 0: - _built_current_triangle_count += SonarReliefMeshBuilder.add_row_band( + var triangles := SonarReliefMeshBuilder.add_row_band( mesh, scan, _built_current_rows, _current_material ) - _built_current_grid_segment_count += SonarReliefMeshBuilder.add_grid_band( - grid_mesh, scan, _built_current_rows, _grid_material + _built_current_triangle_count += triangles + _built_current_grid_segment_count += ( + triangles / 2 * SonarReliefMeshBuilder.GRID_SEGMENTS_PER_CELL ) _built_current_rows += 1 @@ -210,13 +245,9 @@ func _rebuild_contacts(scan: SonarScan) -> void: marker.position = contact.center_m + Vector3.UP * 1.4 marker.visible = false marker.set_meta("forward_distance_m", contact.center_m.z) - var material := StandardMaterial3D.new() - material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.albedo_color = SonarReliefMeshBuilder.COLOR_METAL - material.emission_enabled = true - material.emission = SonarReliefMeshBuilder.COLOR_METAL - material.emission_energy_multiplier = 4.0 - marker.material_override = material + marker.material_override = SonarReliefStyle.marker_material( + SonarReliefStyle.COLOR_METAL + ) _contact_root.add_child(marker) @@ -244,11 +275,7 @@ func _add_submarine_marker() -> void: box.size = Vector3(3.2, 1.1, 5.8) marker.mesh = box marker.position = Vector3(0.0, 0.0, 1.0) - var material := StandardMaterial3D.new() - material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.albedo_color = Color.WHITE - material.emission_enabled = true - material.emission = Color.WHITE - material.emission_energy_multiplier = 2.4 - marker.material_override = material + marker.material_override = SonarReliefStyle.marker_material( + SonarReliefStyle.COLOR_VEHICLE + ) add_child(marker) diff --git a/game/presentation/sonar/sonar_relief_style.gd b/game/presentation/sonar/sonar_relief_style.gd new file mode 100644 index 0000000..d5586ab --- /dev/null +++ b/game/presentation/sonar/sonar_relief_style.gd @@ -0,0 +1,157 @@ +class_name SonarReliefStyle +extends RefCounted + +## Single owner of the holographic look of the sonar relief: palette, grid +## spacing, line widths and intensities. Display only: nothing here is a +## gameplay value. + +## Near-black table the hologram floats on. Black also means "unknown". +const COLOR_BACKGROUND := Color("010507") +## Height ramp, relative to the scan: dark teal low, cyan mid, pale on peaks. +const COLOR_DEEP := Color("06404a") +const COLOR_MIDDLE := Color("17b8c4") +const COLOR_PEAK := Color("d8fbff") +## Metal returns keep one warm accent so an anomaly never reads as terrain. +const COLOR_METAL := Color("ff7a2a") +const COLOR_VEHICLE := Color("f2ffff") + +## Grid lines follow the sample grid: one minor line every N cells. +const GRID_MINOR_CELLS := 2.0 +const GRID_MAJOR_CELLS := 6.0 +const GRID_MINOR_WIDTH_PX := 1.2 +const GRID_MAJOR_WIDTH_PX := 2.2 +const GRID_MINOR_INTENSITY := 0.30 +const GRID_MAJOR_INTENSITY := 0.80 +## Iso-height contours, in fractions of the scan's height span. +const CONTOUR_STEP := 0.125 +const CONTOUR_WIDTH_PX := 1.0 +const CONTOUR_INTENSITY := 0.20 +## Translucent surface under the grid and the rim that outlines ridges. +const SURFACE_INTENSITY := 0.50 +const RIM_INTENSITY := 0.35 +const RIM_POWER := 2.5 +## How much brighter the peaks' lines are than the deep ones. +const PEAK_LINE_BOOST := 0.55 + +const _SHADER_CODE := """ +shader_type spatial; +render_mode unshaded, cull_disabled, depth_draw_always; + +uniform vec3 background : source_color; +uniform vec3 color_deep : source_color; +uniform vec3 color_middle : source_color; +uniform vec3 color_peak : source_color; +uniform vec3 color_metal : source_color; +uniform float grid_minor_cells; +uniform float grid_major_cells; +uniform float grid_minor_width_px; +uniform float grid_major_width_px; +uniform float grid_minor_intensity; +uniform float grid_major_intensity; +uniform float contour_step; +uniform float contour_width_px; +uniform float contour_intensity; +uniform float surface_intensity; +uniform float rim_intensity; +uniform float rim_power; +uniform float peak_line_boost; +uniform float opacity = 1.0; + +// Anti-aliased line on every multiple of `spacing`, `width_px` wide on screen. +// Fades out when lines would be closer than a few pixels, so it never shimmers. +float grid_line(float coordinate, float spacing, float width_px) { + float scaled = coordinate / spacing; + float pixel = max(fwidth(scaled), 1e-5); + float distance_px = abs(fract(scaled - 0.5) - 0.5) / pixel; + float line = 1.0 - smoothstep(width_px * 0.5 - 0.5, width_px * 0.5 + 0.5, distance_px); + return line * clamp((1.0 / pixel - 2.0) / 3.0, 0.0, 1.0); +} + +void fragment() { + // UV carries (column, row) of the sample grid; COLOR.r the relative height, + // COLOR.g the metal flag, COLOR.b the echo intensity. + float height = clamp(COLOR.r, 0.0, 1.0); + vec3 ramp = mix(color_deep, color_middle, smoothstep(0.0, 0.55, height)); + ramp = mix(ramp, color_peak, smoothstep(0.55, 1.0, height)); + // The translucent surface stays in the teal-to-cyan range so peaks do not + // wash out to grey; only the lines reach the pale peak colour. + vec3 fill = mix(color_deep, color_middle, smoothstep(0.0, 0.85, height)); + float metal = step(0.5, COLOR.g); + ramp = mix(ramp, color_metal, metal); + fill = mix(fill, color_metal, metal); + + float minor = max( + grid_line(UV.x, grid_minor_cells, grid_minor_width_px), + grid_line(UV.y, grid_minor_cells, grid_minor_width_px) + ); + float major = max( + grid_line(UV.x, grid_major_cells, grid_major_width_px), + grid_line(UV.y, grid_major_cells, grid_major_width_px) + ); + float contour = grid_line(height, contour_step, contour_width_px); + float rim = pow(1.0 - abs(dot(normalize(NORMAL), normalize(VIEW))), rim_power); + + float lines = max(minor * grid_minor_intensity, major * grid_major_intensity); + lines = max(lines, contour * contour_intensity); + lines *= 1.0 + peak_line_boost * height; + float surface = surface_intensity * (0.22 + 0.78 * height) * (0.8 + 0.2 * COLOR.b); + vec3 glow = fill * (surface + rim * rim_intensity) + ramp * lines; + + ALBEDO = background + glow; + //FADE ALPHA = opacity; +} +""" + +static var _opaque_shader: Shader +static var _fading_shader: Shader + + +## The current scan is opaque: depth-tested, so ridges hide what lies behind +## them and nothing depends on draw order. The previous scan fades out with the +## same shader, alpha-blended and still writing depth. +static func relief_material(fading: bool) -> ShaderMaterial: + if _opaque_shader == null: + _opaque_shader = Shader.new() + _opaque_shader.code = _SHADER_CODE + _fading_shader = Shader.new() + _fading_shader.code = _SHADER_CODE.replace("//FADE ", "") + var material := ShaderMaterial.new() + material.shader = _fading_shader if fading else _opaque_shader + material.set_shader_parameter("background", COLOR_BACKGROUND) + material.set_shader_parameter("color_deep", COLOR_DEEP) + material.set_shader_parameter("color_middle", COLOR_MIDDLE) + material.set_shader_parameter("color_peak", COLOR_PEAK) + material.set_shader_parameter("color_metal", COLOR_METAL) + material.set_shader_parameter("grid_minor_cells", GRID_MINOR_CELLS) + material.set_shader_parameter("grid_major_cells", GRID_MAJOR_CELLS) + material.set_shader_parameter("grid_minor_width_px", GRID_MINOR_WIDTH_PX) + material.set_shader_parameter("grid_major_width_px", GRID_MAJOR_WIDTH_PX) + material.set_shader_parameter("grid_minor_intensity", GRID_MINOR_INTENSITY) + material.set_shader_parameter("grid_major_intensity", GRID_MAJOR_INTENSITY) + material.set_shader_parameter("contour_step", CONTOUR_STEP) + material.set_shader_parameter("contour_width_px", CONTOUR_WIDTH_PX) + material.set_shader_parameter("contour_intensity", CONTOUR_INTENSITY) + material.set_shader_parameter("surface_intensity", SURFACE_INTENSITY) + material.set_shader_parameter("rim_intensity", RIM_INTENSITY) + material.set_shader_parameter("rim_power", RIM_POWER) + material.set_shader_parameter("peak_line_boost", PEAK_LINE_BOOST) + return material + + +static func set_opacity(material: ShaderMaterial, opacity: float) -> void: + material.set_shader_parameter("opacity", clampf(opacity, 0.0, 1.0)) + + +static func marker_material(color: Color) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = color + return material + + +## Colour of a relative height (0 low, 1 peak), for the depth scale legend. +static func height_gradient() -> Gradient: + var gradient := Gradient.new() + gradient.offsets = PackedFloat32Array([0.0, 0.55, 1.0]) + gradient.colors = PackedColorArray([COLOR_DEEP, COLOR_MIDDLE, COLOR_PEAK]) + return gradient diff --git a/game/world/sites/vehicle_test_environment.tscn b/game/world/sites/vehicle_test_environment.tscn index ac53448..f3bdbfb 100644 --- a/game/world/sites/vehicle_test_environment.tscn +++ b/game/world/sites/vehicle_test_environment.tscn @@ -8,6 +8,14 @@ background_color = Color(0.005, 0.045, 0.065, 1) ambient_light_source = 3 ambient_light_color = Color(0.12, 0.32, 0.37, 1) ambient_light_energy = 0.42 +glow_enabled = true +glow_levels/1 = 1.0 +glow_levels/2 = 1.0 +glow_levels/3 = 0.6 +glow_levels/4 = 0.2 +glow_intensity = 1.0 +glow_blend_mode = 0 +glow_hdr_threshold = 1.0 fog_enabled = true fog_light_color = Color(0.07, 0.25, 0.3, 1) fog_light_energy = 0.48 diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd index adbf33b..07fc0ea 100644 --- a/tests/integration/run_vertical_slice_foundation.gd +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -11,6 +11,17 @@ const CockpitDetailing = preload( const CockpitPressureBulkhead = preload( "res://game/presentation/cockpit/cockpit_pressure_bulkhead.gd" ) +const CockpitAlertBox = preload("res://game/presentation/cockpit/cockpit_alert_box.gd") +const CockpitAnalogGauges = preload( + "res://game/presentation/cockpit/cockpit_analog_gauges.gd" +) +const CockpitSonarConsole = preload( + "res://game/presentation/cockpit/cockpit_sonar_console.gd" +) +const CockpitSwitchPanel = preload( + "res://game/presentation/cockpit/cockpit_switch_panel.gd" +) +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd") const ControlsSettingsScreen = preload( "res://game/presentation/settings/controls_settings_screen.gd" @@ -19,7 +30,7 @@ const ProductionInputAdapter = preload( "res://game/infrastructure/input/production_input_adapter.gd" ) -const TEST_COUNT := 78 +const TEST_COUNT := 91 var _failures: Array[String] = [] @@ -91,7 +102,17 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: else null ) var analog_gauges := ( - instruments.get_node_or_null("Dashboard/AnalogGauges") + instruments.get_node_or_null("Dashboard/AnalogGauges") as CockpitAnalogGauges + if instruments != null + else null + ) + var alert_box := ( + instruments.get_node_or_null("AlertBox") as CockpitAlertBox + if instruments != null + else null + ) + var switch_panel := ( + instruments.get_node_or_null("SwitchPanel") as CockpitSwitchPanel if instruments != null else null ) @@ -106,6 +127,8 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: _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(alert_box != null, "alert box is assembled") + _expect(switch_panel != null, "switch panel 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") @@ -117,17 +140,19 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: or detailing == null or pressure_bulkhead == null or analog_gauges == null + or alert_box == null + or switch_panel == null or adapter == null or hud == null ): return - _expect(instruments.get_node("%WindowFrame") is MeshInstance3D, "cockpit has a physical porthole frame") + _expect(pressure_bulkhead.porthole_frame() 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") - var sonar_monitor := instruments.get_node("SonarMonitor") as Node3D + var sonar_monitor := instruments.get_node("SonarMonitor") as CockpitSonarConsole var sonar_screen := instruments.get_node("%SonarScreen") as MeshInstance3D - var sonar_housing := instruments.get_node("SonarMonitor/Housing") as MeshInstance3D + var sonar_housing := sonar_monitor.housing() var cockpit_camera := camera.get_node("Camera") as Camera3D var electrical_bank := detailing.get_node("SideEquipment/ElectricalBank") as Node3D _expect(sonar_monitor.position.x > 0.5, "cockpit sonar is mounted on the right side") @@ -147,21 +172,39 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: instruments.find_children("SonarReferenceTrace", "MeshInstance3D", true).is_empty(), "cockpit sonar does not invent a decorative profile" ) - _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( + _shell_surrounds(pressure_bulkhead.hull_shell(), cockpit_camera.global_position), + "pressure hull shell surrounds the pilot" + ) _expect(absf(detailing.battery_needle_degrees()) > 100.0, "analog battery gauge follows the dive snapshot") _expect(detailing.textures_are_loaded(), "cockpit loads its dedicated 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(detailing.side_equipment_count() >= 5, "cockpit sidewalls expose tanks and junction boxes") - _expect(detailing.side_control_count() >= 9, "cockpit sidewalls expose valves and pilot controls") + _expect(detailing.side_equipment_count() >= 3, "cockpit sidewalls expose a gas bottle and junction boxes") + _expect(detailing.side_control_count() >= 3, "cockpit sidewalls expose a valve and pilot controls") _expect(detailing.sonar_control_count() >= 4, "sonar console exposes physical lamps and gain control") _expect(detailing.practical_light_count() == 2, "cockpit uses two caged practical lights") _expect(pressure_bulkhead.panel_count() == 1, "porthole bulkhead uses one continuous curved shell") _expect(pressure_bulkhead.rectangular_whitebox_is_hidden(), "porthole rectangular whitebox is hidden") _expect(analog_gauges.gauge_count() == 4, "dashboard exposes four primary analog instruments") - _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(alert_box.indicator_count() == 3, "alert box exposes three pilot lights") + _expect(alert_box.active_indicator_count() == 1, "exactly one pilot light reflects the alert state") + _expect( + alert_box.active_indicator_name() == "Lamp_Alerte", + "normal telemetry lights the green system lamp" + ) + _expect( + analog_gauges.pilot_light_count() == 6 and analog_gauges.lit_pilot_light_count() == 0, + "gauge panel pilot lights rest dark without per-instrument data" + ) + _expect( + switch_panel.control_count() == 10 and switch_panel.controls_are_at_rest(), + "decorative switches stay in their rest pose" + ) + _expect( + _sonar_texture_matches_glass(instruments.sonar_texture(), sonar_screen), + "sonar render target has the aspect ratio of the monitor glass" + ) _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") @@ -184,6 +227,13 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: _expect(sonar_display.built_row_count() > 0, "renderer reveals near rows incrementally") _expect(sonar_display.built_triangle_count() >= 90, "sonar renders a dense relief surface, not a line") _expect(sonar_display.built_grid_segment_count() > 0, "sonar overlays a perspective grid on the relief") + var scale_texts: PackedStringArray = sonar_display.depth_scale_texts() + _expect( + scale_texts.size() >= 2 + and scale_texts[0] == "%.0f m" % first_scan.maximum_elevation_m + and scale_texts[scale_texts.size() - 1] == "%.0f m" % first_scan.minimum_elevation_m, + "sonar height scale shows the real elevation span of the scan" + ) adapter.sonar_view_toggle_requested.emit() _expect(sonar_display.is_expanded(), "sonar can expand without pausing the dive") adapter.sonar_view_toggle_requested.emit() @@ -294,6 +344,38 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: "fixed dive ticks resume after pause" ) + analog_gauges.present(0.0, 0.5, 1.0, 0.25) + _expect( + is_equal_approx(analog_gauges.needle_degrees("Needle_Oxygen"), 120.0), + "empty gauge needle rests at the lower-left end of the printed scale" + ) + _expect( + is_zero_approx(analog_gauges.needle_degrees("Needle_Battery")), + "half-scale gauge needle points straight up" + ) + _expect( + is_equal_approx(analog_gauges.needle_degrees("Needle_Hull"), -120.0), + "full gauge needle reaches the lower-right end clockwise" + ) + _expect( + is_equal_approx(analog_gauges.needle_degrees("Needle_Depth"), 60.0), + "depth needle uses the same printed sweep" + ) + _expect( + _alert_lamp_for(alert_box, DiveAlertLevel.Value.NORMAL) == "Lamp_Alerte", + "normal level lights only the system lamp" + ) + _expect( + _alert_lamp_for(alert_box, DiveAlertLevel.Value.ATTENTION) == "Lamp_Attention" + and _alert_lamp_for(alert_box, DiveAlertLevel.Value.RETURN) == "Lamp_Attention", + "attention and return levels light only the attention lamp" + ) + _expect( + _alert_lamp_for(alert_box, DiveAlertLevel.Value.CRITICAL) == "Lamp_Danger" + and _alert_lamp_for(alert_box, DiveAlertLevel.Value.FAILURE) == "Lamp_Danger", + "critical and failure levels light only the danger lamp" + ) + func _pressed_key(keycode: Key) -> InputEventKey: var event := InputEventKey.new() @@ -327,20 +409,61 @@ func _sonar_screen_is_ahead_of_bank( return true +## The imported housing is an arbitrary mesh, so the clearance is measured on +## its triangles: looking straight at the glass, nothing of the housing may sit +## level with or in front of the picture. func _sonar_screen_clears_housing( sonar_screen: MeshInstance3D, sonar_housing: MeshInstance3D ) -> bool: - if sonar_screen == null or sonar_housing == null: + if sonar_screen == null or sonar_housing == null or sonar_housing.mesh == null: return false - var housing_box := sonar_housing.mesh as BoxMesh - if housing_box == null: - return false - var housing_front_z := ( - sonar_housing.position.z - + housing_box.size.z * sonar_housing.scale.z * 0.5 + var glass := sonar_screen.mesh.get_aabb() + var screen_to_housing := ( + sonar_housing.global_transform.affine_inverse() * sonar_screen.global_transform ) - return sonar_screen.position.z >= housing_front_z + 0.005 + var outward := (screen_to_housing.basis * Vector3.BACK).normalized() + var triangles := sonar_housing.mesh.get_faces() + if triangles.is_empty(): + return false + for column in 5: + for row in 3: + var on_glass := Vector3( + lerpf(glass.position.x, glass.end.x, 0.1 + 0.2 * column), + lerpf(glass.position.y, glass.end.y, 0.1 + 0.4 * row), + glass.position.z - 0.005 + ) + var origin := screen_to_housing * on_glass + for index in range(0, triangles.size(), 3): + if Geometry3D.ray_intersects_triangle( + origin, + outward, + triangles[index], + triangles[index + 1], + triangles[index + 2] + ) != null: + return false + return true + + +func _shell_surrounds(shell: MeshInstance3D, point: Vector3) -> bool: + if shell == null or shell.mesh == null: + return false + var local := shell.global_transform.affine_inverse() * point + return shell.mesh.get_aabb().grow(-0.05).has_point(local) + + +func _sonar_texture_matches_glass(texture: Texture2D, sonar_screen: MeshInstance3D) -> bool: + if texture == null or sonar_screen == null: + return false + var glass := sonar_screen.mesh.get_aabb().size + var texture_ratio := float(texture.get_width()) / float(texture.get_height()) + return absf(texture_ratio - glass.x / glass.y) < 0.01 + + +func _alert_lamp_for(alert_box: CockpitAlertBox, level: DiveAlertLevel.Value) -> String: + alert_box.present(level) + return alert_box.active_indicator_name() if alert_box.active_indicator_count() == 1 else "" func _binding_for(adapter: ProductionInputAdapter, action_id: StringName) -> String: