Author SHA1 Message Date
chindit b53ff3f8e0 Merge pull request 'Make the Hebat Allah wreck explorable in the main game' (#5) from fix/hebat-allah-gameplay into main
Quality Gate / Quality Gate (push) Successful in 6m11s
Reviewed-on: #5
2026-10-07 17:33:49 +02:00
Codex eef7b5c362 Wire Hebat Allah dive into the main game
Quality Gate / Quality Gate (pull_request) Successful in 6m15s
2026-10-07 17:31:10 +02:00
chindit a8d913a320 Merge pull request 'Add Hebat Allah wreck to the sunlit shallows site' (#3) from olix21/feat/wreck-hebat-allah into main
Quality Gate / Quality Gate (push) Successful in 6m23s
Reviewed-on: #3
2026-10-07 17:30:00 +02:00
chindit e53bf8dbb4 Merge pull request 'Add sunlit shallows site with localized sun shafts' (#2) from olix21/feat/sunlit-shallows into main
Quality Gate / Quality Gate (push) Successful in 5m26s
Reviewed-on: #2
2026-10-07 12:21:42 +02:00
chindit a4dbfedbcf Merge pull request 'feat(cockpit) : douze modèles scriptés remplacent le cockpit provisoire' (#1) from olix21/feat/cockpit-models into main
Quality Gate / Quality Gate (push) Successful in 6m2s
Reviewed-on: #1
2026-10-07 12:21:15 +02:00
OlivierandClaude Fable 5.1 50b5aac069 Add Hebat Allah wreck to the sunlit shallows site
Quality Gate / Quality Gate (pull_request) Successful in 6m49s
First wreck in the production world layer. A WreckActor (StaticBody3D)
instantiates a two-LOD glTF visual, builds a trimesh collision from a
separate lighter glTF, and carries the sonar metadata (metal, 0.9
reflectivity) so the bathymetry rays echo it. The sunlit shallows site
places the MV Hebat Allah 62 m ahead of the submarine on the floor.

The assets derive from a CC BY 4.0 photogrammetry scan by Holger Buss
(Sketchfab). The raw 274 MB scan stays outside the repo; the committed
GLBs come from a reproducible Blender pipeline under scripts/assets/:
skirt cut, 44 m rescale, merge by distance (the scan is a triangle soup),
fragment removal, 0.4 m voxel remesh, decimation to 58k/14.5k/2.9k
triangles, smart UVs and a 2K albedo rebaked from a luminance-rebuilt
texture (the scan's red channel is dead).

docs/ASSET_LICENSES.md starts the licence registry required by the design
plan and records the attribution owed in the credits. The site suite
gains twelve assertions covering sonar metadata, collision, LOD handover
and triangle budgets. Godot extracts the embedded albedo next to the glb
on import; those PNGs are ignored like the cockpit ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 07:30:21 +02:00
OlivierandClaude Fable 5.1 f0072a3e11 Add sunlit shallows site with localized sun shafts
Quality Gate / Quality Gate (pull_request) Successful in 6m55s
New visual test site under game/world/sites: flat sandy floor at
-40 m, calm surface at 0 m, fixed daytime sun ahead of and above
the submarine. Light shafts come from a shadow-only surface
occluder whose alpha-scissor ripples open only inside a pool a few
tens of metres ahead; a subtractive FogVolume cancels the global
volumetric fog above the water, and an unshaded ceiling mesh shows
the lit underside of the surface inside that pool. A composed
world (sunlit_shallows_world.tscn) mounts the submarine, cockpit
and camera rig in it, with the projectors kept out of the fog.

Cockpit: the porthole glass moves to its own render layer with a
rougher material and the porthole fill light excludes that layer,
so interior lamps no longer mirror as hard specular points.

Quality gate: new integration suite for the site (42 checks), three
glass assertions in the foundation suite, and the Input-access
architecture rule now matches both path separators so the gate runs
on Windows. project.godot raises the volumetric fog froxel grid to
128 so the shafts stay readable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:18:56 +02:00
OlivierandClaude Opus 5.5 2c1ea50aba feat(cockpit): replace procedural cockpit with twelve scripted models
Quality Gate / Quality Gate (pull_request) Successful in 7m6s
Instance twelve Blender-scripted glTF models in the cockpit scene and
rebuild the cabin around them: gauge panel, sonar, switch panel, alert
box, speaker, porthole, hull wall, caged lamps, gas bottle, handrails
and side consoles. Presentation only; no domain, application or world
rule changes beyond enabling glow in the test environment.

- Needles, alert lamps and sonar screen are driven by the existing
  snapshots; the alert box now carries SYS/ATTN/ALRM.
- Depth dial reads percent of certified depth.
- Sonar display restyled as a turquoise holographic relief with a
  shader grid and height scale; SubViewport matches the glass (960x604).
- Cabin lighting retuned: warm pooled lamp light, cool porthole rim,
  lamp shadows, dial backlight.
- Procedural stand-ins removed (bulkhead, tanks, consoles, levers,
  indicator columns, placeholder pipe run, dial face PNGs).
- Tests adapted where nodes disappeared; one assertion removed
  (pipe_segment_count) until the pipe kit is placed.
- Model .glb files are committed without LFS; textures Godot extracts
  next to them at import are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:46:46 +02:00
63 changed files with 2565 additions and 1597 deletions

No files matched your search

+4
View File
@@ -3,3 +3,7 @@
*.tmp
*.log
builds/
# Textures que Godot extrait des .glb a l import : regenerees, non versionnees
game/presentation/cockpit/assets/models/*.png
game/world/wrecks/assets/**/*.png
+49
View File
@@ -0,0 +1,49 @@
# Registre des licences de ressources externes
Registre exigé par `GAME_DESIGN_PLAN.md` §19 : chaque ressource tierce (modèle,
texture, son, police) a une entrée ici avant d'entrer dans le dépôt ou dans une
build. Une ressource dont la licence interdit la diffusion commerciale est
marquée `À REMPLACER AVANT COMMERCIALISATION` et doit sortir du projet avant
toute vente, précommande, financement participatif ou page store payante.
Statuts possibles : `OK commercial`, `À VÉRIFIER`, `À REMPLACER AVANT
COMMERCIALISATION`, `RETIRÉ`.
## Modèles 3D
### MV Hebat Allah — `OK commercial` (crédit obligatoire)
- Source : https://sketchfab.com/3d-models/shipwreck-mv-hebat-allah-3a5306f3961146729d054ee42bc063e5
- Auteur : Holger Buss (`holger.buss`), Sketchfab.
- Licence : **CC BY 4.0**. Usage commercial et dérivés autorisés à condition de
créditer l'auteur. Crédit à inscrire dans l'écran des crédits :
« MV Hebat Allah shipwreck scan by Holger Buss (sketchfab.com), CC BY 4.0,
modified ».
- Contenu d'origine : scan photogrammétrique d'un caboteur de 44 m coulé
volontairement en mer Rouge (2004). 2 375 184 triangles, 109 morceaux, une
texture 8K. Fichier brut conservé hors dépôt dans
`C:\Users\olivi\Assets\hebat_allah\raw\`, jamais committé.
- Dérivés committés (`game/world/wrecks/assets/hebat_allah/`) : coque remise à
l'échelle sur 44 m, jupe de sédiment coupée à 3 m, soupe de triangles fusionnée
par distance, fragments isolés retirés, surface refermée par remesh voxel
0,4 m, LOD0 58 k triangles et LOD1 14,5 k avec UV refaits, albédo rebaké en 2K
puis neutralisé (le canal rouge du scan est inexploitable, la teinte est
reconstruite depuis la luminance), collision trimesh séparée de 2,9 k
triangles. Pipeline reproductible : `scripts/assets/hebat_allah_prepare_albedo.py`
puis `scripts/assets/hebat_allah_blender_pipeline.py`.
- Usage dans le projet : kit « acier du XXe siècle », site des hauts-fonds
ensoleillés (`game/world/sites/sunlit_shallows_environment.tscn`).
## Modèles du cockpit — `À VÉRIFIER` (polices seulement)
- `game/presentation/cockpit/assets/models/*.glb` : générés par scripts Blender
écrits pour le projet, aucune ressource tierce (voir
`docs/briefs/cockpit-modeles-integration.md`). Modèles : `OK commercial`.
- Polices rendues en texture dans les cadrans et étiquettes : Arial et Impact,
polices système Windows. Point de licence à vérifier avant diffusion ; prévoir
une police libre (SIL OFL) en remplacement si nécessaire.
## Images de référence
- `docs/visual_targets/cockpit-objective-v1.jpg` : générée pour le projet,
référence interne non distribuée dans les builds. Pas de contrainte.
View File
Whitespace-only changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+384
View File
@@ -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_<nom du nœud>` :
`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.
@@ -0,0 +1,28 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="PackedScene" path="res://game/world/sites/sunlit_shallows_environment.tscn" id="1_environment"]
[ext_resource type="PackedScene" path="res://game/world/submarine/submarine_actor.tscn" id="2_submarine"]
[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_camera_rig.tscn" id="3_camera"]
[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_interior.tscn" id="4_cockpit"]
[node name="SunlitShallowsWorld" type="Node3D"]
[node name="Environment" parent="." instance=ExtResource("1_environment")]
[node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")]
unique_name_in_owner = true
position = Vector3(0, -30, 0)
[node name="CockpitInterior" parent="SubmarineActor" instance=ExtResource("4_cockpit")]
unique_name_in_owner = true
[node name="CockpitCameraRig" parent="SubmarineActor" instance=ExtResource("3_camera")]
unique_name_in_owner = true
[node name="LeftProjector" parent="SubmarineActor" index="0"]
light_energy = 3.0
light_volumetric_fog_energy = 0.15
[node name="RightProjector" parent="SubmarineActor" index="1"]
light_energy = 3.0
light_volumetric_fog_energy = 0.15
@@ -51,8 +51,8 @@ const ConfirmationDialogScreen = preload(
const RebindResult = preload("res://game/application/input/rebind_result.gd")
const MAIN_MENU_SCENE = preload("res://game/presentation/main_menu/main_menu_screen.tscn")
const VEHICLE_TEST_WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/vehicle_test_world.tscn"
const DIVE_WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/sunlit_shallows_world.tscn"
)
const COCKPIT_HUD_SCENE = preload("res://game/presentation/cockpit/cockpit_hud.tscn")
const SONAR_DISPLAY_SCENE = preload(
@@ -66,7 +66,7 @@ const CONFIRMATION_SCENE = preload(
"res://game/presentation/confirmation/confirmation_dialog.tscn"
)
const DIVE_SESSION_ID := &"vertical_slice_vehicle_test"
const DIVE_SESSION_ID := &"sunlit_shallows_dive"
const CONFIRM_ABANDON := &"abandon_dive"
const CONFIRM_MAIN_MENU := &"return_main_menu"
const CONFIRM_QUIT := &"quit_game"
@@ -105,7 +105,7 @@ func _ready() -> void:
_show_main_menu()
func _start_vehicle_test() -> void:
func _start_dive() -> void:
var rules = DiveRulesResolver.new().resolve(
DiveDifficultyProfile.standard(),
DiveUpgradeEffects.new(),
@@ -117,7 +117,7 @@ func _start_vehicle_test() -> void:
if not session.is_ready():
return
var world_scene := load(VEHICLE_TEST_WORLD_SCENE_PATH) as PackedScene
var world_scene := load(DIVE_WORLD_SCENE_PATH) as PackedScene
if world_scene == null:
return
var world := world_scene.instantiate() as Node3D
@@ -457,7 +457,7 @@ func _show_main_menu() -> void:
_replace_active_world(null)
var screen := MAIN_MENU_SCENE.instantiate() as MainMenuScreen
_replace_active_screen(screen)
screen.new_game_requested.connect(_start_vehicle_test)
screen.new_game_requested.connect(_start_dive)
screen.quit_requested.connect(_quit_game)
+39 -22
View File
@@ -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/<model>_<texture>.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/`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

@@ -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
@@ -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)
)
@@ -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)
+40 -168
View File
@@ -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,6 +48,8 @@ func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void:
if update == null or update.snapshot == null or telemetry == null:
return
var snapshot = update.snapshot
var alert_level: DiveAlertLevel.Value = snapshot.alerts.maximum
if _analog_gauges != null:
_analog_gauges.present(
snapshot.oxygen.ratio,
snapshot.battery.ratio,
@@ -55,23 +59,13 @@ func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void:
/ maxf(snapshot.depth.certified_m, 0.001),
0.0,
1.0
),
snapshot.alerts.maximum
)
var alert_level: DiveAlertLevel.Value = snapshot.alerts.maximum
)
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:
@@ -109,6 +108,10 @@ func practical_light_count() -> int:
return 0 if _practical_lighting == null else _practical_lighting.practical_light_count()
func fill_light_ignores_porthole_glass() -> bool:
return _practical_lighting != null and _practical_lighting.fill_light_ignores_porthole_glass()
func gauges_are_dashboard_mounted() -> bool:
return _analog_gauges != null and _analog_gauges.get_parent() == get_parent().get_node("Dashboard")
@@ -117,8 +120,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 +128,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 +143,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)
@@ -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
+88 -217
View File
@@ -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
@@ -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
@@ -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
@@ -9,16 +9,43 @@ const CockpitMeshFactory = preload(
"res://game/presentation/cockpit/cockpit_mesh_factory.gd"
)
const CockpitPressureBulkhead = preload(
"res://game/presentation/cockpit/cockpit_pressure_bulkhead.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
var _porthole_fill: 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 +53,82 @@ 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
)
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
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
)
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
# 30 cm behind the glass, it would mirror itself as a hard blue point.
porthole_fill.light_cull_mask &= ~CockpitPressureBulkhead.PORTHOLE_GLASS_RENDER_LAYER_BIT
add_child(porthole_fill)
_porthole_fill = porthole_fill
func fill_light_ignores_porthole_glass() -> bool:
return (
_porthole_fill != null
and (
_porthole_fill.light_cull_mask
& CockpitPressureBulkhead.PORTHOLE_GLASS_RENDER_LAYER_BIT
) == 0
)
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
@@ -1,192 +1,84 @@
class_name CockpitPressureBulkhead
extends Node3D
const CockpitMaterialPalette = preload(
"res://game/presentation/cockpit/cockpit_material_palette.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`.
## The porthole glass lives alone on render layer 2 so interior lights that
## sit right behind it (the cool porthole fill) can leave it out of their cull
## mask instead of mirroring themselves as a hard specular point.
const PORTHOLE_GLASS_RENDER_LAYER_BIT := 1 << 1
## Imported glass is near-mirror smooth; a little roughness turns the remaining
## lamp reflections into soft smears rather than pinpoints.
const PORTHOLE_GLASS_ROUGHNESS := 0.45
const CockpitMeshFactory = preload(
"res://game/presentation/cockpit/cockpit_mesh_factory.gd"
)
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
_porthole_glass.layers = PORTHOLE_GLASS_RENDER_LAYER_BIT
var glass_material := CockpitMeshFactory.own_surface_material(_porthole_glass)
if glass_material != null:
glass_material.roughness = PORTHOLE_GLASS_ROUGHNESS
func glass_is_on_dedicated_layer() -> bool:
return _porthole_glass != null and _porthole_glass.layers == PORTHOLE_GLASS_RENDER_LAYER_BIT
func glass_roughness() -> float:
if _porthole_glass == null:
return 0.0
var material := _porthole_glass.get_active_material(0) as BaseMaterial3D
return 0.0 if material == null else material.roughness
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
@@ -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
)
@@ -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
@@ -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
+4
View File
@@ -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()
+2 -2
View File
@@ -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"
@@ -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(
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 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
)
var metal := 1.0 if sample.material == SonarMaterial.Value.METAL else 0.0
return Color(height, metal, clampf(sample.intensity, 0.0, 1.0))
@@ -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)
@@ -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
@@ -0,0 +1,28 @@
// Flat sandy floor with slowly drifting caustic lines. The pattern is purely
// presentational and time-driven by the shader clock; no gameplay reads it.
shader_type spatial;
uniform vec3 sand_color : source_color = vec3(0.34, 0.32, 0.25);
uniform vec3 caustic_tint : source_color = vec3(0.55, 0.82, 0.92);
uniform sampler2D caustic_noise : repeat_enable, filter_linear_mipmap;
uniform float caustic_scale = 0.055;
uniform float caustic_speed = 0.018;
uniform float caustic_strength : hint_range(0.0, 2.0) = 0.25;
varying vec2 world_xz;
void vertex() {
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
void fragment() {
vec2 drift_a = TIME * caustic_speed * vec2(1.0, 0.6);
vec2 drift_b = TIME * caustic_speed * vec2(-0.7, 1.0);
float layer_a = texture(caustic_noise, world_xz * caustic_scale + drift_a).r;
float layer_b = texture(caustic_noise, world_xz * caustic_scale * 1.35 + drift_b).r;
float caustic = pow(clamp(min(layer_a, layer_b) * 1.9, 0.0, 1.0), 4.0);
ALBEDO = sand_color;
ROUGHNESS = 0.95;
METALLIC = 0.0;
EMISSION = caustic_tint * caustic * caustic_strength;
}
@@ -0,0 +1,161 @@
class_name SunlitShallowsEnvironment
extends Node3D
## Visual test site: flat floor, calm water, fixed daytime sun.
## The hour never changes, so the sun transform is a constant and the only
## motion is the slow ripple drift inside the two shaders.
const SURFACE_LEVEL_M := 0.0
const FLOOR_DEPTH_M := -40.0
## Air above the calm surface holds no scattering medium: a subtractive
## FogVolume cancels the global volumetric fog there so the view never shows
## an unshadowed glow above the water.
const AIR_VOLUME_HEIGHT_M := 80.0
const VOLUMETRIC_FOG_DENSITY := 0.035
const SITE_EXTENT_M := 240.0
## Sun sits ahead of the observer (world -Z) and high: shafts travel down
## toward the submarine, as in the cockpit visual target.
const SUN_ROTATION_DEGREES := Vector3(-44.0, 162.0, 0.0)
const NOISE_SEED := 7
## Calm water: ripple drift stays well under 0.05 texture units per second.
const SURFACE_DRIFT_SPEED := 0.012
## Sunlight only breaks through a pool of surface a few tens of metres ahead of
## the submarine (world -Z), so the shafts land in front of the porthole.
const SHAFT_POOL_CENTER_XZ_M := Vector2(-14.0, -31.0)
const SHAFT_POOL_RADIUS_M := 22.0
const SHAFT_POOL_EDGE_M := 12.0
## Coarser ripples give a few broken oblique shafts rather than a dense fan.
const SURFACE_RIPPLE_SCALE := 0.025
## The underside of the surface is drawn dark, lit only inside the pool.
const SURFACE_CEILING_LIT_SCALE := 0.3
const SURFACE_OCCLUDER_SHADER := preload(
"res://game/world/sites/sunlit_shallows_surface_occluder.gdshader"
)
const CAUSTIC_FLOOR_SHADER := preload(
"res://game/world/sites/sunlit_shallows_caustic_floor.gdshader"
)
const SURFACE_CEILING_SHADER := preload(
"res://game/world/sites/sunlit_shallows_surface_ceiling.gdshader"
)
func _ready() -> void:
_fix_sun()
_add_flat_floor()
_add_surface_occluder()
_add_surface_ceiling()
_add_air_fog_cut()
func _fix_sun() -> void:
var sun := get_node_or_null("Sun") as DirectionalLight3D
if sun == null:
return
sun.rotation_degrees = SUN_ROTATION_DEGREES
func _add_flat_floor() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(SITE_EXTENT_M, SITE_EXTENT_M)
plane.subdivide_width = 1
plane.subdivide_depth = 1
var material := ShaderMaterial.new()
material.shader = CAUSTIC_FLOOR_SHADER
material.set_shader_parameter("caustic_noise", _make_ripple_noise(0.007))
var body := StaticBody3D.new()
body.name = "FlatFloor"
body.position = Vector3(0.0, FLOOR_DEPTH_M, 0.0)
body.set_meta("sonar_material", 1)
body.set_meta("sonar_reflectivity", 0.6)
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = "Mesh"
mesh_instance.mesh = plane
mesh_instance.material_override = material
body.add_child(mesh_instance)
var collision := CollisionShape3D.new()
collision.name = "Collision"
var shape := BoxShape3D.new()
shape.size = Vector3(SITE_EXTENT_M, 1.0, SITE_EXTENT_M)
collision.shape = shape
collision.position = Vector3(0.0, -0.5, 0.0)
body.add_child(collision)
add_child(body)
func _add_surface_occluder() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(SITE_EXTENT_M, SITE_EXTENT_M)
var material := ShaderMaterial.new()
material.shader = SURFACE_OCCLUDER_SHADER
material.set_shader_parameter("ripple_noise", _make_ripple_noise(0.009))
material.set_shader_parameter("drift_speed", SURFACE_DRIFT_SPEED)
material.set_shader_parameter("ripple_scale", SURFACE_RIPPLE_SCALE)
material.set_shader_parameter("pool_center_xz", SHAFT_POOL_CENTER_XZ_M)
material.set_shader_parameter("pool_radius", SHAFT_POOL_RADIUS_M)
material.set_shader_parameter("pool_edge", SHAFT_POOL_EDGE_M)
var occluder := MeshInstance3D.new()
occluder.name = "CalmSurfaceOccluder"
occluder.position = Vector3(0.0, SURFACE_LEVEL_M, 0.0)
occluder.mesh = plane
occluder.material_override = material
occluder.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
add_child(occluder)
func _add_surface_ceiling() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(SITE_EXTENT_M * 1.5, SITE_EXTENT_M * 1.5)
var material := ShaderMaterial.new()
material.shader = SURFACE_CEILING_SHADER
material.set_shader_parameter("ripple_noise", _make_ripple_noise(0.009))
material.set_shader_parameter("drift_speed", SURFACE_DRIFT_SPEED)
material.set_shader_parameter("pool_center_xz", SHAFT_POOL_CENTER_XZ_M)
material.set_shader_parameter("pool_radius", SHAFT_POOL_RADIUS_M)
material.set_shader_parameter("lit_scale", SURFACE_CEILING_LIT_SCALE)
var ceiling := MeshInstance3D.new()
ceiling.name = "SurfaceCeiling"
ceiling.position = Vector3(0.0, SURFACE_LEVEL_M, 0.0)
ceiling.mesh = plane
ceiling.material_override = material
ceiling.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(ceiling)
func _add_air_fog_cut() -> void:
var air := FogVolume.new()
air.name = "AirFogCut"
air.shape = RenderingServer.FOG_VOLUME_SHAPE_BOX
air.size = Vector3(SITE_EXTENT_M * 1.5, AIR_VOLUME_HEIGHT_M, SITE_EXTENT_M * 1.5)
air.position = Vector3(0.0, SURFACE_LEVEL_M + AIR_VOLUME_HEIGHT_M * 0.5, 0.0)
var material := FogMaterial.new()
material.density = -VOLUMETRIC_FOG_DENSITY
material.edge_fade = 0.0
air.material = material
add_child(air)
func _make_ripple_noise(frequency: float) -> NoiseTexture2D:
var noise := FastNoiseLite.new()
noise.seed = NOISE_SEED
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
noise.frequency = frequency
noise.fractal_octaves = 3
noise.fractal_lacunarity = 2.1
noise.fractal_gain = 0.45
var texture := NoiseTexture2D.new()
texture.width = 512
texture.height = 512
texture.seamless = true
texture.generate_mipmaps = true
texture.noise = noise
return texture
@@ -0,0 +1,69 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://game/world/sites/sunlit_shallows_environment.gd" id="1_environment"]
[ext_resource type="PackedScene" path="res://game/world/wrecks/hebat_allah_wreck.tscn" id="2_wreck"]
[sub_resource type="Environment" id="EnvironmentSunlitShallows"]
background_mode = 1
background_color = Color(0.005, 0.03, 0.06, 1)
ambient_light_source = 3
ambient_light_color = Color(0.14, 0.32, 0.42, 1)
ambient_light_energy = 0.22
tonemap_mode = 2
tonemap_exposure = 0.75
glow_enabled = true
glow_levels/1 = 0.6
glow_levels/2 = 1.0
glow_levels/3 = 0.8
glow_levels/4 = 0.3
glow_intensity = 0.35
glow_blend_mode = 0
glow_hdr_threshold = 1.6
fog_enabled = true
fog_light_color = Color(0.04, 0.16, 0.24, 1)
fog_light_energy = 0.12
fog_sun_scatter = 0.0
fog_density = 0.003
fog_aerial_perspective = 0.6
fog_sky_affect = 1.0
volumetric_fog_enabled = true
volumetric_fog_density = 0.035
volumetric_fog_albedo = Color(0.26, 0.52, 0.7, 1)
volumetric_fog_emission_energy = 0.0
volumetric_fog_anisotropy = 0.55
volumetric_fog_length = 150.0
volumetric_fog_detail_spread = 1.6
volumetric_fog_ambient_inject = 0.015
volumetric_fog_sky_affect = 1.0
volumetric_fog_temporal_reprojection_enabled = true
volumetric_fog_temporal_reprojection_amount = 0.92
[node name="SunlitShallowsEnvironment" type="Node3D"]
script = ExtResource("1_environment")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("EnvironmentSunlitShallows")
[node name="Sun" type="DirectionalLight3D" parent="."]
rotation_degrees = Vector3(-44, 162, 0)
light_color = Color(0.86, 0.95, 1, 1)
light_energy = 1.0
light_volumetric_fog_energy = 1.4
shadow_enabled = true
shadow_bias = 0.04
shadow_normal_bias = 1.5
shadow_blur = 2.5
directional_shadow_mode = 1
directional_shadow_split_1 = 0.2
directional_shadow_blend_splits = true
directional_shadow_max_distance = 160.0
[node name="ObserverCamera" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.9962, 0.0872, 0, -0.0872, 0.9962, 0, -30, 0)
fov = 72.0
near = 0.1
far = 400.0
[node name="HebatAllahWreck" parent="." instance=ExtResource("2_wreck")]
position = Vector3(-8, -41, -62)
rotation_degrees = Vector3(0, 55, 3)
@@ -0,0 +1,32 @@
// Underside of the calm surface as seen from below: dark everywhere, lit with
// drifting crests only inside the pool where the occluder lets the sun through.
// Purely visual; shadows and shafts come from the occluder mesh.
shader_type spatial;
render_mode unshaded, cull_disabled;
uniform sampler2D ripple_noise : repeat_enable, filter_linear_mipmap;
uniform vec2 pool_center_xz = vec2(-14.0, -31.0);
uniform float pool_radius = 22.0;
uniform float ripple_scale = 0.03;
uniform float drift_speed = 0.012;
uniform float lit_scale : hint_range(0.0, 1.0) = 0.3;
uniform vec3 dark_color : source_color = vec3(0.01, 0.05, 0.09);
uniform vec3 lit_color : source_color = vec3(0.5, 0.85, 1.0);
varying vec2 world_xz;
void vertex() {
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
void fragment() {
vec2 slow_drift = vec2(TIME * drift_speed);
vec2 cross_drift = vec2(-TIME * drift_speed * 0.7);
float broad = texture(ripple_noise, world_xz * ripple_scale + slow_drift).r;
float fine = texture(ripple_noise, world_xz * ripple_scale * 1.9 + cross_drift).r;
float crest = smoothstep(0.45, 0.7, broad * 0.65 + fine * 0.35);
float pool = 1.0 - smoothstep(
pool_radius * 0.6, pool_radius * 1.6, distance(world_xz, pool_center_xz)
);
ALBEDO = mix(dark_color, lit_color * (0.35 + crest) * lit_scale, pool);
}
@@ -0,0 +1,39 @@
// Calm-water surface seen only through its shadow: the mesh is invisible
// (SHADOW_ONLY) and the alpha-scissor cut-outs let the fixed daytime sun carve
// light shafts into the volumetric fog below.
shader_type spatial;
render_mode cull_disabled, unshaded, depth_prepass_alpha;
uniform sampler2D ripple_noise : repeat_enable, filter_linear_mipmap;
uniform float ripple_scale = 0.025;
uniform float drift_speed = 0.012;
uniform float coverage : hint_range(0.0, 1.0) = 0.30;
// World-space pool where the surface lets sunlight through. Outside it the
// surface is fully opaque, so shafts only form in that patch of water.
uniform vec2 pool_center_xz = vec2(-14.0, -31.0);
uniform float pool_radius = 22.0;
uniform float pool_edge = 12.0;
varying vec2 world_xz;
void vertex() {
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
void fragment() {
vec2 slow_drift = TIME * drift_speed * vec2(1.0, 0.35);
vec2 cross_drift = TIME * drift_speed * vec2(-0.4, 0.9);
float broad = texture(ripple_noise, world_xz * ripple_scale + slow_drift).r;
float fine = texture(ripple_noise, world_xz * ripple_scale * 1.9 + cross_drift).r;
float raw_ripple = broad * 0.65 + fine * 0.35;
// Inside the pool the crests are sharpened so the gaps cut crisp shafts.
float pool_ripple = smoothstep(0.35, 0.65, raw_ripple);
// Outside the pool only the deepest troughs open: a dim dapple, not darkness.
float dim_ripple = smoothstep(0.16, 0.30, raw_ripple);
float pool = 1.0 - smoothstep(
pool_radius - pool_edge, pool_radius, distance(world_xz, pool_center_xz)
);
ALBEDO = vec3(0.0);
ALPHA = mix(dim_ripple, pool_ripple, pool);
ALPHA_SCISSOR_THRESHOLD = coverage;
}
@@ -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
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://game/world/wrecks/wreck_actor.gd" id="1_wreck_actor"]
[ext_resource type="PackedScene" path="res://game/world/wrecks/assets/hebat_allah/hebat_allah_visual.glb" id="2_visual"]
[ext_resource type="PackedScene" path="res://game/world/wrecks/assets/hebat_allah/hebat_allah_collision.glb" id="3_collision"]
[node name="HebatAllahWreck" type="StaticBody3D"]
script = ExtResource("1_wreck_actor")
visual_scene = ExtResource("2_visual")
collision_scene = ExtResource("3_collision")
lod_switch_distance_m = 45.0
sonar_reflectivity = 0.9
+65
View File
@@ -0,0 +1,65 @@
class_name WreckActor
extends StaticBody3D
## A wreck resting on a site: two visual levels of detail from one glTF and a
## separate, much lighter trimesh collision. The sonar reads the body's
## metadata, so this node is the one owner of how a wreck echoes.
const LOD0_NODE_NAME := "WreckLOD0"
const LOD1_NODE_NAME := "WreckLOD1"
const COLLISION_NODE_NAME := "WreckCollision"
const COLLISION_SHAPE_NAME := "Collision"
const VISUAL_NAME := "Visual"
## Hysteresis around the LOD handover so a hovering submarine never flickers.
const LOD_MARGIN_M := 4.0
@export var visual_scene: PackedScene
@export var collision_scene: PackedScene
## Beyond this distance the coarse LOD is drawn instead of the detailed one.
@export_range(10.0, 400.0, 1.0) var lod_switch_distance_m := 45.0
@export_range(0.0, 1.0, 0.01) var sonar_reflectivity := 0.9
func _ready() -> void:
set_meta("sonar_material", SonarMaterial.Value.METAL)
set_meta("sonar_reflectivity", sonar_reflectivity)
_build_visual()
_build_collision()
func _build_visual() -> void:
if visual_scene == null:
push_error("WreckActor %s has no visual scene" % name)
return
var visual := visual_scene.instantiate() as Node3D
visual.name = VISUAL_NAME
add_child(visual)
var lod0 := visual.find_child(LOD0_NODE_NAME, true, false) as MeshInstance3D
var lod1 := visual.find_child(LOD1_NODE_NAME, true, false) as MeshInstance3D
if lod0 == null or lod1 == null:
push_error("WreckActor %s visual scene lacks %s or %s" % [
name, LOD0_NODE_NAME, LOD1_NODE_NAME
])
return
lod0.visibility_range_end = lod_switch_distance_m
lod0.visibility_range_end_margin = LOD_MARGIN_M
lod1.visibility_range_begin = lod_switch_distance_m
lod1.visibility_range_begin_margin = LOD_MARGIN_M
func _build_collision() -> void:
if collision_scene == null:
push_error("WreckActor %s has no collision scene" % name)
return
var source := collision_scene.instantiate() as Node3D
var collision_mesh := source.find_child(COLLISION_NODE_NAME, true, false) as MeshInstance3D
if collision_mesh == null or collision_mesh.mesh == null:
push_error("WreckActor %s collision scene lacks %s" % [name, COLLISION_NODE_NAME])
source.free()
return
var shape := CollisionShape3D.new()
shape.name = COLLISION_SHAPE_NAME
shape.shape = collision_mesh.mesh.create_trimesh_shape()
shape.transform = collision_mesh.transform
source.free()
add_child(shape)
+2
View File
@@ -23,4 +23,6 @@ window/stretch/mode="canvas_items"
[rendering]
environment/volumetric_fog/volume_size=128
environment/volumetric_fog/volume_depth=128
textures/default_filters/use_nearest_mipmap_filter=false
@@ -0,0 +1,272 @@
"""Blender pipeline turning the raw MV Hebat Allah photogrammetry scan into the
game-ready wreck assets committed under game/world/wrecks/assets/hebat_allah/.
Run headless with Blender 5.2:
WRECK_RAW_DIR=<dir with wreck_raw_joined.blend and albedo_2k_balanced.png>
WRECK_OUT_DIR=<output dir>
WRECK_VOXEL=0.4
blender -b --python scripts/assets/hebat_allah_blender_pipeline.py
Steps: apply transforms, cut the sediment skirt 3 m around the hull, scale the
hull to its real 44 m, merge the triangle soup by distance, drop isolated
fragments, voxel remesh to a closed surface, decimate LOD0, smart-project UVs,
bake the albedo from the scan, derive LOD1 and a UV-less collision mesh, export
two GLBs. Licence: see docs/ASSET_LICENSES.md (CC BY 4.0, Holger Buss).
"""
import bpy, bmesh, math, json, os, time
from mathutils import Vector, Matrix
T0 = time.time()
RAW = os.environ.get("WRECK_RAW_DIR", r"C:\Users\olivi\Assets\hebat_allah\raw")
VOXEL_TAG = os.environ.get("WRECK_VOXEL", "0.4")
VOXEL = float(VOXEL_TAG)
OUT = os.environ.get("WRECK_OUT_DIR", r"C:\Users\olivi\Assets\hebat_allah\out\v" + VOXEL_TAG)
os.makedirs(OUT, exist_ok=True)
CHK = os.path.join(OUT, "check")
os.makedirs(CHK, exist_ok=True)
log = {}
def mark(k):
log[k] = round(time.time() - T0, 1)
print("STEP", k, log[k], flush=True)
def activate(o):
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
bpy.context.view_layer.objects.active = o
bpy.ops.wm.open_mainfile(filepath=RAW + r"\wreck_raw_joined.blend")
src = bpy.data.objects["wreck_raw"]
activate(src)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
co = [v.co for v in src.data.vertices if v.co.z > 3.0]
fx = (min(c.x for c in co), max(c.x for c in co))
fy = (min(c.y for c in co), max(c.y for c in co))
MARGIN = 3.0
bpy.ops.object.mode_set(mode='EDIT')
for pno, pco in [((1, 0, 0), (fx[1] + MARGIN, 0, 0)), ((-1, 0, 0), (fx[0] - MARGIN, 0, 0)),
((0, 1, 0), (0, fy[1] + MARGIN, 0)), ((0, -1, 0), (0, fy[0] - MARGIN, 0))]:
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.bisect(plane_co=pco, plane_no=pno, clear_outer=True, clear_inner=False)
bpy.ops.object.mode_set(mode='OBJECT')
cx, cy = (fx[0] + fx[1]) / 2, (fy[0] + fy[1]) / 2
zmin = min(v.co.z for v in src.data.vertices)
SCALE = 44.0 / (fx[1] - fx[0])
src.data.transform(Matrix.Scale(SCALE, 4) @ Matrix.Rotation(math.radians(90), 4, 'Z') @ Matrix.Translation((-cx, -cy, -zmin)))
src.data.update()
mark("cut_scaled")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
src.data.calc_loop_triangles()
log["verts_after_merge"] = len(src.data.vertices)
mark("merged")
print("DBG", log, flush=True)
def drop_small_components(obj, min_faces):
import numpy as np
me = obj.data
nv = len(me.vertices)
ev = np.empty(len(me.edges) * 2, dtype=np.int64)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
label = np.arange(nv, dtype=np.int64)
for _ in range(10000):
m = np.minimum(label[ev[:, 0]], label[ev[:, 1]])
new = label.copy()
np.minimum.at(new, ev[:, 0], m)
np.minimum.at(new, ev[:, 1], m)
for _ in range(4):
new = new[new]
if np.array_equal(new, label):
break
label = new
nf = len(me.polygons)
loop_start = np.empty(nf, dtype=np.int64)
me.polygons.foreach_get("loop_start", loop_start)
loop_vert = np.empty(len(me.loops), dtype=np.int64)
me.loops.foreach_get("vertex_index", loop_vert)
flab = label[loop_vert[loop_start]]
counts = np.bincount(flab, minlength=nv)
small = counts[flab] < min_faces
removed = int(np.unique(flab[small]).size)
kept = int(np.unique(flab[~small]).size)
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
geom = [bm.faces[int(i)] for i in np.nonzero(small)[0]]
if geom:
bmesh.ops.delete(bm, geom=geom, context='FACES')
bm.to_mesh(me)
bm.free()
me.update()
return removed, kept
log["frag_removed_kept"] = drop_small_components(src, 400)
mark("fragments")
print("DBG", log, flush=True)
src.data.calc_loop_triangles()
log["tris_after_clean"] = len(src.data.loop_triangles)
mat_src = bpy.data.materials.new("src_mat")
mat_src.use_nodes = True
nt = mat_src.node_tree
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = bpy.data.images.load(RAW + r"\albedo_2k_balanced.png")
nt.links.new(tex.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"])
src.data.materials.clear()
src.data.materials.append(mat_src)
rm = src.copy()
rm.data = src.data.copy()
rm.name = "remeshed"
bpy.context.scene.collection.objects.link(rm)
activate(rm)
rm.data.remesh_voxel_size = VOXEL
rm.data.remesh_voxel_adaptivity = 0.0
rm.data.use_remesh_fix_poles = False
rm.data.use_remesh_preserve_volume = True
bpy.ops.object.voxel_remesh()
rm.data.calc_loop_triangles()
log["tris_after_remesh"] = len(rm.data.loop_triangles)
mark("remesh")
print("DBG", log, flush=True)
log["remesh_frag_removed_kept"] = drop_small_components(rm, 300)
rm.data.calc_loop_triangles()
n_rm = len(rm.data.loop_triangles)
lod0 = rm
lod0.name = "WreckLOD0"
lod0.data.name = "WreckLOD0"
m = lod0.modifiers.new("dec", 'DECIMATE')
m.ratio = min(1.0, 60000 / n_rm)
m.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier="dec")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=0.003, correct_aspect=True, scale_to_bounds=False)
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.shade_smooth()
mark("lod0_uv")
baked = bpy.data.images.new("wreck_hebat_allah_albedo", 2048, 2048)
baked.generated_color = (0.3, 0.3, 0.3, 1)
mat = bpy.data.materials.new("wreck_hebat_allah")
mat.use_nodes = True
nt = mat.node_tree
tn = nt.nodes.new("ShaderNodeTexImage")
tn.image = baked
nt.links.new(tn.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"])
nt.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.85
nt.nodes["Principled BSDF"].inputs["Metallic"].default_value = 0.0
nt.nodes.active = tn
lod0.data.materials.clear()
lod0.data.materials.append(mat)
sc = bpy.context.scene
sc.render.engine = 'CYCLES'
sc.cycles.device = 'CPU'
sc.cycles.samples = 4
sc.render.bake.use_selected_to_active = True
sc.render.bake.cage_extrusion = 0.6
sc.render.bake.max_ray_distance = 1.6
sc.render.bake.margin = 8
sc.render.bake.use_pass_direct = False
sc.render.bake.use_pass_indirect = False
sc.render.bake.use_pass_color = True
bpy.ops.object.select_all(action='DESELECT')
src.select_set(True)
lod0.select_set(True)
bpy.context.view_layer.objects.active = lod0
bpy.ops.object.bake(type='DIFFUSE')
baked.filepath_raw = OUT + r"\hebat_allah_albedo_2k.png"
baked.file_format = 'PNG'
baked.save()
mark("bake")
def derive(name, ratio, keep_mat):
o = lod0.copy()
o.data = lod0.data.copy()
o.name = name
o.data.name = name
bpy.context.scene.collection.objects.link(o)
activate(o)
if not keep_mat:
while o.data.uv_layers:
o.data.uv_layers.remove(o.data.uv_layers[0])
m = o.modifiers.new("dec", 'DECIMATE')
m.ratio = ratio
m.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier="dec")
if not keep_mat:
o.data.materials.clear()
o.data.calc_loop_triangles()
return o, len(o.data.loop_triangles)
lod1, t1 = derive("WreckLOD1", 0.25, True)
col, tc = derive("WreckCollision", 0.05, False)
lod0.data.calc_loop_triangles()
t0 = len(lod0.data.loop_triangles)
bpy.data.objects.remove(src)
def export(objs, path):
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB', use_selection=True, export_apply=True,
export_yup=True, export_image_format='AUTO', export_materials='EXPORT')
export([lod0, lod1], OUT + r"\hebat_allah_visual.glb")
export([col], OUT + r"\hebat_allah_collision.glb")
bpy.ops.wm.save_as_mainfile(filepath=OUT + r"\hebat_allah_work.blend")
mark("export")
sc.render.engine = 'BLENDER_WORKBENCH'
sc.display.shading.light = 'STUDIO'
sc.display.shading.color_type = 'TEXTURE'
sc.render.resolution_x = 1600
sc.render.resolution_y = 900
sc.world = bpy.data.worlds.new("W")
sc.world.color = (0.08, 0.1, 0.12)
cam = bpy.data.cameras.new("C")
cam.lens = 35
cob = bpy.data.objects.new("Cam", cam)
sc.collection.objects.link(cob)
sc.camera = cob
def look(loc, target):
cob.location = loc
cob.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
def shot(name, vis):
for o in (lod0, lod1, col):
o.hide_render = o is not vis
sc.render.filepath = os.path.join(CHK, name + ".png")
bpy.ops.render.render(write_still=True)
look((-30, -45, 18), (0, 0, 5))
shot("lod0_bow_quarter", lod0)
shot("lod1_bow_quarter", lod1)
shot("collision_bow_quarter", col)
look((28, 40, 14), (0, 0, 5))
shot("lod0_stern_quarter", lod0)
look((-14, -6, 3.5), (0, -8, 4))
shot("lod0_from_12m", lod0)
look((0, 0, 70), (0, 0, 0))
shot("lod0_top", lod0)
log.update({"scale": SCALE, "lod0_tris": t0, "lod1_tris": t1, "collision_tris": tc,
"dims": [round(v, 2) for v in lod0.dimensions],
"sizes_MB": {f: round(os.path.getsize(os.path.join(OUT, f)) / 1e6, 1) for f in os.listdir(OUT)}})
print("RESULT" + json.dumps(log), flush=True)
@@ -0,0 +1,42 @@
"""Rebuild a neutral 2K albedo from the raw MV Hebat Allah scan texture.
The underwater scan has a dead red channel (mean 17/255) and a strong blue
cast, so a white balance only produces magenta noise. This script keeps the
luminance of the two live channels, stretches it, and tints it with a rust /
algae palette driven by the green-to-blue ratio. The game's fog adds the water
colour at runtime.
python scripts/assets/hebat_allah_prepare_albedo.py <albedo_8k.png> <out_dir>
Writes albedo_2k_balanced.png and albedo_1k_balanced.png into <out_dir>.
Requires Pillow and numpy. Licence: see docs/ASSET_LICENSES.md.
"""
import sys
from pathlib import Path
import numpy as np
from PIL import Image
def balance(source: Path, out_dir: Path) -> None:
image = Image.open(source).convert("RGB").resize((2048, 2048), Image.LANCZOS)
rgb = np.asarray(image).astype(np.float32) / 255.0
luminance = 0.55 * rgb[..., 1] + 0.45 * rgb[..., 2]
low, high = np.percentile(luminance, 1), np.percentile(luminance, 99)
luminance = np.clip((luminance - low) / (high - low), 0.0, 1.0)
luminance = luminance ** 0.9 * 0.75 + 0.08
algae_ratio = np.clip((rgb[..., 1] - rgb[..., 2] * 0.68) * 3.0, 0.0, 1.0)
rust = np.array([0.62, 0.52, 0.44])
algae = np.array([0.50, 0.58, 0.46])
tint = rust[None, None, :] * (1.0 - algae_ratio[..., None]) + algae[None, None, :] * algae_ratio[..., None]
out = np.clip(luminance[..., None] * tint / 0.55, 0.0, 1.0)
result = Image.fromarray((out * 255).astype(np.uint8))
out_dir.mkdir(parents=True, exist_ok=True)
result.save(out_dir / "albedo_2k_balanced.png")
result.resize((1024, 1024), Image.LANCZOS).save(out_dir / "albedo_1k_balanced.png")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
balance(Path(sys.argv[1]), Path(sys.argv[2]))
+1 -1
View File
@@ -108,7 +108,7 @@ if [ -d game ]; then
input_violations="$(
rg -n '\bInput\.' game --glob '*.gd' \
| rg -v '^game/infrastructure/input/' \
| rg -v '^game[/\\]infrastructure[/\\]input[/\\]' \
|| true
)"
fail_with_matches "direct Input access outside infrastructure/input" "$input_violations"
+10
View File
@@ -113,6 +113,16 @@ check_godot_log \
"production foundation integration" \
"$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log"
printf 'Quality gate: sunlit shallows environment integration\n'
"$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration.log" \
--path . \
--script tests/integration/run_sunlit_shallows_environment.gd \
2>&1 | tee "$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration-console.log"
check_godot_log \
"sunlit shallows environment integration" \
"$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration-console.log"
printf 'Quality gate: POC B integration\n'
"$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \
@@ -0,0 +1,313 @@
extends SceneTree
## Checks the sunlit shallows visual site keeps its three promises:
## a flat floor, a calm shadow-only water surface, and a daytime sun whose
## hour never changes.
const SCENE_PATH := "res://game/world/sites/sunlit_shallows_environment.tscn"
const WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/sunlit_shallows_world.tscn"
)
var _failures: Array[String] = []
var _checks := 0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene := load(SCENE_PATH) as PackedScene
_expect(scene != null, "scene loads")
if scene == null:
_finish()
return
var site := scene.instantiate() as SunlitShallowsEnvironment
_expect(site != null, "scene root is SunlitShallowsEnvironment")
if site == null:
_finish()
return
root.add_child(site)
await process_frame
_test_fixed_daytime_sun(site)
_test_flat_floor(site)
_test_calm_surface_occluder(site)
_test_scattering_environment(site)
_test_air_fog_cut(site)
_test_surface_ceiling(site)
_test_wreck(site)
site.queue_free()
await process_frame
_test_composed_world()
_finish()
func _test_composed_world() -> void:
var world_scene := load(WORLD_SCENE_PATH) as PackedScene
_expect(world_scene != null, "composed world scene loads")
if world_scene == null:
return
var world := world_scene.instantiate() as Node3D
_expect(
world.get_node_or_null("Environment") is SunlitShallowsEnvironment,
"composed world uses the sunlit shallows environment"
)
var submarine := world.get_node_or_null("SubmarineActor") as Node3D
_expect(submarine != null, "composed world has a submarine")
if submarine != null:
_expect(
submarine.position.y < SunlitShallowsEnvironment.SURFACE_LEVEL_M
and submarine.position.y > SunlitShallowsEnvironment.FLOOR_DEPTH_M,
"submarine starts between surface and floor"
)
_expect(
submarine.get_node_or_null("CockpitInterior") != null
and submarine.get_node_or_null("CockpitCameraRig") != null,
"submarine carries cockpit and camera rig"
)
for projector_name: String in ["LeftProjector", "RightProjector"]:
var projector := submarine.get_node_or_null(projector_name) as SpotLight3D
_expect(projector != null, "%s exists" % projector_name)
if projector != null:
_expect(
projector.light_volumetric_fog_energy < 1.0,
"%s does not wash out the sun shafts in the fog" % projector_name
)
world.free()
func _test_fixed_daytime_sun(site: SunlitShallowsEnvironment) -> void:
var sun := site.get_node_or_null("Sun") as DirectionalLight3D
_expect(sun != null, "sun is a DirectionalLight3D")
if sun == null:
return
_expect(
sun.rotation_degrees.is_equal_approx(
SunlitShallowsEnvironment.SUN_ROTATION_DEGREES
),
"sun hour is the fixed constant"
)
var sun_direction := -sun.global_transform.basis.z
_expect(sun_direction.y < -0.6, "sun shines downward from above the porthole (daytime)")
_expect(
sun_direction.z > 0.15,
"sun shines toward the observer (rays come from ahead, world -Z)"
)
_expect(sun.shadow_enabled, "sun casts shadows (needed for light shafts)")
_expect(
sun.light_volumetric_fog_energy >= 1.0,
"sun injects energy into the volumetric fog"
)
func _test_flat_floor(site: SunlitShallowsEnvironment) -> void:
var floor_mesh := site.get_node_or_null("FlatFloor/Mesh") as MeshInstance3D
_expect(floor_mesh != null, "floor mesh exists")
if floor_mesh == null:
return
var vertices := floor_mesh.mesh.get_faces()
var flat := true
for vertex: Vector3 in vertices:
if not is_zero_approx(vertex.y):
flat = false
break
_expect(flat, "floor mesh is flat")
_expect(
is_equal_approx(
floor_mesh.global_position.y,
SunlitShallowsEnvironment.FLOOR_DEPTH_M
),
"floor sits at the site depth"
)
_expect(
floor_mesh.material_override is ShaderMaterial,
"floor uses the caustic shader material"
)
var collision := site.get_node_or_null("FlatFloor/Collision") as CollisionShape3D
_expect(collision != null and collision.shape is BoxShape3D, "floor has a box collider")
func _test_calm_surface_occluder(site: SunlitShallowsEnvironment) -> void:
var occluder := site.get_node_or_null("CalmSurfaceOccluder") as MeshInstance3D
_expect(occluder != null, "surface occluder exists")
if occluder == null:
return
_expect(
occluder.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY,
"surface occluder is shadow-only"
)
_expect(
is_equal_approx(
occluder.global_position.y,
SunlitShallowsEnvironment.SURFACE_LEVEL_M
),
"surface occluder sits at water level"
)
var material := occluder.material_override as ShaderMaterial
_expect(material != null, "surface occluder uses a shader material")
if material == null:
return
var drift: Variant = material.get_shader_parameter("drift_speed")
_expect(
drift is float and drift > 0.0 and drift < 0.05,
"surface drift is slow (calm water)"
)
_expect(
material.get_shader_parameter("ripple_noise") is NoiseTexture2D,
"surface ripple noise is assigned"
)
var pool_center: Variant = material.get_shader_parameter("pool_center_xz")
_expect(
pool_center is Vector2 and pool_center.y < -20.0 and pool_center.y > -120.0,
"light pool sits a few tens of metres ahead (world -Z)"
)
var pool_radius: Variant = material.get_shader_parameter("pool_radius")
_expect(
pool_radius is float and pool_radius > 5.0 and pool_radius < 60.0,
"light pool radius is local, not the whole surface"
)
func _test_scattering_environment(site: SunlitShallowsEnvironment) -> void:
var world_environment := site.get_node_or_null("WorldEnvironment") as WorldEnvironment
_expect(world_environment != null, "world environment exists")
if world_environment == null:
return
var environment := world_environment.environment
_expect(environment.volumetric_fog_enabled, "volumetric fog is enabled")
_expect(environment.volumetric_fog_anisotropy > 0.5, "fog scatters forward")
_expect(environment.fog_enabled, "depth fog is enabled")
func _test_surface_ceiling(site: SunlitShallowsEnvironment) -> void:
var ceiling := site.get_node_or_null("SurfaceCeiling") as MeshInstance3D
_expect(ceiling != null, "visible surface ceiling exists")
if ceiling == null:
return
_expect(
is_equal_approx(ceiling.global_position.y, SunlitShallowsEnvironment.SURFACE_LEVEL_M),
"surface ceiling sits at water level"
)
_expect(
ceiling.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF,
"surface ceiling leaves shadowing to the occluder"
)
var ceiling_material := ceiling.material_override as ShaderMaterial
var occluder := site.get_node_or_null("CalmSurfaceOccluder") as MeshInstance3D
var occluder_material: ShaderMaterial = null if occluder == null else occluder.material_override
_expect(
ceiling_material != null
and occluder_material != null
and ceiling_material.get_shader_parameter("pool_center_xz")
== occluder_material.get_shader_parameter("pool_center_xz")
and ceiling_material.get_shader_parameter("pool_radius")
== occluder_material.get_shader_parameter("pool_radius"),
"surface ceiling lights the same pool the occluder opens"
)
func _test_air_fog_cut(site: SunlitShallowsEnvironment) -> void:
var air := site.get_node_or_null("AirFogCut") as FogVolume
_expect(air != null, "air fog cut exists")
if air == null:
return
var bottom := air.global_position.y - air.size.y * 0.5
_expect(
is_equal_approx(bottom, SunlitShallowsEnvironment.SURFACE_LEVEL_M),
"air fog cut starts at the water surface"
)
var material := air.material as FogMaterial
_expect(material != null and material.density < 0.0, "air fog cut subtracts fog")
var world_environment := site.get_node("WorldEnvironment") as WorldEnvironment
_expect(
material != null and is_equal_approx(
-material.density,
world_environment.environment.volumetric_fog_density
),
"air fog cut cancels the global volumetric density exactly"
)
func _test_wreck(site: SunlitShallowsEnvironment) -> void:
var wreck := site.get_node_or_null("HebatAllahWreck") as WreckActor
_expect(wreck != null, "site places the Hebat Allah wreck")
if wreck == null:
return
_expect(
int(wreck.get_meta("sonar_material", SonarMaterial.Value.UNKNOWN))
== SonarMaterial.Value.METAL,
"wreck echoes as metal on the sonar"
)
_expect((wreck.collision_layer & 1) == 1, "wreck sits on the sonar collision layer")
_expect(
wreck.position.y < SunlitShallowsEnvironment.SURFACE_LEVEL_M
and absf(wreck.position.y - SunlitShallowsEnvironment.FLOOR_DEPTH_M) < 3.0,
"wreck rests on the site floor"
)
var collision := wreck.get_node_or_null(WreckActor.COLLISION_SHAPE_NAME) as CollisionShape3D
_expect(
collision != null and collision.shape is ConcavePolygonShape3D,
"wreck has a trimesh collision separate from its visuals"
)
if collision != null and collision.shape is ConcavePolygonShape3D:
var faces := (collision.shape as ConcavePolygonShape3D).get_faces()
_expect(faces.size() >= 3 * 500, "wreck collision mesh is not empty")
_expect(faces.size() <= 3 * 6000, "wreck collision mesh stays light")
var lod0 := wreck.find_child(WreckActor.LOD0_NODE_NAME, true, false) as MeshInstance3D
var lod1 := wreck.find_child(WreckActor.LOD1_NODE_NAME, true, false) as MeshInstance3D
_expect(lod0 != null and lod1 != null, "wreck carries two visual LODs")
if lod0 == null or lod1 == null:
return
_expect(
lod0.visibility_range_end > 0.0
and is_equal_approx(lod1.visibility_range_begin, lod0.visibility_range_end),
"wreck LODs hand over at one distance"
)
var lod0_triangles := _triangle_count(lod0.mesh)
var lod1_triangles := _triangle_count(lod1.mesh)
_expect(
lod0_triangles > 0 and lod0_triangles <= 120000,
"wreck LOD0 stays within the triangle budget"
)
_expect(lod1_triangles > 0 and lod1_triangles < lod0_triangles / 2, "wreck LOD1 is far lighter")
_expect(
lod0.mesh.surface_get_material(0) != null
or lod0.get_surface_override_material(0) != null,
"wreck LOD0 carries a material"
)
func _triangle_count(mesh: Mesh) -> int:
if mesh == null:
return 0
var triangles := 0
for surface in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(surface)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if indices.is_empty():
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
triangles += vertices.size() / 3
else:
triangles += indices.size() / 3
return triangles
func _expect(condition: bool, label: String) -> void:
_checks += 1
if not condition:
_failures.append(label)
func _finish() -> void:
if _failures.is_empty():
print("Sunlit shallows environment: %d passed, 0 failed." % _checks)
quit(0)
else:
for failure: String in _failures:
push_error("sunlit shallows environment: %s" % failure)
print("Sunlit shallows environment: %d failed." % _failures.size())
quit(1)
@@ -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"
@@ -18,8 +29,14 @@ const ControlsSettingsScreen = preload(
const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd"
)
const SunlitShallowsEnvironment = preload(
"res://game/world/sites/sunlit_shallows_environment.gd"
)
const WreckActor = preload("res://game/world/wrecks/wreck_actor.gd")
const SonarService = preload("res://game/application/sonar/sonar_service.gd")
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const TEST_COUNT := 78
const TEST_COUNT := 99
var _failures: Array[String] = []
@@ -91,7 +108,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
)
@@ -99,13 +126,40 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var hud := composition.active_screen() as CockpitHud
var sonar_service = composition.sonar_service()
var sonar_display = composition.sonar_display()
_expect(composition.active_world() != null, "vehicle test world is assembled")
var active_world := composition.active_world()
var site: SunlitShallowsEnvironment = null
var wreck: WreckActor = null
var floor_body: StaticBody3D = null
if active_world != null:
site = active_world.get_node_or_null("Environment") as SunlitShallowsEnvironment
if site != null:
wreck = site.get_node_or_null("HebatAllahWreck") as WreckActor
floor_body = site.get_node_or_null("FlatFloor") as StaticBody3D
_expect(active_world != null and active_world.name == "SunlitShallowsWorld", "new game loads sunlit shallows world")
_expect(site != null, "sunlit environment is active")
_expect(
floor_body != null and floor_body.get_node_or_null("Collision") is CollisionShape3D,
"active dive has a physical seabed"
)
_expect(
wreck != null and wreck.get_node_or_null("Collision") is CollisionShape3D,
"active dive contains the collidable Hebat Allah wreck"
)
_expect(
actor != null
and wreck != null
and (wreck.global_position - actor.global_position).dot(-actor.global_basis.z) > 0.0
and actor.global_position.distance_to(wreck.global_position) < SonarService.MAXIMUM_RANGE_M,
"wreck starts ahead of the submarine within sonar range"
)
_expect(actor != null, "production submarine actor is assembled")
_expect(camera != null, "cockpit camera is attached to the submarine")
_expect(instruments != null, "physical cockpit instruments are assembled")
_expect(detailing != null, "cockpit structural detailing is assembled")
_expect(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 +171,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 +203,42 @@ 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(pressure_bulkhead.glass_is_on_dedicated_layer(), "porthole glass sits alone on its render layer")
_expect(pressure_bulkhead.glass_roughness() >= 0.4, "porthole glass is rough enough to smear lamp reflections")
_expect(detailing.fill_light_ignores_porthole_glass(), "porthole fill light does not reflect in the glass")
_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")
@@ -176,7 +253,11 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var first_scan = sonar_service.current_scan()
_expect(first_scan != null, "manual sonar command publishes a typed scan")
_expect(first_scan.samples.observed_count() > 1000, "reference acquisition captures dense bathymetry")
_expect(first_scan.contacts.size() >= 1, "metal calibration target becomes a distinct contact")
var has_wreck_contact := false
for contact in first_scan.contacts:
if contact.material == SonarMaterial.Value.METAL:
has_wreck_contact = true
_expect(has_wreck_contact, "manual sonar sees the metal wreck in the active world")
_expect("RELEVÉ EN COURS" in sonar_display.status_text(), "sonar display reports progressive reveal")
_expect(not sonar_display.idle_prompt_visible(), "sonar data replaces the idle instruction")
for frame in 18:
@@ -184,18 +265,33 @@ 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()
_expect(not sonar_display.is_expanded(), "expanded sonar returns to cockpit view")
var start_position: Vector3 = actor.global_position
var start_wreck_distance: float = (
actor.global_position.distance_to(wreck.global_position) if wreck != null else INF
)
var starting_battery: float = composition.latest_update().snapshot.battery.remaining_kwh
Input.action_press(ProductionInputAdapter.ACTION_FORWARD)
for frame in 35:
await physics_frame
Input.action_release(ProductionInputAdapter.ACTION_FORWARD)
_expect(actor.global_position.distance_to(start_position) > 0.1, "keyboard propulsion moves the submarine")
_expect(
wreck != null
and actor.global_position.distance_to(wreck.global_position) < start_wreck_distance - 0.1,
"forward propulsion closes the distance to the wreck"
)
_expect(actor.velocity.length() > 0.1, "propulsion builds inertial velocity")
_expect(
composition.latest_update().snapshot.battery.remaining_kwh < starting_battery,
@@ -294,6 +390,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 +455,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: