Validate POC A and POC B foundations

This commit is contained in:
chindit committed 2026-08-07 13:31:21 +02:00
1 parent 44fd4b4b43
commit c38e09a663
47 files changed
+4990 -35

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.godot/
.DS_Store
*.tmp
*.log
builds/
+64
View File
@@ -0,0 +1,64 @@
# Ransom's Wake — Repository rules
These rules apply to the whole repository. They are mandatory for human and AI
contributors.
## Read before changing code
1. Read `docs/ENGINEERING_GUARDRAILS.md`.
2. Read the relevant technical baseline under `docs/`.
3. Check `docs/adr/` for decisions affecting the change.
## Prototype boundary
- `src/` and `scenes/` contain the disposable POC A/B implementation.
- They may be changed only for an explicitly requested prototype correction.
- Production code must not import, extend, copy, or silently promote code from
`src/` or `scenes/`.
- Reuse a validated behavior by rewriting it behind a production contract and
by linking the relevant baseline in the change description.
## Production boundary
New production code belongs under `game/` and follows these dependency rules:
- `game/domain/`: pure rules and typed data; depends on no other game layer.
- `game/application/`: use cases and ports; may depend only on `domain`.
- `game/world/`: Godot actors and physics adapters; may depend on `application`
and `domain`.
- `game/presentation/`: HUD and rendering; may depend on `application` and
read-only domain data.
- `game/infrastructure/`: saves, audio, platform and input adapters; may depend
on `application` and `domain`.
- `game/composition/`: scene assembly; this is the only layer allowed to wire
all other layers together.
Do not create a generic `utils`, `helpers`, `common`, or `shared` dumping ground.
Reusable behavior must have one named owner in the layer responsible for it.
## Implementation rules
- One authoritative implementation per concept.
- Use typed objects or resources across production boundaries, not anonymous
dictionaries with implicit keys.
- UI and renderers never calculate gameplay rules or mutate world state
directly; they issue application commands and consume snapshots/events.
- Domain code does not access `Input`, the scene tree, rendering, audio, files,
clocks, or random global state.
- Randomness and time-dependent behavior must be injectable and deterministic
in tests.
- New autoloads, cross-layer dependencies, save-format changes, or exceptions to
these rules require an ADR in `docs/adr/`.
- A bug fix requires a regression test unless the behavior cannot reasonably be
automated; document that exception.
## Required verification
Run this command before declaring a code change complete:
```sh
bash scripts/quality_gate.sh
```
Do not weaken, skip, or delete a failing check to make a change pass. Fix the
cause or record an explicit architectural decision.
+53
View File
@@ -0,0 +1,53 @@
# Contributing to Ransom's Wake
## Definition of ready
Before implementation, a change must identify:
- the player-visible outcome;
- the owning production layer;
- the authoritative source of state;
- the behavior that will prove completion;
- any save, input, accessibility or performance consequence.
If ownership is unclear, resolve it before writing code. If a change introduces
a new dependency direction or global service, write an ADR first.
## Definition of done
A change is complete only when:
- no second implementation of the same rule exists;
- public boundaries use explicit typed data;
- unit tests cover new domain behavior;
- integration tests cover changed scene or service wiring;
- every corrected bug has a regression test, or a documented reason why not;
- error and empty states are handled;
- relevant documentation and baselines are updated;
- `bash scripts/quality_gate.sh` passes from the repository root.
Visual behavior additionally requires a check at 1280 × 720 and at the smallest
supported in-game presentation size. Human readability cannot be inferred from
mesh counts or screenshots alone, so playtest observations remain evidence.
## Change discipline
- Keep one conceptual change per commit.
- Prefer deletion or extraction over parallel implementations.
- Do not rename and redesign unrelated systems in the same change.
- Never modify generated `.godot/` content.
- Keep assets separate from gameplay state and reference them through resources.
- Treat warnings, leaked nodes and nondeterministic tests as defects to triage,
even if the process returns exit code zero.
## Review checklist
- Is there exactly one owner for each new rule?
- Does dependency flow match `docs/ENGINEERING_GUARDRAILS.md`?
- Can the domain behavior run without a scene tree?
- Can UI code only observe or issue commands?
- Are time, randomness and external I/O controllable in tests?
- Is failure behavior tested?
- Is a prototype implementation being copied instead of translated?
- Would a future save migration be required?
- Does the quality gate pass?
+88
View File
@@ -0,0 +1,88 @@
# Ransom’s Wake — Prototypes
Le POC A validé couvre le pilotage et la survie. Le POC B validé couvre un sonar
manuel reconstruisant progressivement une carte bathymétrique 3D continue.
## POC A — périmètre validé
- Pilotage 3D inertiel depuis l'habitacle, derrière un hublot circulaire.
- Oxygène consommé en continu.
- Batterie consommée uniquement sous propulsion.
- Profondeur nominale, zone critique et rupture de coque.
- Dégâts de collision.
- Alarmes visuelles et sonores.
- Estimation du coût de retour.
- Éjection volontaire.
- Parcours whitebox en trois étapes.
Le sonar, les épaves, le commerce, la narration et les sauvegardes de campagne sont volontairement absents.
## POC B — périmètre validé
- Zone de calibration avec fond sculpté, passage et objet métallique.
- Scan manuel de 2,5 secondes, révélé du proche vers le lointain.
- Surface bathymétrique continue, ombrée et colorée selon l'altitude.
- Portée de 95 mètres sur une ouverture de 76°.
- Petit écran de cockpit et grande vue partageant le même rendu.
- Ancien scan remplacé progressivement par le nouveau.
## Prérequis
- Godot 4.7.1.
## Lancer
Depuis le dossier du projet :
godot --editor --path .
Ou lancer directement le POC :
godot --path .
Le projet lance désormais le POC B. Pour relancer le POC A validé :
godot --path . --scene res://scenes/poc_a.tscn
## Contrôles
| Touche | Action |
|---|---|
| W / S | Avancer / reculer |
| A / D | Tourner à gauche / droite |
| Espace / Ctrl | Monter / descendre |
| Maj | Forte poussée |
| Souris | Caméra et orientation |
| X maintenu | Éjection volontaire |
| F9 | Forcer un état critique pour tester les alarmes |
| Échap | Libérer ou capturer la souris |
| Entrée | Recommencer après succès ou perte |
| F | Impulsion sonar dans le POC B |
| Tab | Agrandir ou fermer l'écran sonar du POC B |
## Tests
Barrière qualité complète obligatoire :
bash scripts/quality_gate.sh
Elle vérifie les frontières d'architecture, charge tous les scripts et scènes,
puis exécute les tests unitaires et d'intégration.
Commandes individuelles pour diagnostic :
godot --headless --path . --script res://tests/run_tests.gd
Test d'intégration du POC B :
godot --headless --path . --script res://tests/run_poc_b_integration.gd
## Documents
- Plan général : docs/GAME_DESIGN_PLAN.md
- Objectifs et protocole du POC : docs/POC_A.md
- Bilan technique du POC A : docs/POC_A_TECHNICAL_BASELINE.md
- Objectifs et protocole du POC B : docs/POC_B.md
- Bilan technique du POC B : docs/POC_B_TECHNICAL_BASELINE.md
- Barrières techniques de production : docs/ENGINEERING_GUARDRAILS.md
- Décisions d'architecture : docs/adr/
+9
View File
@@ -0,0 +1,9 @@
# Production content
Ce dossier contiendra les ressources de données du jeu : épaves, contrats,
objets, améliorations, marchés, dialogues et sites de plongée.
Une ressource décrit des données et référence des identifiants stables. Elle ne
duplique pas une règle de gameplay et ne dépend pas d'une scène de POC. Chaque
type de ressource recevra un validateur de contrat avant l'ajout de contenu en
volume.
+273
View File
@@ -0,0 +1,273 @@
# Barrières techniques avant la verticale
## Objectif
Cette politique protège la verticale contre trois dérives principales :
1. transformer silencieusement les POC en code de production ;
2. dupliquer une règle dans plusieurs scènes ou interfaces ;
3. découvrir tardivement des erreurs de dépendance, de chargement ou de
régression.
Les contraintes ci-dessous sont volontairement plus strictes que nécessaire
pour un petit prototype. Une verticale engage plusieurs mois de travail ; une
frontière légèrement contraignante maintenant coûte moins cher qu'une
réécriture au milieu de la production.
## 1. Séparation physique entre prototypes et production
```text
src/ POC A et B jetables
scenes/ scènes des POC
game/ futur code de production
domain/ règles pures et données typées
application/ cas d'usage et contrats
world/ acteurs, physique et monde Godot
presentation/ HUD, écrans et rendu
infrastructure/ sauvegarde, audio, plateforme et entrées
composition/ assemblage des scènes et dépendances
content/ définitions de contenu, sans logique dupliquée
tests/
unit/ règles sans scène
integration/ assemblage de nœuds et services
contract/ ressources, sauvegardes et compatibilité
docs/adr/ décisions d'architecture immuables
scripts/ barrières qualité reproductibles
```
Les dossiers `game/` et `content/` seront créés avec la première tranche de la
verticale. Ils ne contiendront aucune copie directe des scripts des POC.
## 2. Direction unique des dépendances
```text
domain <── application <── world
▲ ▲ │
└──────────────┼── presentation
└── infrastructure
composition peut assembler toutes les branches.
```
### Domain
Contient les règles qui doivent rester vraies quel que soit l'écran ou la scène :
ressources, dégâts, intensité sonar, coût d'une amélioration, identification,
inventaire et transitions d'état.
- Classes `RefCounted` ou `Resource` typées.
- Aucun `Node`, `Input`, signal de scène, audio, fichier ou rendu.
- Aucun accès à une horloge ou à un générateur aléatoire global.
- Tests synchrones et déterministes.
### Application
Exprime ce que le joueur ou le jeu peut demander : démarrer un scan, équiper un
module, enregistrer une preuve, terminer une plongée ou vendre un objet.
- Dépend uniquement du domaine.
- Définit des ports abstraits pour sauvegarde, audio, temps ou plateforme.
- Retourne des résultats explicites ; aucune dépendance à un HUD ou un acteur.
### World
Adapte Godot aux cas d'usage : véhicule, collisions, capteurs, épaves et outils.
- Ne possède pas les règles économiques ou de campagne.
- Convertit les événements physiques en commandes d'application.
- Ne référence aucun HUD concret.
### Presentation
Affiche des snapshots et transforme une interaction en commande.
- Ne calcule pas la consommation, les dégâts, la valeur ou la réussite.
- Ne modifie pas directement un acteur ou une ressource de domaine.
- Les shaders et renderers traduisent les données ; ils ne les inventent pas.
### Infrastructure
Implémente les ports externes : sauvegardes, fichiers, audio, entrées, builds et
services de plateforme.
- Ne contient aucune règle de gameplay.
- Les erreurs d'I/O deviennent des résultats contrôlés et testables.
### Composition
Seule couche autorisée à connaître les implémentations concrètes de toutes les
autres. Elle instancie, injecte et connecte. Une scène de composition ne doit pas
devenir un second système de gameplay.
## 3. Une seule source de vérité
Chaque information mutable possède un propriétaire unique.
| Concept | Propriétaire attendu | Consommateurs |
|---|---|---|
| Oxygène, batterie, coque | état de plongée du domaine | HUD, alarmes, sauvegarde |
| Mouvement physique | acteur sous-marin du monde | caméra, télémétrie, sonar |
| Relevé sonar | service/cartographie sonar | renderer, sauvegarde éventuelle |
| Inventaire | agrégat de campagne | HUD, vente, sauvegarde |
| Contrat actif | service de contrats | objectifs, port, sauvegarde |
| Paramètres d'accessibilité | service de réglages | UI, caméra, audio, sonar |
Un consommateur reçoit un snapshot immuable ou un événement. Il ne maintient
pas une copie concurrente de l'état. Un cache doit être explicitement nommé,
invalidé par son propriétaire et vérifié par test.
## 4. Politique anti-duplication
Avant d'ajouter une fonction, rechercher le concept et ses synonymes avec `rg`.
Une similitude ne justifie pas toujours une abstraction. Extraire lorsque deux
implémentations portent la même règle métier ou doivent évoluer ensemble. Garder
deux présentations séparées lorsqu'elles partagent seulement quelques lignes de
mise en page.
Interdictions :
- copier un calcul du domaine dans un HUD ;
- dupliquer des seuils dans plusieurs scripts ;
- créer un dossier générique `utils`, `helpers`, `common` ou `shared` ;
- coder une variante par difficulté avec un second système complet ;
- dupliquer les identifiants d'actions ou les chemins de sauvegarde ;
- faire du prototype une dépendance de production.
Les constantes de réglage deviennent des ressources de configuration validées.
Les identifiants stables deviennent des types ou constantes centralisés dans
leur domaine propriétaire.
## 5. Contrats de données
Les dictionnaires anonymes sont tolérés dans les POC mais interdits aux
frontières de production. Un contrat doit préciser :
- le type de chaque champ ;
- son unité (`m`, `s`, `%`, radians) ;
- le repère spatial utilisé ;
- sa mutabilité ;
- son propriétaire ;
- sa stratégie de version si elle est sauvegardée.
Exemples attendus : `SonarSample`, `SonarScan`, `DiveSnapshot`,
`TelemetrySnapshot`, `WreckDefinition` et `SaveGameVersion`.
Les conversions entre coordonnées monde, véhicule et carte sonar se font dans
un adaptateur nommé et couvert par des tests. Aucune convention implicite de
signe ou d'axe ne doit traverser plusieurs fichiers.
## 6. Tests obligatoires
### Pyramide
1. **Unitaires** : majorité des tests ; domaine pur, rapides et déterministes.
2. **Contrats** : ressources, sérialisation, migrations, configurations et
invariants de contenu.
3. **Intégration** : scènes minimales, signaux, physique et assemblage.
4. **Parcours** : peu nombreux ; une boucle complète avec critères observables.
5. **Visuels et humains** : lisibilité, contraste, mouvement et compréhension.
### Règles
- Tout bug corrigé commence par un test qui échoue lorsque c'est raisonnable.
- Aucun test ne dépend du réseau, de l'heure réelle ou d'un ordre aléatoire.
- Les attentes temporelles utilisent une horloge injectée ou un nombre de ticks
borné, jamais un sommeil arbitraire.
- Une scène d'intégration construit le minimum requis pour le comportement.
- Les tests vérifient des résultats, pas la structure interne d'une méthode.
- Une migration de sauvegarde garde un échantillon de chaque version supportée.
- Le contenu principal reçoit des tests de validation : ID uniques, références
résolues, profondeur possible et absence de blocage narratif.
### Barrière actuelle
`bash scripts/quality_gate.sh` exécute dans cet ordre :
1. règles d'architecture ;
2. chargement de tous les scripts et scènes ;
3. tests unitaires existants ;
4. tests d'intégration du POC B.
La verticale ajoutera ses suites à cette commande unique. Une fonctionnalité ne
peut pas être déclarée terminée si la barrière échoue.
La commande locale est la barrière active dans le dépôt actuel. Avant la
première fusion de code de production sur un dépôt distant, elle devra devenir
un contrôle CI obligatoire avec protection de branche. La CI appellera ce même
script ; elle ne maintiendra pas une seconde liste de tests.
## 7. Taille et responsabilités
Seuils de revue, non objectifs mécaniques :
- au-delà de 350 lignes par fichier, vérifier si plusieurs responsabilités sont
mêlées ;
- au-delà de 40 lignes par fonction, vérifier si des étapes nommées manquent ;
- au-delà de cinq dépendances injectées, vérifier si le composant orchestre trop
de domaines ;
- une scène ne doit avoir qu'une racine de composition identifiable ;
- un signal doit décrire un fait passé, une commande une intention.
Une exception justifiée vaut mieux qu'un découpage artificiel. Une exception
durable doit être documentée dans un ADR.
## 8. Gestion des erreurs et observabilité
- Toute erreur récupérable produit un résultat explicite et un message utile.
- Les erreurs de sauvegarde ne doivent jamais écraser la dernière sauvegarde
valide.
- Les ressources invalides échouent au chargement ou au test de contrat.
- Les logs n'enregistrent aucune donnée personnelle ou secret.
- Les événements importants portent un contexte : mission, site, scan ou version.
- Les fuites de nœuds, avertissements et erreurs Godot sont triés avant fusion.
## 9. Sauvegardes et migrations
- Format versionné dès le premier prototype de verticale sauvegardé.
- Écriture dans un fichier temporaire, validation, puis remplacement atomique.
- Jamais de sérialisation arbitraire de nœuds ou de physique.
- IDs stables plutôt que chemins de scène comme identité métier.
- Migration explicite d'une version à la suivante.
- Sauvegardes de référence anonymes conservées sous `tests/fixtures/`.
## 10. Entrées, accessibilité et localisation
- Le gameplay dépend d'actions abstraites, jamais de touches codées en dur.
- Une seule infrastructure traduit les périphériques en commandes.
- Toute nouvelle action est remappable et documentée.
- Les textes visibles sont identifiables pour la localisation ; aucun texte
métier n'est construit par concaténation fragile.
- Les informations critiques combinent texte, forme, son ou animation avec la
couleur.
- Les options réduisant mouvement, flash et contraste sont consultées par la
présentation, pas recodées dans chaque effet.
## 11. Décisions d'architecture
Un ADR est requis avant :
- un nouvel autoload ;
- une dépendance qui inverse le graphe défini ici ;
- un format de sauvegarde ou une migration ;
- une technologie externe ou un plugin ;
- une exception durable à une barrière automatique ;
- un changement de renderer ou de version Godot pendant un jalon.
Un ADR décrit le contexte, la décision, ses conséquences et les alternatives
rejetées. Il ne doit pas être réécrit pour masquer une ancienne décision ; un
nouvel ADR le remplace explicitement.
## 12. Ordre de mise en production
Avant le premier gameplay de la verticale :
1. créer les couches vides et leur test d'architecture ;
2. définir les premiers contrats typés ;
3. porter une règle pure avec ses tests ;
4. créer une seule scène de composition ;
5. intégrer le véhicule puis le sonar par adaptateurs ;
6. ajouter un parcours end-to-end minimal ;
7. seulement ensuite ajouter contenu, port et économie.
Chaque tranche doit rester jouable et laisser la barrière qualité verte.
+52 -35
View File
@@ -5,16 +5,20 @@
## Statut du document
- Nature : plan directeur de conception et de production
- Version : 0.1
- État : préproduction, aucun code commencé
- Version : 0.3
- État : POC A et POC B validés ; cadrage de la verticale à préparer
- Titre : titre de travail, à valider juridiquement avant toute annonce publique
- Prix commercial : volontairement non défini à ce stade
Les baselines techniques consolidées sont conservées dans
[POC_A_TECHNICAL_BASELINE.md](POC_A_TECHNICAL_BASELINE.md) et
[POC_B_TECHNICAL_BASELINE.md](POC_B_TECHNICAL_BASELINE.md).
## Pitch
Trace a cursed Inca ransom backward through five centuries of Caribbean shipwrecks. Read the sonar, survive the descent, and decide what the deep should return.
Ransom’s Wake est un jeu solo d’exploration sous-marine en 3D stylisée, vu à la troisième personne. Le joueur incarne un ancien technicien offshore devenu récupérateur indépendant dans un archipel caribéen fictif.
Ransom’s Wake est un jeu solo d’exploration sous-marine en 3D stylisée, vu depuis l'habitacle du mini-sous-marin à travers un large hublot circulaire. Le joueur incarne un ancien technicien offshore devenu récupérateur indépendant dans un archipel caribéen fictif.
Chaque dossier consiste à obtenir une piste, préparer un mini-sous-marin, rejoindre un secteur probable, lire le relief et le sonar pour localiser une épave, l’identifier, récupérer ou documenter son contenu, puis remonter avant l’épuisement des ressources. Les bénéfices financent l’accès à de nouvelles profondeurs et à des instruments plus performants.
@@ -29,7 +33,7 @@ Une mission récente apparemment banale révèle progressivement une chaîne de
La singularité du jeu repose sur trois éléments combinés :
1. La recherche locale d’une épave réellement cachée par le relief.
2. Un sonar 2D reconstruisant progressivement une carte 3D incomplète.
2. Un sonar manuel reconstruisant progressivement une carte bathymétrique 3D.
3. Des dossiers historiques couvrant cinq siècles, reliés par une enquête surnaturelle.
Le jeu ne doit pas être présenté comme un simple cycle « trouver du loot, vendre, améliorer ». La découverte et l’identification de l’épave constituent le cœur du plaisir. L’extraction, le commerce et les améliorations servent à mettre cette recherche sous tension et à renouveler ses possibilités.
@@ -146,30 +150,37 @@ La première version ne comprend pas :
Le mini-sous-marin utilise un contrôleur cinématique. Il simule accélération, inertie et courants sans reposer sur une simulation complète de flottabilité.
Le POC A a validé une réponse volontairement lente : les commandes donnent un
retour audiovisuel immédiat, tandis que la vitesse linéaire, la rotation de la
coque et la dérive évoluent progressivement.
Fonctions d’assistance :
- maintien automatique de l’assiette ;
- maintien de profondeur sans commande verticale ;
- arrêt stable lorsque le joueur relâche les commandes ;
- limitation du roulis ;
- caméra anticollision ;
- rapprochement automatique de la caméra dans les cavités ;
- transparence temporaire des obstacles qui masquent le véhicule.
- caméra fixée à un point intérieur de la cabine ;
- regard vertical limité par le champ utile du hublot ;
- réduction indépendante du roulis et des mouvements de caméra pour l'accessibilité.
### 3.2 Contrôles clavier-souris par défaut
- WASD : propulsion avant/arrière et translation latérale.
- Souris : caméra et direction de déplacement.
- W/S : propulsion avant et arrière.
- A/D : gouvernail gauche et droite.
- Souris horizontale : consigne de cap.
- Souris verticale : regard limité dans le hublot.
- Espace/Ctrl : monter et descendre.
- Maj : forte poussée, plus rapide mais moins efficace.
- Alt : regard libre.
- F : impulsion sonar.
- E : interaction contextuelle.
- 1 à 4 : sélection des modules.
- Tab : carte sonar 3D.
- Tab : agrandir ou fermer l'écran sonar.
- Commande d’éjection maintenue trois secondes : abandon volontaire.
Toutes les actions doivent pouvoir être remappées. Aucun mode première personne n’est prévu.
Toutes les actions doivent pouvoir être remappées. Le POV depuis l'habitacle est
le seul point de vue de pilotage prévu ; aucune caméra extérieure jouable n'est
nécessaire à la production.
---
@@ -298,13 +309,14 @@ Les sauvegardes de port protègent contre les bugs, pas contre les conséquences
Le sonar est le principal risque technique et doit être validé avant la production de la campagne.
1. Le joueur envoie une impulsion orientée.
2. L’écran 2D affiche distance, angle, intensité et continuité des échos.
3. Plusieurs angles sont nécessaires pour comprendre la forme.
4. Chaque impact observé ajoute un point à une carte 3D.
5. Les points sont dédupliqués et persistent entre les plongées.
6. Les matériaux influencent la réponse.
7. La carte ne révèle jamais un volume non observé.
1. Le joueur envoie une impulsion orientée sous le nez du sous-marin.
2. Le relief est révélé du proche vers le lointain pendant 2,5 secondes.
3. Les retours voisins forment une maquette bathymétrique 3D continue locale.
4. Les ruptures de profondeur restent visibles comme des ouvertures possibles.
5. Un seul scan antérieur subsiste pendant le remplacement par le suivant.
6. Le petit écran et la vue agrandie montrent exactement le même relief.
7. Les matériaux influencent la couleur et l'intensité de la réponse.
8. Aucune géométrie non observée n'est inventée.
### 7.2 Matériaux sonar
@@ -318,9 +330,9 @@ Le sonar est le principal risque technique et doit être validé avant la produc
### 7.3 Progression sonar
- Sonar I : courte portée, contours grossiers.
- Sonar II : portée et densité supérieures, meilleure détection des cavités.
- Sonar III : reconstruction stable et détails plus fins.
- Sonar I : courte portée, relief grossier et impulsions manuelles.
- Sonar II : portée et densité supérieures, remplacement plus rapide.
- Sonar III : balayage automatique et reconstruction plus stable.
Modules de capteur :
@@ -779,9 +791,9 @@ Aucun personnage 3D marchable.
### 20.1 Pile
- Godot 4.6 stable.
- Godot 4.7.1 stable.
- GDScript.
- Renderer Forward+.
- Renderer Forward+ visé pour la production ; GL Compatibility utilisé par le POC A pour faciliter les premiers tests multiplateformes.
- Blender.
- Export glTF 2.0.
- Git.
@@ -814,13 +826,17 @@ La version mineure du moteur est figée pendant chaque jalon. Toute mise à jour
### 20.4 Sonar technique
- Impulsions via faisceaux de rayons physiques bornés.
- Matériaux annotés avec réflectivité, rugosité et catégorie.
- Bruit déterministe selon distance, angle, sonar et difficulté.
- Affichage 2D distance/azimut.
- Carte 3D par points dédupliqués dans une grille spatiale.
- Sauvegarde compacte des points.
- Aucun scan GPU ou reconstruction dynamique de mesh pour la première version.
- Grille bathymétrique structurée par ligne et colonne, avec position, normale,
intensité, matériau et élévation normalisée.
- Surface continue par `ArrayMesh` indexé, normales lissées et palette d'altitude.
- Caméra sonar oblique et fixe, cap vers le haut.
- Un scan courant et un scan précédent en fondu au maximum.
- Une texture de rendu partagée entre l'écran compact et la vue agrandie.
- Passe frontale complémentaire pour les anomalies matérielles.
- Représentation hybride à concevoir pour les grottes et surplombs, qu'une carte
à hauteur unique ne peut pas décrire seule.
- Bruit déterministe selon distance, angle, sonar et difficulté après validation
de la lecture fondamentale.
### 20.5 Persistance
@@ -906,7 +922,7 @@ La version mineure du moteur est figée pendant chaque jalon. Toute mise à jour
|---|---:|---|
| Préparation | 2 à 4 semaines | Outils, dépôt, builds vides sur trois OS |
| POC A jetable | 6 à 8 semaines | Pilotage, caméra, jauges, profondeur, éjection |
| POC B jetable | 8 à 12 semaines | Sonar hybride, grotte whitebox, carte 3D |
| POC B jetable | 8 à 12 semaines | Sonar de relief, lecture d'ouverture, anomalie |
| Verticale | 4 à 6 mois | Une épave, port, contrat, vente, amélioration |
| Acte I | 7 à 10 mois | Quatre épaves, économie, difficulté, bilingue |
| Démo et préparation EA | 2 à 3 mois | Démo 30 à 45 minutes, QA Windows/Linux |
@@ -921,7 +937,8 @@ Total attendu : 36 à 48 mois à environ 15 heures par semaine.
## 24. Portes de décision
- Si le sonar n’est pas amusant après le POC B, tester une reconstruction plus automatique.
- Si les grottes restent illisibles dans la verticale, tester une reconstruction
locale hybride sans abandonner la bathymétrie extérieure validée par le POC B.
- Si la recherche reste incompréhensible, réduire l’incertitude des coordonnées.
- Si la seconde épave dépasse huit semaines, réduire le jeu à huit épaves et deux zones.
- Si le multiplateforme consomme plus de 20 % du temps, repousser la QA Mac.
@@ -973,5 +990,5 @@ Total attendu : 36 à 48 mois à environ 15 heures par semaine.
- Accès à 5 à 10 testeurs.
- Deux POC jetables avant la verticale de production.
- Décisions de réduction prises aux jalons.
- Aucun code ne doit être commencé avant validation de ce plan comme base de travail.
- Aucun code de verticale ne doit être commencé avant validation de son cadrage
technique et de ses contrats de données.
+106
View File
@@ -0,0 +1,106 @@
# POC A — Pilotage et survie
**État : validé.** Le principe du pilotage sous contrainte justifie la poursuite
du projet. Les réglages de réactivité et le rendu des projecteurs restent à
reprendre pendant la verticale de production.
## Question à valider
Piloter le mini-sous-marin sous contrainte est-il agréable, lisible et suffisamment tendu pour justifier la suite du projet ?
## Ce que le prototype teste
- Compréhension immédiate des déplacements.
- Lisibilité du POV derrière un hublot circulaire.
- Différence entre oxygène et batterie de propulsion.
- Lecture de la profondeur nominale et critique.
- Réaction aux alarmes.
- Capacité à conserver une marge de retour.
- Acceptation de l’éjection et de la perte du véhicule.
## Parcours
1. Rejoindre une balise à faible profondeur.
2. Descendre vers une balise placée à 66 m, au-delà de la profondeur nominale de 60 m.
3. Choisir combien de temps rester dans la zone dangereuse.
4. Revenir au point de récupération.
Le raccourci F9 place oxygène, batterie et coque près de leurs seuils critiques pour tester rapidement les alarmes et l’éjection.
## Protocole de playtest
Chaque session est observée sans expliquer les commandes au-delà de ce qui est affiché.
Après la session, demander :
1. Qu’est-ce qui consommait la batterie ?
2. À quel moment as-tu compris que la profondeur devenait dangereuse ?
3. Savais-tu combien coûterait le retour ?
4. La caméra t’a-t-elle gêné ?
5. As-tu eu l’impression de contrôler un véhicule ou une caméra volante ?
6. Les alarmes étaient-elles lisibles sans être agaçantes ?
7. Voudrais-tu effectuer une seconde plongée ?
## Test ciblé — navigation depuis le hublot
Cette itération teste la lecture du déplacement dans une zone plus dense, sans
ajouter de sonar ni d'indicateur de direction.
1. Rejoindre la première balise en passant entre les formations proches.
2. S'arrêter avant la balise, effectuer un demi-tour, puis la retrouver.
3. Descendre vers la seconde balise en utilisant le relief et les particules pour estimer la vitesse.
4. Déclencher l'état critique avec F9 et amorcer le retour sans collision.
Pendant la session, relever :
- le premier moment où le joueur anticipe correctement sa distance d'arrêt ;
- le nombre de collisions dans le premier couloir ;
- le temps nécessaire pour retrouver la balise après le demi-tour ;
- les moments où le joueur dit ne plus connaître son orientation ;
- les obstacles découverts trop tard malgré les phares.
Le test est concluant si le joueur retrouve la balise en moins de 15 secondes,
termine le premier couloir sans aide et sait expliquer quels indices lui ont
permis d'estimer sa vitesse et sa profondeur.
## Critères de passage
- 80 % des testeurs terminent le parcours sans aide orale.
- Aucun testeur ne confond oxygène et batterie après la session.
- Les trois seuils d’alarme sont perçus.
- La caméra n’est pas citée comme obstacle majeur par plus de 20 % des testeurs.
- Au moins quatre testeurs sur cinq souhaitent effectuer une autre plongée.
- Aucun bug bloquant sur Windows, Linux ou macOS.
## Critères de reprise
- Si le déplacement ressemble à une caméra volante, augmenter inertie, inclinaison visuelle et retours moteur.
- Si le POV gêne dans les passages serrés, ajuster le FOV, le roulis transmis et les limites du regard.
- Si la batterie est incomprise, renommer explicitement la jauge « propulsion » et renforcer le feedback sous poussée.
- Si la profondeur tue trop brutalement, allonger la zone de grâce.
- Si les alarmes fatiguent, espacer leur répétition sans réduire leur multimodalité.
## Non-objectifs
- Qualité graphique finale.
- Sonar.
- Recherche d’épave.
- Économie.
- Assurance persistante.
- Marchés.
- Dialogue.
- Sauvegarde.
- Compatibilité manette certifiée.
## Décisions issues du POC
- POV unique depuis l'habitacle, derrière un large hublot circulaire.
- Caméra physiquement solidaire de la cabine.
- W/S pour avancer et reculer ; A/D pour gouverner.
- Réponse audiovisuelle immédiate, mouvement linéaire et angulaire inertiel.
- Relief proche, particules et balises intermédiaires nécessaires à l'orientation.
- Projecteurs indispensables, mais leur diffusion volumétrique reste un risque
technique à reprendre avec le renderer de production.
Le détail des valeurs, limites et solutions provisoires est consigné dans
[POC_A_TECHNICAL_BASELINE.md](POC_A_TECHNICAL_BASELINE.md).
+287
View File
@@ -0,0 +1,287 @@
# POC A — Bilan technique consolidé
## Statut
- Date de consolidation : 6 août 2026.
- État : POC validé et figé comme référence de conception.
- Nature : prototype jetable sous Godot 4.7.1, renderer GL Compatibility.
- Suite : POC B consacré au sonar de relief facetté et à sa lisibilité.
Ce document sépare les décisions validées des valeurs de réglage et des
solutions provisoires. Les nombres ci-dessous décrivent la dernière version
testée du POC A ; ils ne constituent pas tous des valeurs de production.
## Résultat du POC
Le principe d'un mini-sous-marin piloté sous contrainte est validé. Le joueur
peut comprendre les ressources, anticiper une profondeur dangereuse, naviguer
avec une inertie perceptible et retrouver un objectif depuis un POV intérieur.
Deux points restent explicitement ouverts :
- la réactivité exacte des commandes doit encore être affinée ;
- les projecteurs et leur diffusion dans l'eau doivent être entièrement repris.
## Décisions de conception validées
### Point de vue
- POV unique depuis l'habitacle.
- Vue extérieure abandonnée comme mode de pilotage de production.
- Large hublot circulaire avec cadre fixe servant de repère spatial.
- Caméra physiquement enfant de la coque et incapable de quitter la cabine.
- Tangage et une part du roulis de la coque transmis à la caméra.
- Regard vertical limité par le champ utile du hublot.
### Commandes et mouvement
- W/S : avancer et reculer.
- A/D : gouverner à gauche et à droite, sans translation latérale commandée.
- Souris horizontale : modifier la consigne de cap.
- Souris verticale : regarder dans les limites du hublot.
- Espace/Ctrl : monter et descendre.
- Maj : forte poussée.
- Le retour à la commande doit être immédiat, mais la réponse physique retardée.
- La coque conserve une dérive et une vitesse angulaire progressives.
- Le sous-marin ne doit pas tourner presque sur place.
Le POC ne contient pas encore le retour moteur audio requis en production. Le
retour immédiat actuel repose principalement sur l'inclinaison du cockpit, le
mouvement des faisceaux et les instruments.
### Orientation et environnement
- Un espace vide ne permet pas de lire correctement vitesse ou profondeur.
- Relief proche, particules suspendues et silhouettes distinctes sont requis.
- Une balise principale distante ne suffit pas après un changement d'objectif.
- Des chaînes de petits feux colorés peuvent indiquer un trajet sans flèche HUD.
- La visibilité doit conserver des silhouettes au-delà de la portée utile des
phares ; une obscurité entièrement noire désoriente sans enrichir la recherche.
### Survie et interface
- L'oxygène diminue continuellement.
- La batterie représente explicitement la propulsion et ne diminue que sous
poussée ou gouvernail actif.
- La batterie vide n'entraîne pas une mort instantanée.
- La profondeur dangereuse, les dégâts de collision et la marge de retour sont
toujours annoncés.
- Les alarmes utilisent texte, couleur et son ; aucune information critique ne
doit dépendre uniquement de la couleur en production.
- L'éjection volontaire nécessite une commande maintenue trois secondes.
## Baseline de pilotage actuelle
### Translation
| Paramètre | Valeur du POC |
|---|---:|
| Vitesse de croisière | 6,0 m/s |
| Forte poussée | 9,5 m/s |
| Accélération | 2,0 m/s² |
| Freinage | 1,35 m/s² |
| Résistance latérale | 2,2 m/s² |
### Rotation
| Paramètre | Valeur du POC |
|---|---:|
| Vitesse de consigne clavier | 55°/s |
| Vitesse angulaire maximale | 45°/s |
| Accélération angulaire | 30°/s² |
| Freinage angulaire | 24°/s² |
| Sensibilité souris | 0,0025 rad/pixel |
La consigne de cap et la rotation réelle sont distinctes. La vitesse angulaire
se rapproche progressivement d'une vitesse cible calculée depuis l'erreur de
cap, puis se résorbe sans téléporter la coque sur la consigne.
### Mesure de référence
Avec W+D maintenus depuis l'arrêt :
| Temps | Rotation cumulée | Vitesse linéaire |
|---|---:|---:|
| 0,5 s | environ 4° | 1,00 m/s |
| 2,0 s | environ 57° | 3,96 m/s |
| 4,0 s | environ 147° | 5,57 m/s |
Cette réponse a été jugée correcte dans son principe, mais encore perfectible
dans son réglage fin.
## Baseline caméra et hublot
| Paramètre | Valeur du POC |
|---|---:|
| FOV vertical Godot (`KEEP_HEIGHT`) | 82° |
| Position locale caméra | (0,00 ; 0,52 ; -0,48) m |
| Inclinaison initiale | -0,04 rad |
| Regard vertical minimal | -0,30 rad, environ -17° |
| Regard vertical maximal | 0,20 rad, environ 11° |
| Tangage visuel maximal de la coque | 18° |
| Roulis visuel maximal de la coque | 16° |
| Roulis transmis à la caméra | 55 % |
Le hublot est un masque circulaire en shader `canvas_item`, corrigé selon le
ratio de l'écran. Son rayon normalisé vertical est de 0,435. Le cadre et le
verre sont provisoires ; seule la forme circulaire et la présence d'un repère
fixe sont validées.
Le contrôleur contient encore une branche de caméra extérieure héritée des
premières itérations. Elle n'est pas validée et doit être supprimée lors de la
réécriture de production.
## Baseline des projecteurs
| Paramètre | Valeur du POC |
|---|---:|
| Nombre | 2 |
| Portée SpotLight3D | 58 m |
| Énergie | 38 |
| Angle | 24° |
| Divergence horizontale | ±6° |
| Inclinaison verticale | -3° |
| Ombres | désactivées |
GL Compatibility n'affiche pas le volume lumineux dans le brouillard. Le POC
combine donc :
1. deux `SpotLight3D` pour éclairer les surfaces ;
2. des lentilles émissives en vue extérieure ;
3. six plans translucides par phare en POV pour simuler la diffusion dans l'eau ;
4. un cône translucide différent pour l'ancienne vue extérieure.
Les six plans POV se trouvent à 4, 7,5, 12, 18, 25 et 32 mètres. Cette solution
est un bricolage jetable : elle produit des halos, dépend fortement du point de
vue et ne doit pas être portée telle quelle en production.
Les ombres des projecteurs ont été désactivées après apparition de bandes de
shadow mapping sur les volumes whitebox. Le renderer de production visé reste
Forward+ ; le traitement final devra comparer brouillard volumétrique, particules
éclairées et solution écran, avec des limites strictes de performance.
## Baseline de visibilité et navigation
### Environnement
| Paramètre | Valeur du POC |
|---|---:|
| Couleur de fond | `#031823` |
| Énergie ambiante | 0,46 |
| Couleur ambiante | `#2b6575` |
| Densité du brouillard | 0,013 |
| Énergie lumineuse du brouillard | 0,52 |
| Énergie directionnelle | 0,35 |
Le parcours comprend sept formations rapprochées supplémentaires et 520
particules suspendues. Les particules ont une durée de vie de 14 secondes et une
vitesse initiale comprise entre 0,025 et 0,11 m/s. Ces valeurs servent uniquement
à rendre parallaxe et vitesse perceptibles.
### Balises
| Étape | Position | Rayon |
|---|---|---:|
| Navigation initiale | (0 ; -22 ; -58) | 8 m |
| Plongée critique | (38 ; -66 ; -128) | 4 m |
| Récupération | (0 ; -12 ; 12) | 8 m |
Après la première balise, trois feux orange matérialisent une descente graduelle
vers la seconde. Après la seconde, quatre feux verts matérialisent le retour.
Les feux intermédiaires ont un rayon de 0,38 m, une émission de 1,5 et une lumière
omnidirectionnelle de portée 7 m et d'énergie 1,8.
Les positions exactes restent des données whitebox et ne constituent pas une
règle de level design. La règle validée est qu'un changement d'objectif doit
fournir immédiatement un premier repère visible dans le hublot.
## Baseline de survie
| Paramètre | Valeur du POC |
|---|---:|
| Oxygène | 600 s, soit 10 min |
| Batterie | 100 % |
| Intégrité | 100 % |
| Consommation oxygène | 1 s par seconde |
| Consommation batterie à pleine poussée | 0,60 %/s |
| Profondeur nominale | 60 m |
| Profondeur critique | 72 m |
| Profondeur d'écrasement | 78 m |
Entre 60 et 72 mètres, les dégâts de pression progressent de 0,15 à 5 points
par seconde selon une courbe quadratique. Entre 72 et 78 mètres, ils progressent
de 5 à 18 points par seconde. À partir de 78 mètres, la coque perd 25 points par
seconde.
Une collision commence à infliger des dégâts au-delà d'une composante d'impact
de 3,5 m/s. Les dégâts valent `(impact - 3,5) × 4`, avec un délai de 0,45 seconde
avant un nouveau dégât de collision.
L'estimation de retour utilise la distance divisée par 6 m/s, multipliée par la
consommation à pleine poussée, plus 0,035 point de batterie par mètre de remontée.
Elle reste une approximation volontairement simple.
## Alarmes et états terminaux
| Niveau | Seuil de ressource | Répétition sonore |
|---|---:|---:|
| Attention | ≤ 30 % | un signal à l'entrée du niveau |
| Retour | ≤ 15 % | 2,5 s |
| Critique | ≤ 5 % | 1,25 s |
| Vide/défaillance | 0 % | 0,75 s |
Pour la profondeur, l'attention commence à 51 m, le retour au-delà de 60 m, le
critique à 72 m et la défaillance à 78 m.
La coque à zéro et l'oxygène à zéro terminent la plongée. La batterie à zéro ne
la termine pas immédiatement. F9 force oxygène à 30 secondes, batterie à 12 %
et coque à 28 % afin de tester rapidement les alarmes.
## Ce qui est validé et ce qui ne l'est pas
### Validé
- POV intérieur et hublot circulaire.
- Caméra fixée dans la cabine.
- Mapping W/S, A/D, Espace/Ctrl et Maj.
- Séparation entre commande immédiate et réponse physique inertielle.
- Séparation oxygène, propulsion et intégrité.
- Profondeur nominale lisible et conséquences progressives.
- Besoin de relief, particules et repères après changement d'objectif.
- Principe des alarmes et de l'éjection volontaire.
### Provisoire
- Toutes les valeurs numériques de pilotage.
- Position exacte de la caméra et FOV à 82°.
- Intensité du roulis et du tangage.
- HUD et cadre du hublot.
- Géométrie, couleurs et densité du parcours.
- Estimation de coût du retour.
- Sons synthétiques des alarmes.
### À reprendre
- Rendu et puissance perçue des projecteurs.
- Retour audio immédiat du moteur, des propulseurs et du gouvernail.
- Réglage fin de la réactivité des commandes.
- Accessibilité du roulis, du mouvement caméra et du contraste.
- Vérification manette et remappage complet.
- Migration du rendu vers Forward+.
## Vérification technique
- 13 tests headless passent : 9 pour la survie et 4 pour la fondation sonar.
- La scène POC A charge sans erreur de script.
- Les itérations du hublot, des projecteurs et des guides ont été vérifiées sur
un rendu OpenGL Compatibility 1280 × 720 sur Apple M1.
- Les tests ne couvrent pas encore l'interface, le shader du hublot, les
particules, le mouvement complet du véhicule ni le rendu des projecteurs.
## Contrat de reprise pour la production
Le code du POC A peut être réutilisé pour apprendre ou accélérer un autre POC,
mais ne doit pas devenir silencieusement le contrôleur de production. Une
réécriture devra préserver les décisions validées, repartir des valeurs de cette
baseline comme premier réglage et remplacer explicitement chaque solution
provisoire.
+104
View File
@@ -0,0 +1,104 @@
# POC B — Sonar bathymétrique
**État : principe validé et figé comme référence de conception.**
Le bilan consolidé, les valeurs techniques et les limites à conserver lors de
la reprise sont consignés dans `docs/POC_B_TECHNICAL_BASELINE.md`.
Les trois premiers langages visuels ont échoué au test de lisibilité : points
sur un écran distance/azimut, surfaces facettées sans épaisseur, puis volumes en
voxels. Ils permettaient de constater des retours, mais pas de reconnaître
immédiatement le fond marin ni de choisir un chemin.
La version actuelle adopte le langage d'un modèle numérique de terrain : une
surface continue observée en perspective, dont la couleur représente l'altitude
et dont l'éclairage rend les pentes visibles.
## Question à valider
Une carte bathymétrique 3D continue, révélée du proche vers le lointain,
permet-elle de reconnaître immédiatement les creux, les hauteurs, un monticule
ouvert et la direction praticable ?
## Décisions de conception conservées
- Sonar installé sous le nez et couvrant environ 76° devant le sous-marin.
- Impulsion manuelle avec F ; un scan complet dure 2,5 secondes.
- Révélation progressive du proche vers le lointain.
- Petite fenêtre toujours visible, cliquable pour ouvrir la grande vue ; Tab est
conservé comme raccourci.
- La simulation et le pilotage continuent lorsque la grande vue est ouverte.
- Caméra sonar oblique et stable, cap du sous-marin vers le haut de l'écran.
- La carte tourne avec le cap du sous-marin.
- Un seul ancien relevé est visible pendant son remplacement, puis disparaît.
- Aucun nom, cadre ou flèche ne révèle automatiquement une ouverture.
- Le scan automatique reste une amélioration future.
## Nouveau langage visuel
- Les échantillons sont organisés en grille régulière devant le véhicule.
- Les cellules voisines forment un seul maillage continu ; aucun point flottant,
cube ou voxel n'est affiché.
- Bleu foncé : profondeur ; cyan/vert : élévation moyenne ; jaune/rouge : sommet.
- L'éclairage directionnel et les normales lissées rendent les pentes lisibles.
- Une jupe sombre entoure le relevé pour l'ancrer comme une maquette de terrain.
- Noir : zone inconnue ou hors de la couverture du scan.
- Orange : matériau anormal ; une balise lumineuse renforce le retour métallique.
- Une maquette blanche du sous-marin reste en bas de la carte.
## Scène de calibration
La scène cubique précédente est remplacée par un fond marin réellement sculpté :
1. Un sol légèrement incliné et irrégulier.
2. Deux reliefs arrondis de tailles différentes dans la partie proche.
3. Deux hautes crêtes formant un monticule en U.
4. Un chenal central bas qui représente l'ouverture lisible dans une carte
d'altitude.
5. Un objet métallique placé dans ce chenal.
Une carte d'altitude ne peut pas représenter un plafond au-dessus d'un vide. Une
future grotte complète devra donc combiner la bathymétrie extérieure avec une
reconstruction locale de ses surfaces intérieures visibles.
## Parcours de test
1. Déclencher un scan avec F depuis la position initiale.
2. Observer la surface avancer vers le lointain pendant 2,5 secondes.
3. Identifier les reliefs proches, les deux crêtes et le chenal central.
4. Choisir une direction de déplacement sans aide textuelle.
5. Repérer l'anomalie orange.
6. Tourner le sous-marin et vérifier que la lecture reste orientée avec le cap.
7. Cliquer sur le petit écran, ou utiliser Tab, pour ouvrir la grande vue.
8. Déclencher un second scan et observer le remplacement progressif du premier.
## Critères de passage
- Le joueur décrit spontanément une carte de relief ou un fond marin.
- Les creux et les hauteurs sont distingués sans explication de la palette.
- Le chenal entre les crêtes est identifié comme un passage possible.
- Le joueur comprend où se trouve son sous-marin et vers où il regarde.
- Le petit écran suffit à lire la silhouette générale du terrain.
- L'ancien scan ne gêne pas la lecture pendant son remplacement.
## Base technique actuelle
- Grille bathymétrique de 49 × 31 échantillons, de 5 à 95 mètres.
- Projection horizontale de 76° alignée sur le cap au moment du scan.
- Chaque cellule conserve ligne, colonne, position, normale, intensité, matériau,
temps de retour et élévation normalisée.
- Génération CPU d'un `ArrayMesh` indexé avec normales lissées et couleurs par
sommet, rendu dans un `SubViewport` 960 × 540.
- Une seconde passe frontale cherche les matériaux anormaux sans ajouter de
nuage de points au rendu.
- Les deux écrans réutilisent la même `ViewportTexture`.
- Deux surfaces au maximum : scan courant et scan précédent en fondu.
## Non-objectifs de cette version
- Carte persistante ou accumulation illimitée de relevés.
- Véritable reconstruction volumétrique de grottes et surplombs.
- Classification automatique des ouvertures.
- Courbes de niveau détaillées ; elles restent une option ultérieure.
- Bruit, faux retours et interférences acoustiques.
- Améliorations de portée, résolution ou balayage automatique.
+430
View File
@@ -0,0 +1,430 @@
# POC B — Bilan technique consolidé du sonar bathymétrique
## Statut
- Date de consolidation : 7 août 2026.
- État : direction validée et POC figé comme référence de conception.
- Nature : prototype jetable sous Godot 4.7.1, renderer GL Compatibility.
- Dépendance : reprend le véhicule, le POV et les commandes validés dans le POC A.
- Conclusion : la représentation bathymétrique 3D est retenue ; le système n'est
pas considéré comme finalisé ni prêt pour la production.
Le joueur a jugé la dernière version « vraiment mieux » et sa direction
« bien meilleure », tout en signalant qu'il reste des raffinements. Cette
validation porte sur le langage visuel et la boucle d'utilisation, pas sur tous
les réglages, la fidélité acoustique ou la qualité graphique définitive.
## Résultat du POC
Le sonar devient compréhensible lorsqu'il représente le fond marin comme un
modèle numérique de terrain continu observé en perspective. Le joueur peut lire
les creux et les hauteurs, identifier un passage et distinguer une anomalie sans
interpréter un nuage de points abstrait.
La boucle validée est la suivante :
1. Le joueur déclenche manuellement une impulsion avec F.
2. La carte se révèle pendant 2,5 secondes, du proche vers le lointain.
3. La couleur code l'élévation et l'ombrage montre les pentes.
4. Une anomalie matérielle est renforcée en orange.
5. Le joueur peut consulter le résultat dans un petit écran ou l'agrandir.
6. Un nouveau scan remplace progressivement le précédent.
## Historique des essais et leçons
### Essai 1 — distance, azimut et points
Le premier système montrait une progression de retours ponctuels et des
distances. Le joueur comprenait la notion de distance, mais ne pouvait ni
reconnaître le relief ni transformer les points en itinéraire. L'image devenait
encore plus déroutante lorsque son orientation ne suivait pas clairement le cap.
Leçon : une donnée techniquement interprétable n'est pas nécessairement une
information navigable. La rotation de la représentation avec le véhicule est
indispensable, mais ne suffit pas à rendre des points lisibles.
### Essai 2 — surfaces facettées
Les impacts ont ensuite été reliés en plaques. Les fragments paraissaient
flotter et le joueur ne reconnaissait qu'une vague forme en T.
Leçon : relier des échos ne produit pas automatiquement un terrain. Il faut une
surface cohérente, un référentiel stable et une lecture explicite de la hauteur.
### Essai 3 — volumes voxelisés
Les plaques ont été épaissies et fusionnées en voxels de trois mètres. Le relief
avait davantage de masse, mais les blocs et leurs ruptures dominaient toujours
la lecture. La scène restait illisible.
Leçon : l'épaisseur ne corrige pas une mauvaise grammaire visuelle. Points,
facettes et voxels sont abandonnés pour l'affichage principal.
### Essai 4 — modèle bathymétrique continu
La dernière version s'inspire des modèles numériques d'élévation : grille
régulière, surface continue, caméra oblique, palette d'altitude et relief
ombré. La scène cubique a également été remplacée par un fond sculpté. Après un
premier test concluant, la portée a été augmentée et la caisse métallique rendue
plus visible.
Leçon : la lisibilité vient de la combinaison de trois indices redondants :
silhouette continue, couleur d'altitude et ombrage. Aucun de ces indices ne doit
porter seul toute l'information.
## Décisions de conception validées
### Déclenchement et temporalité
- Le scan est une action manuelle sur F.
- Le résultat ne doit jamais être immédiat.
- Les retours proches apparaissent avant les retours lointains.
- La durée de référence est de 2,5 secondes pour 95 mètres.
- Un délai technique de 0,35 seconde suit un scan avant la prochaine impulsion.
- Le scan automatique est réservé à une amélioration future.
- Une éventuelle difficulté facile pourra fournir davantage d'assistance, mais
aucune aide automatique n'est requise dans la baseline.
### Présentation
- Le sonar montre une maquette 3D continue du fond devant le sous-marin.
- Le point de vue est oblique et légèrement surélevé, comparable à une carte de
relief plutôt qu'à un écran radar.
- Le sous-marin blanc reste en bas de l'image et son cap pointe vers le haut.
- La caméra ne devient jamais une caméra libre sortant du véhicule.
- Lorsque le sous-marin tourne, la vue sonar tourne avec lui.
- Noir signifie inconnu ou hors de la couverture.
- Bleu signifie bas/profond ; cyan et vert signifient relief intermédiaire ;
jaune et rouge signifient haut.
- Orange signale un matériau anormal. Dans le POC, il s'agit d'une caisse.
- Les ouvertures ne sont ni nommées, ni encadrées, ni indiquées par une flèche.
### Interface
- Un petit écran sonar reste présent dans le cockpit.
- Un clic sur cet écran l'agrandit ; Tab fournit le même basculement.
- La vue agrandie affiche exactement la même texture que la petite.
- Ouvrir la grande vue ne suspend ni la simulation ni les commandes clavier.
- Le véhicule peut donc poursuivre son mouvement pendant la consultation.
- Le statut textuel indique le pourcentage, la fin du scan et la présence d'une
anomalie, sans expliquer automatiquement la géométrie du terrain.
### Mémoire des relevés
- Le POC ne construit pas une carte persistante du monde.
- Un seul relevé précédent est conservé pendant l'arrivée du suivant.
- Cet ancien relevé passe d'environ 52 % d'opacité à l'invisibilité pendant les
2,5 secondes du nouveau scan.
- Il est supprimé lorsque le nouveau relevé est terminé.
## Baseline numérique du capteur
| Paramètre | Valeur actuelle |
|---|---:|
| Portée minimale | 5 m |
| Portée maximale | 95 m |
| Ouverture horizontale | 76° |
| Grille bathymétrique | 49 colonnes × 31 lignes |
| Nombre maximal d'échantillons de terrain | 1 519 |
| Durée de révélation | 2,5 s |
| Premier retour planifié | 4 % du scan, soit environ 0,10 s |
| Délai après impulsion | 0,35 s |
| Masque physique | couche 1 |
| Taille de cellule du modèle d'échos | 0,65 m |
| Hauteur de départ des sondes de terrain | 48 m au-dessus du capteur |
| Profondeur des sondes de terrain | 92 m sous le capteur |
L'origine logique du capteur se trouve à `(0 ; -0,75 ; -2,2)` mètres dans le
repère local du sous-marin. La grille forme un trapèze qui s'élargit selon
`tan(76° / 2) × distance`.
Pour chaque ligne, le temps de révélation est interpolé de 4 % à 100 % de la
durée totale. Le balayage visible est donc volontairement piloté par la distance
avant et non par une simulation exacte du temps aller-retour acoustique.
## Acquisition bathymétrique actuelle
La passe principale ne projette pas réellement 1 519 rayons depuis le nez. Elle
place une grille horizontale devant le véhicule et lance, pour chaque cellule,
une sonde verticale de haut en bas. Le premier impact fournit :
- la position mondiale ;
- la normale de surface ;
- la distance au capteur ;
- la ligne et la colonne ;
- l'intensité ;
- la catégorie de matériau ;
- le type d'échantillon ;
- le temps de retour visuel ;
- l'élévation normalisée.
Cette technique est un substitut de POC destiné à produire une carte d'altitude
continue et lisible. Elle peut révéler le dessus d'un relief que le capteur réel
ne verrait pas directement. Elle ne doit donc pas être présentée comme une
simulation fidèle d'un sonar multifaisceau.
L'intensité combine :
1. une atténuation linéaire avec la distance ;
2. une réponse d'incidence allant de 0,18 à 1 ;
3. une réflectivité propre au collider ;
4. un bornage entre 0 et 1.
La passe bathymétrique utilise une portée d'intensité interne égale à 1,4 fois
la portée nominale afin de ne pas perdre les coins éloignés du trapèze.
## Détection complémentaire des anomalies
Une seconde passe utilise 31 × 21 rayons réellement projetés depuis le nez :
| Paramètre | Valeur actuelle |
|---|---:|
| Ouverture horizontale | 76° |
| Ouverture verticale | 64° |
| Inclinaison centrale | -18° |
| Portée | 95 m |
Seuls les impacts classés `METAL` sont conservés. Cette passe permet de détecter
une anomalie frontale sans réintroduire le nuage de points dans l'affichage.
Le renderer moyenne actuellement toutes les positions métalliques et affiche un
seul marqueur. Plusieurs objets éloignés seraient donc fusionnés en une position
moyenne incorrecte. La production devra regrouper les retours en amas distincts.
## Normalisation de l'élévation
Pour chaque scan, l'altitude minimale devient 0 et l'altitude maximale devient
1, avec une amplitude minimale forcée à six mètres. Ce choix garantit un bon
contraste même sur un terrain peu accidenté.
Conséquence : une même couleur ne correspond pas nécessairement à la même
profondeur absolue entre deux scans. La palette actuelle communique la forme
locale, pas une mesure bathymétrique universelle. Une production devra choisir
entre contraste adaptatif, échelle absolue ou combinaison des deux avec une
légende chiffrée.
## Reconstruction et rendu
### Maillage
- Les échantillons sont indexés par `(colonne, ligne)`.
- Deux triangles relient chaque groupe complet de quatre cellules adjacentes.
- Les normales des faces sont accumulées puis normalisées par sommet pour lisser
l'éclairage.
- Une absence d'échantillon interrompt le maillage ; les zones inconnues ne sont
pas comblées automatiquement.
- Les deux faces de la surface sont rendues afin que le tangage du véhicule ne
la fasse pas disparaître.
- Une jupe verticale de 4,5 mètres ferme le pourtour et donne à la carte l'aspect
d'une maquette solide.
- Le maillage complet est actuellement reconstruit sur le CPU chaque fois que le
nombre de retours visibles change.
Cette dernière stratégie est acceptable pour le POC mais coûteuse. Un système
de production devra mettre à jour des bandes ou tuiles incrémentales, réutiliser
les buffers ou déléguer une partie de la reconstruction au GPU.
### Palette
| Intervalle normalisé | Dégradé |
|---|---|
| 0 à 0,28 | `#173f91` vers `#168fc1` |
| 0,28 à 0,52 | `#168fc1` vers `#36b979` |
| 0,52 à 0,76 | `#36b979` vers `#d7d93d` |
| 0,76 à 1 | `#d7d93d` vers `#ee5734` |
| Métal | `#ff6b22` |
| Jupe | `#152a32` |
| Inconnu | fond `#020407` |
L'intensité du retour ajoute au maximum 8 % de blanc à la couleur du terrain.
### Viewport et caméra
| Paramètre | Valeur actuelle |
|---|---:|
| `SubViewport` | 960 × 540 |
| Mise à jour | continue |
| Projection | orthographique |
| Taille orthographique | 116 m |
| Plan lointain | 240 m |
| Position caméra relative | 30 m derrière, 65 m au-dessus |
| Point visé relatif | 43 m devant, 8 m sous le véhicule |
Le sonar possède son propre `World3D`, son environnement, deux lumières
directionnelles et sa caméra. Ce découplage permet aux vues compacte et agrandie
de partager une image stable indépendamment de la caméra principale.
L'environnement utilise un tonemapper filmique, une énergie ambiante de 0,42,
une lumière principale d'énergie 1,25 avec ombres et une lumière de remplissage
d'énergie 0,38.
### Repères visuels
- Le sous-marin est une capsule blanche émissive de 5,8 mètres, prolongée par un
nez conique.
- La caisse/anomalie est représentée par un cube orange non éclairé de
`8 × 4,5 × 8` mètres, avec une émission d'énergie 4.
- Le cube est placé 1,4 mètre au-dessus de la moyenne des retours métalliques
afin de rester identifiable dans la petite vue.
Ces dimensions sont des exagérations d'interface, pas les dimensions physiques
exactes des objets détectés.
## Baseline de l'interface
| Élément | Dimensions actuelles |
|---|---:|
| Panneau compact | 320 × 276 px |
| Texture compacte | 298 × 190 px |
| Panneau agrandi | 1 060 × 652 px |
| Texture agrandie | 1 038 × 556 px |
La légende actuelle est : bleu profond, vert/jaune hauteur, orange
caisse/anomalie, noir inconnu. L'interface rappelle la portée de 95 mètres et le
fait que le cap se trouve vers le haut.
Les textes, tailles, bordures et positions sont provisoires. Sont validés : la
présence des deux formats, leur texture commune, le clic d'agrandissement et la
continuité de la simulation.
## Scène de calibration
| Élément | Valeur actuelle |
|---|---:|
| Position initiale du sous-marin | `(0 ; -25 ; 22)` m |
| Terrain physique | 150 × 170 m |
| Centre longitudinal du terrain | z = -28 m |
| Maillage du terrain | 61 × 69 sommets |
| Collision | `ConcavePolygonShape3D`, double face |
| Position horizontale de la caisse | `(0 ; -45)` m |
| Taille physique de la caisse | `8 × 5 × 7` m |
Le terrain est généré par une pente légère, deux ondulations sinusoïdales et des
fonctions gaussiennes. Il contient deux reliefs proches, deux crêtes hautes, une
crête arrière et une dépression centrale étroite. Cette dépression forme un
chenal en U lisible comme passage possible. La caisse repose dans ce chenal.
La scène est un instrument de calibration de lisibilité. Sa topologie et ses
dimensions ne constituent pas une règle de level design.
## Architecture et flux de données
### Fichiers principaux
| Fichier | Responsabilité actuelle |
|---|---|
| `src/sonar_system.gd` | Déclenchement, acquisition, calendrier des retours et historique court |
| `src/sonar_model.gd` | Déduplication spatiale et calcul d'intensité |
| `src/sonar_relief_renderer.gd` | Reconstruction du maillage et rendu bathymétrique |
| `src/sonar_hud.gd` | Vues compacte/agrandie, statuts, légende et interactions |
| `src/poc_b.gd` | Assemblage du POC et génération de la scène de calibration |
| `tests/run_poc_b_integration.gd` | Validation automatisée de la boucle complète |
### Signaux
- `ping_started()` vide le relevé courant et prépare le fondu du précédent.
- `ping_progress(current_cells, previous_cells, progress)` alimente le maillage
progressif et les statuts.
- `ping_completed(current_cells, previous_cells)` fige le nouveau résultat et
supprime l'ancien.
- `expanded_changed(is_expanded)` gère notamment la capture de la souris.
Les tableaux sont actuellement copiés en profondeur entre le système, les
signaux et le renderer. Cette simplicité protège le POC des mutations partagées,
mais deviendra coûteuse avec une carte plus grande.
## Ce qui est validé et ce qui ne l'est pas
### Validé
- Principe d'une surface bathymétrique 3D continue.
- Caméra oblique, orthographique et alignée sur le cap.
- Triple lecture par silhouette, couleur d'altitude et ombrage.
- Impulsion manuelle et résultat non immédiat.
- Révélation du proche vers le lointain en 2,5 secondes.
- Portée de POC suffisamment utile à 95 mètres et couverture de 76°.
- Petit écran cliquable et vue agrandie non bloquante.
- Remplacement progressif d'un seul relevé précédent.
- Différenciation orange d'une anomalie.
- Absence d'aide automatique signalant les ouvertures.
### Provisoire
- Toutes les valeurs numériques de portée, résolution et durée.
- Palette exacte et seuils de couleur.
- Caméra, éclairage, taille de la jupe et modèle du sous-marin.
- Taille exagérée du marqueur de caisse.
- Terrain et objet de calibration.
- Textes et composition de l'interface.
- Maintien d'un seul relevé précédent.
- Utilisation du renderer GL Compatibility.
### À reprendre ou approfondir
- Acquisition physiquement crédible depuis le capteur sous le nez.
- Représentation des parois verticales, plafonds, grottes et surplombs.
- Regroupement et représentation de plusieurs anomalies.
- Échelle bathymétrique absolue ou légende de profondeur.
- Coût énergétique, bruit, faux retours et interférences.
- Résolution et portée variables selon les améliorations du véhicule.
- Accessibilité de la palette et alternatives à la couleur.
- Courbes de niveau éventuelles dans la grande vue seulement.
- Performance de reconstruction et allocations mémoire.
- Contrôles à la manette et navigation complète dans l'interface.
## Limite structurelle : grottes et surplombs
Une carte d'altitude associe une seule hauteur à chaque position horizontale.
Elle ne peut donc pas représenter simultanément le toit et le sol d'une grotte,
ni un objet situé sous un surplomb.
La production devra probablement utiliser une solution hybride :
1. une carte bathymétrique continue pour le fond extérieur ;
2. des surfaces locales supplémentaires pour les parois et plafonds visibles ;
3. une règle d'occlusion empêchant de révéler ce que l'impulsion ne peut pas
atteindre ;
4. une transition visuelle cohérente entre carte extérieure et intérieur scanné.
Ce problème doit être testé dans un niveau réel, pas résolu en ajoutant des
voxels à la baseline validée.
## Vérification technique
- 13 tests unitaires headless passent : neuf pour la survie et quatre pour la
fondation sonar.
- 14 assertions d'intégration POC B passent.
- L'intégration vérifie la durée, la portée, l'ouverture, le caractère non
immédiat, la densité du relevé, les coordonnées de grille, l'élévation, la
détection métallique, la génération de faces, l'agrandissement, la conservation
d'un relevé précédent et son remplacement progressif.
- Le rendu a été contrôlé en 960 × 540 puis réduit à 298 × 190.
- La vérification visuelle a été effectuée en OpenGL Compatibility sur Apple M1.
Les tests ne valident pas automatiquement la compréhension humaine du relief,
la perception des couleurs, la fidélité acoustique, les performances sur une
grande carte ni le comportement de plusieurs anomalies.
## Contrat de reprise pour la production
Le code du POC B peut servir de référence, d'outil de calibration ou de point de
départ temporaire. Il ne doit pas devenir silencieusement le sonar de production.
Une réécriture devra préserver :
- la surface continue et son orientation stable ;
- la progression temporelle proche-vers-lointain ;
- l'action manuelle et la possibilité d'une amélioration automatique ;
- les formats compact et agrandi ;
- la redondance couleur, volume et anomalie ;
- le principe de ne pas révéler automatiquement le chemin.
Elle devra remplacer explicitement :
- les sondes verticales par un modèle d'acquisition cohérent avec le gameplay ;
- la reconstruction complète à chaque retour par une mise à jour incrémentale ;
- la moyenne unique des anomalies par un regroupement spatial ;
- la carte à hauteur unique par une représentation hybride lorsque les grottes
le nécessitent ;
- les copies profondes et données sous forme de dictionnaires par des structures
adaptées aux performances mesurées.
+32
View File
@@ -0,0 +1,32 @@
# 0001 — Frontières du code de production
- Statut : accepté
- Date : 2026-08-07
- Remplace : aucun
## Contexte
Les POC A et B mélangent volontairement règles, nœuds Godot, rendu et assemblage.
Cette vitesse était adaptée à la validation, mais favoriserait duplication et
régressions dans une verticale de plusieurs mois.
## Décision
Le code de production est créé sous `game/` et séparé en `domain`,
`application`, `world`, `presentation`, `infrastructure` et `composition`.
Les dépendances suivent exclusivement les directions décrites dans
`docs/ENGINEERING_GUARDRAILS.md`.
## Conséquences
- Les règles peuvent être testées sans scène.
- L'assemblage Godot reste remplaçable.
- Certaines fonctionnalités nécessitent davantage de contrats explicites.
- Les contrôles automatiques refusent les dépendances inverses connues.
## Alternatives considérées
- Continuer à organiser les scripts par scène : rejeté à cause du risque de
règles dupliquées entre HUD, acteurs et écrans.
- Un découpage strict par fonctionnalité : intéressant plus tard, mais moins
adapté tant que les frontières de la première verticale ne sont pas stables.
@@ -0,0 +1,28 @@
# 0002 — Les prototypes restent des références jetables
- Statut : accepté
- Date : 2026-08-07
- Remplace : aucun
## Contexte
Les POC ont validé des comportements importants, mais utilisent des compromis
documentés : dictionnaires implicites, reconstruction complète du sonar, sondes
verticales, génération de scène monolithique et rendu GL Compatibility.
## Décision
`src/` et `scenes/` restent la zone des POC. Aucun code sous `game/` ne peut les
importer. Les comportements validés sont réécrits derrière des contrats typés,
avec leurs baselines comme spécification.
## Conséquences
- La verticale ne dépend pas accidentellement d'une dette de prototype.
- Une correction demandée sur un POC reste possible sans toucher la production.
- Le premier portage demande un effort explicite et mesurable.
## Alternatives considérées
- Déplacer progressivement les fichiers existants dans la production : rejeté,
car les dépendances cachées seraient conservées sous de nouveaux chemins.
+28
View File
@@ -0,0 +1,28 @@
# 0003 — Une barrière qualité unique
- Statut : accepté
- Date : 2026-08-07
- Remplace : aucun
## Contexte
Des suites lancées séparément sont facilement oubliées. Un test unitaire vert ne
détecte pas nécessairement un script non chargé, une scène invalide ou une
dépendance architecturale inversée.
## Décision
`bash scripts/quality_gate.sh` devient la commande obligatoire avant achèvement
d'un changement. Elle vérifie l'architecture, charge toutes les ressources de
code et de scène, puis exécute les suites automatisées.
## Conséquences
- Le même point d'entrée peut être utilisé localement et en CI.
- La durée de vérification augmentera avec la verticale et devra rester suivie.
- Un échec ne peut être neutralisé sans correction ou nouvel ADR.
## Alternatives considérées
- Documenter plusieurs commandes indépendantes : rejeté, car l'oubli d'une
suite deviendrait un comportement normal.
+22
View File
@@ -0,0 +1,22 @@
# Architecture Decision Records
Nommer les décisions `NNNN-titre-court.md`. Ne jamais modifier le sens d'un ADR
accepté ; créer un nouvel ADR qui le remplace.
Modèle :
```markdown
# NNNN — Titre
- Statut : proposé | accepté | remplacé
- Date : AAAA-MM-JJ
- Remplace : aucun | NNNN
## Contexte
## Décision
## Conséquences
## Alternatives considérées
```
+16
View File
@@ -0,0 +1,16 @@
# Production game code
Ce dossier est réservé à la verticale et au jeu de production. Il ne doit pas
dépendre de `src/` ou `scenes/`, qui contiennent les POC jetables.
Les sous-dossiers autorisés sont :
- `domain/` : règles pures et données typées ;
- `application/` : cas d'usage et ports ;
- `world/` : acteurs et physique Godot ;
- `presentation/` : HUD et rendu ;
- `infrastructure/` : sauvegarde, audio, entrées et plateforme ;
- `composition/` : assemblage concret.
Créer un sous-dossier seulement avec sa première responsabilité réelle. Les
règles complètes se trouvent dans `docs/ENGINEERING_GUARDRAILS.md`.
+24
View File
@@ -0,0 +1,24 @@
; Ransom's Wake - POC B
; Generated for Godot 4.7.1.
config_version=5
[application]
config/name="Ransom's Wake - POC B"
run/main_scene="res://scenes/poc_b.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
window/size/window_width_override=1280
window/size/window_height_override=720
window/stretch/mode="canvas_items"
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
textures/default_filters/use_nearest_mipmap_filter=false
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/poc_a.gd" id="1_main"]
[node name="PocA" type="Node3D"]
script = ExtResource("1_main")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/poc_b.gd" id="1_main"]
[node name="PocB" type="Node3D"]
script = ExtResource("1_main")
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
PROJECT_DIRECTORY="$(CDPATH= cd -- "$SCRIPT_DIRECTORY/.." && pwd)"
cd "$PROJECT_DIRECTORY"
failure_count=0
fail_with_matches() {
local label="$1"
local matches="$2"
if [ -n "$matches" ]; then
printf 'ARCHITECTURE ERROR: %s\n%s\n' "$label" "$matches"
failure_count=$((failure_count + 1))
fi
}
source_directories="src tests"
if [ -d game ]; then
source_directories="$source_directories game"
fi
duplicate_classes="$(
rg --no-filename -o \
'^[[:space:]]*class_name[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' \
$source_directories 2>/dev/null \
| awk '{print $2}' \
| sort \
| uniq -d \
|| true
)"
fail_with_matches "duplicate class_name declarations" "$duplicate_classes"
if [ -d game ]; then
prototype_imports="$(rg -n 'res://(src|scenes)/' game --glob '*.gd' || true)"
fail_with_matches "production imports prototype code or scenes" "$prototype_imports"
test_imports="$(rg -n 'res://tests/' game --glob '*.gd' || true)"
fail_with_matches "production imports test code" "$test_imports"
poc_references="$(rg -n 'poc_[ab]|POC_[AB]' game --glob '*.gd' --glob '*.tscn' || true)"
fail_with_matches "production contains a prototype dependency" "$poc_references"
if [ -d game/domain ]; then
domain_nodes="$(
rg -n '^extends[[:space:]]+(Node|Node2D|Node3D|Control|CanvasLayer|CharacterBody3D|SubViewport)' \
game/domain --glob '*.gd' || true
)"
fail_with_matches "domain extends scene-tree or presentation nodes" "$domain_nodes"
domain_dependencies="$(
rg -n 'res://game/(application|world|presentation|infrastructure|composition)/' \
game/domain --glob '*.gd' || true
)"
fail_with_matches "domain depends on an outer layer" "$domain_dependencies"
fi
if [ -d game/application ]; then
application_dependencies="$(
rg -n 'res://game/(world|presentation|infrastructure|composition)/' \
game/application --glob '*.gd' || true
)"
fail_with_matches "application depends on an adapter layer" "$application_dependencies"
fi
if [ -d game/world ]; then
world_dependencies="$(
rg -n 'res://game/(presentation|infrastructure|composition)/' \
game/world --glob '*.gd' || true
)"
fail_with_matches "world depends on presentation or infrastructure" "$world_dependencies"
fi
if [ -d game/presentation ]; then
presentation_dependencies="$(
rg -n 'res://game/(world|infrastructure|composition)/' \
game/presentation --glob '*.gd' || true
)"
fail_with_matches "presentation depends on a concrete adapter" "$presentation_dependencies"
fi
if [ -d game/infrastructure ]; then
infrastructure_dependencies="$(
rg -n 'res://game/(world|presentation|composition)/' \
game/infrastructure --glob '*.gd' || true
)"
fail_with_matches "infrastructure depends on world or presentation" "$infrastructure_dependencies"
fi
input_violations="$(
rg -n '\bInput\.' game --glob '*.gd' \
| rg -v '^game/infrastructure/input/' \
|| true
)"
fail_with_matches "direct Input access outside infrastructure/input" "$input_violations"
fi
if [ "$failure_count" -ne 0 ]; then
printf 'Architecture checks: %d failure(s).\n' "$failure_count"
exit 1
fi
printf 'Architecture checks: passed.\n'
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
PROJECT_DIRECTORY="$(CDPATH= cd -- "$SCRIPT_DIRECTORY/.." && pwd)"
GODOT_EXECUTABLE="${GODOT_BIN:-godot}"
REQUIRED_GODOT_VERSION="4.7.1"
QUALITY_TEMP_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/ransoms-wake-quality.XXXXXX")"
cleanup_quality_directory() {
if [ -n "$QUALITY_TEMP_DIRECTORY" ] && [ -d "$QUALITY_TEMP_DIRECTORY" ]; then
rm -r -- "$QUALITY_TEMP_DIRECTORY"
fi
}
trap cleanup_quality_directory EXIT
cd "$PROJECT_DIRECTORY"
check_godot_log() {
local stage_name="$1"
local log_path="$2"
local unexpected_messages
unexpected_messages="$(
rg -n '^(ERROR|SCRIPT ERROR|WARNING):' "$log_path" \
| rg -v '^.*ERROR: Condition "ret != noErr" is true\. Returning: ""$' \
|| true
)"
if [ -n "$unexpected_messages" ]; then
printf 'Quality gate: unexpected Godot diagnostics during %s\n%s\n' \
"$stage_name" "$unexpected_messages"
exit 1
fi
}
actual_version="$($GODOT_EXECUTABLE --version)"
case "$actual_version" in
"$REQUIRED_GODOT_VERSION"*) ;;
*)
printf 'Quality gate: Godot %s required, found %s.\n' \
"$REQUIRED_GODOT_VERSION" "$actual_version"
exit 1
;;
esac
printf 'Quality gate: shell scripts\n'
bash -n scripts/check_architecture.sh scripts/quality_gate.sh
printf 'Quality gate: architecture\n'
bash scripts/check_architecture.sh
printf 'Quality gate: resource loading\n'
"$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/load.log" \
--path . \
--script tests/run_load_checks.gd \
2>&1 | tee "$QUALITY_TEMP_DIRECTORY/load-console.log"
check_godot_log "resource loading" "$QUALITY_TEMP_DIRECTORY/load-console.log"
printf 'Quality gate: unit tests\n'
"$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/unit.log" \
--path . \
--script tests/run_tests.gd \
2>&1 | tee "$QUALITY_TEMP_DIRECTORY/unit-console.log"
check_godot_log "unit tests" "$QUALITY_TEMP_DIRECTORY/unit-console.log"
printf 'Quality gate: POC B integration\n'
"$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \
--path . \
--script tests/run_poc_b_integration.gd \
2>&1 | tee "$QUALITY_TEMP_DIRECTORY/poc_b-integration-console.log"
check_godot_log "POC B integration" "$QUALITY_TEMP_DIRECTORY/poc_b-integration-console.log"
printf 'Quality gate: passed.\n'
+71
View File
@@ -0,0 +1,71 @@
class_name AlarmPlayer
extends AudioStreamPlayer
var _last_severity := 0
var _repeat_timer := 0.0
func update_alerts(delta: float, alerts: Array[int]) -> void:
var severity := 0
for alert in alerts:
severity = maxi(severity, alert)
_repeat_timer = maxf(0.0, _repeat_timer - delta)
if severity > _last_severity:
_play_alarm(severity)
_repeat_timer = _repeat_delay(severity)
elif severity >= SurvivalModel.AlertLevel.RETURN and _repeat_timer <= 0.0:
_play_alarm(severity)
_repeat_timer = _repeat_delay(severity)
_last_severity = severity
func reset() -> void:
_last_severity = 0
_repeat_timer = 0.0
stop()
func _repeat_delay(severity: int) -> float:
match severity:
SurvivalModel.AlertLevel.EMPTY:
return 0.75
SurvivalModel.AlertLevel.CRITICAL:
return 1.25
SurvivalModel.AlertLevel.RETURN:
return 2.5
SurvivalModel.AlertLevel.CAUTION:
return 5.0
_:
return 999.0
func _play_alarm(severity: int) -> void:
if severity <= SurvivalModel.AlertLevel.NORMAL:
return
var generator := AudioStreamGenerator.new()
generator.mix_rate = 22050.0
generator.buffer_length = 0.45
stream = generator
play()
var playback := get_stream_playback() as AudioStreamGeneratorPlayback
if playback == null:
return
var frequency := 520.0 + float(severity) * 110.0
var duration := 0.35
var frame_count := int(generator.mix_rate * duration)
var pulse_count := 1 if severity == SurvivalModel.AlertLevel.CAUTION else 2
if severity >= SurvivalModel.AlertLevel.CRITICAL:
pulse_count = 3
for frame_index in frame_count:
var time := float(frame_index) / generator.mix_rate
var phase_in_pulse := fmod(time * float(pulse_count), duration) / duration
var gate := 1.0 if phase_in_pulse < 0.62 else 0.0
var envelope := minf(time * 30.0, 1.0) * minf((duration - time) * 30.0, 1.0)
var sample := sin(TAU * frequency * time) * 0.16 * gate * envelope
playback.push_frame(Vector2(sample, sample))
+1
View File
@@ -0,0 +1 @@
uid://ya1ugkxp82l5
+394
View File
@@ -0,0 +1,394 @@
extends Node3D
const MISSION_POINTS := [
{
"title": "Rejoignez la balise de navigation",
"position": Vector3(0.0, -22.0, -58.0),
"color": Color("5ddcff"),
"radius": 8.0,
},
{
"title": "Descendez au-delà de 60 m et inspectez la balise",
"position": Vector3(38.0, -66.0, -128.0),
"color": Color("ffbd59"),
"radius": 4.0,
},
{
"title": "Revenez dans la zone de récupération",
"position": Vector3(0.0, -12.0, 12.0),
"color": Color("7af0a5"),
"radius": 8.0,
},
]
var _submarine: SubmarineController
var _hud: PocHud
var _alarm_player: AlarmPlayer
var _mission_index := 0
var _mission_markers: Array[Node3D] = []
var _mission_guide_groups: Array[Node3D] = []
var _terminal := false
var _elapsed_time := 0.0
func _ready() -> void:
_ensure_input_actions()
_build_environment()
_build_course()
_hud = PocHud.new()
add_child(_hud)
_alarm_player = AlarmPlayer.new()
add_child(_alarm_player)
_submarine = SubmarineController.new()
_submarine.name = "Submarine"
_submarine.position = Vector3(0.0, -12.0, 12.0)
_submarine.mission_origin = _submarine.position
_submarine.telemetry_updated.connect(_on_telemetry_updated)
_submarine.loss_triggered.connect(_on_loss_triggered)
_submarine.abandon_progress_changed.connect(_hud.set_ejection_progress)
add_child(_submarine)
_update_mission_marker_visibility()
_hud.set_objective(MISSION_POINTS[0]["title"])
func _process(delta: float) -> void:
if _terminal:
return
_elapsed_time += delta
var target: Vector3 = MISSION_POINTS[_mission_index]["position"]
var distance := _submarine.global_position.distance_to(target)
_hud.set_objective(MISSION_POINTS[_mission_index]["title"], distance)
if distance <= float(MISSION_POINTS[_mission_index]["radius"]):
_mission_index += 1
if _mission_index >= MISSION_POINTS.size():
_complete_course()
else:
_update_mission_marker_visibility()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("debug_critical") and not _terminal:
_submarine.force_critical_state()
if _terminal and event.is_action_pressed("restart_poc"):
get_tree().reload_current_scene()
func _on_telemetry_updated(snapshot: Dictionary) -> void:
if _hud == null:
return
_hud.update_telemetry(snapshot)
_alarm_player.update_alerts(
get_process_delta_time(),
[
int(snapshot["oxygen_alert"]),
int(snapshot["battery_alert"]),
int(snapshot["hull_alert"]),
int(snapshot["depth_alert"]),
]
)
func _on_loss_triggered(reason: StringName) -> void:
_terminal = true
_alarm_player.reset()
var message: String = {
&"hull_failure": "La coque a cédé sous la pression ou après un impact.",
&"oxygen_depleted": "L’oxygène est épuisé. La capsule a été récupérée.",
&"manual_ejection": "Vous avez abandonné volontairement le véhicule.",
}.get(reason, "Le véhicule a été perdu.")
_hud.show_terminal("SOUS-MARIN PERDU", message)
func _complete_course() -> void:
_terminal = true
_submarine.controls_enabled = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_alarm_player.reset()
_hud.show_terminal(
"PARCOURS TERMINÉ",
"Temps : %02d:%02d\nOxygène, propulsion, pression et retour ont été exercés."
% [int(_elapsed_time) / 60, int(_elapsed_time) % 60]
)
func _update_mission_marker_visibility() -> void:
for marker_index in _mission_markers.size():
_mission_markers[marker_index].visible = marker_index == _mission_index
for guide_index in _mission_guide_groups.size():
_mission_guide_groups[guide_index].visible = guide_index == _mission_index
func _build_environment() -> void:
var world_environment := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("031823")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("2b6575")
environment.ambient_light_energy = 0.46
environment.fog_enabled = true
environment.fog_light_color = Color("174553")
environment.fog_light_energy = 0.52
environment.fog_density = 0.013
environment.fog_height = -5.0
environment.fog_height_density = 0.03
world_environment.environment = environment
add_child(world_environment)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-55.0, -28.0, 0.0)
sun.light_color = Color("82bdc9")
sun.light_energy = 0.35
sun.shadow_enabled = true
add_child(sun)
var surface := MeshInstance3D.new()
var surface_mesh := PlaneMesh.new()
surface_mesh.size = Vector2(320.0, 420.0)
surface.mesh = surface_mesh
surface.position = Vector3(0.0, 0.0, -80.0)
var surface_material := StandardMaterial3D.new()
surface_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
surface_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
surface_material.cull_mode = BaseMaterial3D.CULL_DISABLED
surface_material.albedo_color = Color(0.13, 0.55, 0.65, 0.13)
surface.material_override = surface_material
add_child(surface)
_add_water_particles()
func _build_course() -> void:
_add_static_box(
"Seafloor",
Vector3(0.0, -96.0, -80.0),
Vector3(300.0, 10.0, 360.0),
Color("183a3c")
)
_add_static_box(
"LeftWall",
Vector3(-75.0, -50.0, -90.0),
Vector3(22.0, 95.0, 260.0),
Color("24494b")
)
# Nearby formations make speed, distance and heading readable from the
# cockpit before the player reaches the first mission beacon.
var navigation_rock_specs := [
[Vector3(-10.0, -24.0, -12.0), Vector3(10.0, 24.0, 10.0), -14.0],
[Vector3(12.0, -30.0, -28.0), Vector3(11.0, 34.0, 12.0), 18.0],
[Vector3(-12.0, -38.0, -42.0), Vector3(9.0, 46.0, 10.0), 8.0],
[Vector3(11.0, -44.0, -50.0), Vector3(8.0, 52.0, 10.0), -10.0],
[Vector3(7.0, -55.0, -73.0), Vector3(10.0, 38.0, 12.0), -16.0],
[Vector3(25.0, -69.0, -96.0), Vector3(12.0, 45.0, 14.0), 13.0],
[Vector3(45.0, -72.0, -112.0), Vector3(13.0, 50.0, 15.0), -9.0],
]
for rock_index in navigation_rock_specs.size():
var spec: Array = navigation_rock_specs[rock_index]
var navigation_rock := _add_static_box(
"NavigationRock%02d" % rock_index,
spec[0],
spec[1],
Color("315553") if rock_index % 2 == 0 else Color("294947")
)
navigation_rock.rotation_degrees.y = spec[2]
_add_static_box(
"RightWall",
Vector3(75.0, -50.0, -90.0),
Vector3(22.0, 95.0, 260.0),
Color("24494b")
)
var rock_specs := [
[Vector3(-25.0, -70.0, -78.0), Vector3(24.0, 44.0, 28.0), -18.0],
[Vector3(28.0, -76.0, -92.0), Vector3(18.0, 34.0, 20.0), 16.0],
[Vector3(-12.0, -83.0, -145.0), Vector3(42.0, 22.0, 30.0), 8.0],
[Vector3(54.0, -63.0, -150.0), Vector3(20.0, 58.0, 24.0), -12.0],
]
for rock_index in rock_specs.size():
var spec: Array = rock_specs[rock_index]
var rock := _add_static_box(
"Rock%02d" % rock_index,
spec[0],
spec[1],
Color("2d5452")
)
rock.rotation_degrees.y = spec[2]
for mission_data in MISSION_POINTS:
_mission_markers.append(
_add_beacon(mission_data["position"], mission_data["color"])
)
# Intermediate lights reveal the direction of the next leg without drawing
# a HUD arrow. Each chain only appears when its objective becomes active.
_mission_guide_groups.append(_add_guide_chain([], Color("5ddcff")))
_mission_guide_groups.append(
_add_guide_chain(
[
Vector3(8.0, -28.0, -75.0),
Vector3(18.0, -38.0, -94.0),
Vector3(29.0, -51.0, -112.0),
],
Color("ffbd59")
)
)
_mission_guide_groups.append(
_add_guide_chain(
[
Vector3(30.0, -55.0, -100.0),
Vector3(23.0, -44.0, -72.0),
Vector3(15.0, -34.0, -44.0),
Vector3(8.0, -23.0, -16.0),
],
Color("7af0a5")
)
)
func _add_water_particles() -> void:
var particles := GPUParticles3D.new()
particles.name = "SuspendedWaterParticles"
particles.position = Vector3(0.0, -45.0, -65.0)
particles.amount = 520
particles.lifetime = 14.0
particles.preprocess = 14.0
particles.fixed_fps = 30
particles.visibility_aabb = AABB(
Vector3(-85.0, -55.0, -125.0),
Vector3(170.0, 110.0, 250.0)
)
var particle_motion := ParticleProcessMaterial.new()
particle_motion.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
particle_motion.emission_box_extents = Vector3(78.0, 48.0, 118.0)
particle_motion.direction = Vector3(0.25, 0.12, 0.08)
particle_motion.spread = 35.0
particle_motion.initial_velocity_min = 0.025
particle_motion.initial_velocity_max = 0.11
particle_motion.gravity = Vector3.ZERO
particle_motion.scale_min = 0.035
particle_motion.scale_max = 0.11
particles.process_material = particle_motion
var mote_mesh := QuadMesh.new()
mote_mesh.size = Vector2(0.075, 0.075)
var mote_material := StandardMaterial3D.new()
mote_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mote_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mote_material.albedo_color = Color(0.62, 0.88, 0.92, 0.42)
mote_material.roughness = 0.15
mote_mesh.material = mote_material
particles.draw_pass_1 = mote_mesh
add_child(particles)
func _add_static_box(node_name: String, box_position: Vector3, box_size: Vector3, color: Color) -> StaticBody3D:
var body := StaticBody3D.new()
body.name = node_name
body.position = box_position
add_child(body)
var mesh_instance := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = box_size
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = color
material.roughness = 0.9
mesh_instance.material_override = material
body.add_child(mesh_instance)
var collision_shape := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = box_size
collision_shape.shape = shape
body.add_child(collision_shape)
return body
func _add_beacon(beacon_position: Vector3, color: Color) -> Node3D:
var marker := Node3D.new()
marker.position = beacon_position
add_child(marker)
var sphere_instance := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 2.2
sphere.height = 4.4
sphere_instance.mesh = sphere
var material := StandardMaterial3D.new()
material.albedo_color = color
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = 4.0
sphere_instance.material_override = material
marker.add_child(sphere_instance)
var light := OmniLight3D.new()
light.omni_range = 18.0
light.light_energy = 5.0
light.light_color = color
marker.add_child(light)
return marker
func _add_guide_chain(guide_positions: Array, color: Color) -> Node3D:
var group := Node3D.new()
group.name = "MissionGuideChain"
add_child(group)
for guide_position in guide_positions:
var guide := Node3D.new()
guide.position = guide_position
group.add_child(guide)
var guide_mesh_instance := MeshInstance3D.new()
var guide_mesh := SphereMesh.new()
guide_mesh.radius = 0.38
guide_mesh.height = 0.76
guide_mesh_instance.mesh = guide_mesh
var guide_material := StandardMaterial3D.new()
guide_material.albedo_color = color
guide_material.emission_enabled = true
guide_material.emission = color
guide_material.emission_energy_multiplier = 1.5
guide_mesh_instance.material_override = guide_material
guide.add_child(guide_mesh_instance)
var guide_light := OmniLight3D.new()
guide_light.omni_range = 7.0
guide_light.light_energy = 1.8
guide_light.light_color = color
guide.add_child(guide_light)
return group
func _ensure_input_actions() -> void:
_register_key_action("move_forward", KEY_W)
_register_key_action("move_backward", KEY_S)
_register_key_action("move_left", KEY_A)
_register_key_action("move_right", KEY_D)
_register_key_action("ascend", KEY_SPACE)
_register_key_action("descend", KEY_CTRL)
_register_key_action("boost", KEY_SHIFT)
_register_key_action("abandon_vehicle", KEY_X)
_register_key_action("debug_critical", KEY_F9)
_register_key_action("restart_poc", KEY_ENTER)
_register_key_action("toggle_mouse_capture", KEY_ESCAPE)
func _register_key_action(action_name: StringName, physical_keycode: Key) -> void:
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
if InputMap.action_get_events(action_name).is_empty():
var key_event := InputEventKey.new()
key_event.physical_keycode = physical_keycode
InputMap.action_add_event(action_name, key_event)
+1
View File
@@ -0,0 +1 @@
uid://hftqmbdbs7ch
+398
View File
@@ -0,0 +1,398 @@
extends Node3D
const SonarHudScript = preload("res://src/sonar_hud.gd")
const SonarModelScript = preload("res://src/sonar_model.gd")
const SonarSystemScript = preload("res://src/sonar_system.gd")
const START_POSITION := Vector3(0.0, -25.0, 22.0)
const METAL_CONTACT_POSITION := Vector3(0.0, -34.0, -45.0)
var _submarine: SubmarineController
var _sonar: Node
var _hud: CanvasLayer
var _ping_audio: AudioStreamPlayer
var _ping_audio_time_remaining := 0.0
func _ready() -> void:
_ensure_input_actions()
_build_environment()
_build_calibration_site()
_hud = SonarHudScript.new()
_hud.name = "SonarHud"
_hud.expanded_changed.connect(_on_sonar_expanded_changed)
add_child(_hud)
_submarine = SubmarineController.new()
_submarine.name = "Submarine"
_submarine.position = START_POSITION
_submarine.mission_origin = START_POSITION
_submarine.telemetry_updated.connect(_hud.update_telemetry)
add_child(_submarine)
_sonar = SonarSystemScript.new()
_sonar.name = "SonarSystem"
_sonar.ping_started.connect(_on_ping_started)
_sonar.ping_progress.connect(_on_ping_progress)
_sonar.ping_completed.connect(_on_ping_completed)
add_child(_sonar)
_sonar.setup(_submarine, self)
_ping_audio = AudioStreamPlayer.new()
_ping_audio.name = "SonarAudio"
add_child(_ping_audio)
func _process(delta: float) -> void:
_hud.update_sonar_pose(_submarine.global_transform)
if _ping_audio_time_remaining > 0.0:
_ping_audio_time_remaining = maxf(0.0, _ping_audio_time_remaining - delta)
if _ping_audio_time_remaining <= 0.0:
_ping_audio.stop()
_ping_audio.stream = null
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("sonar_ping"):
_sonar.request_ping()
elif event.is_action_pressed("toggle_sonar_map"):
_hud.toggle_expanded()
get_viewport().set_input_as_handled()
elif event.is_action_pressed("toggle_mouse_capture") and _hud.is_expanded():
_hud.set_expanded(false)
get_viewport().set_input_as_handled()
func _on_ping_started() -> void:
_hud.show_ping_started(_sonar.max_range)
_play_sonar_ping()
func _on_ping_progress(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
progress: float
) -> void:
_hud.show_ping_progress(
current_cells,
previous_cells,
_sonar.max_range,
progress
)
func _on_ping_completed(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary]
) -> void:
_hud.show_ping_results(current_cells, previous_cells, _sonar.max_range)
func _on_sonar_expanded_changed(is_expanded: bool) -> void:
Input.mouse_mode = (
Input.MOUSE_MODE_VISIBLE
if is_expanded
else Input.MOUSE_MODE_CAPTURED
)
func _build_environment() -> void:
var world_environment := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("020d13")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("2b6870")
environment.ambient_light_energy = 0.46
environment.fog_enabled = true
environment.fog_light_color = Color("19434b")
environment.fog_light_energy = 0.46
environment.fog_density = 0.010
world_environment.environment = environment
add_child(world_environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-48.0, -22.0, 0.0)
light.light_color = Color("86c8cf")
light.light_energy = 0.34
add_child(light)
_add_water_particles()
func _build_calibration_site() -> void:
_add_bathymetric_terrain()
_add_metal_contact()
func _add_bathymetric_terrain() -> void:
var terrain := StaticBody3D.new()
terrain.name = "ContinuousCalibrationSeafloor"
terrain.set_meta("sonar_material", SonarModelScript.MaterialCategory.ROCK)
terrain.set_meta("sonar_reflectivity", 0.72)
add_child(terrain)
var mesh := _build_bathymetric_terrain_mesh()
var mesh_instance := MeshInstance3D.new()
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = Color("315f5c")
material.roughness = 0.96
mesh_instance.material_override = material
terrain.add_child(mesh_instance)
var collision := CollisionShape3D.new()
var shape := ConcavePolygonShape3D.new()
shape.set_faces(mesh.get_faces())
shape.backface_collision = true
collision.shape = shape
terrain.add_child(collision)
func _build_bathymetric_terrain_mesh() -> ArrayMesh:
var column_count := 61
var row_count := 69
var terrain_width := 150.0
var terrain_length := 170.0
var terrain_center_z := -28.0
var vertices: Array[Vector3] = []
var normals: Array[Vector3] = []
var indices: Array[int] = []
for row in row_count:
var z := terrain_center_z + lerpf(
terrain_length * 0.5,
-terrain_length * 0.5,
float(row) / float(row_count - 1)
)
for column in column_count:
var x := lerpf(
-terrain_width * 0.5,
terrain_width * 0.5,
float(column) / float(column_count - 1)
)
vertices.append(Vector3(x, _terrain_height_at(x, z), z))
normals.append(Vector3.ZERO)
for row in row_count - 1:
for column in column_count - 1:
var near_left := row * column_count + column
var near_right := near_left + 1
var far_left := (row + 1) * column_count + column
var far_right := far_left + 1
_append_terrain_triangle(
near_left,
near_right,
far_left,
vertices,
normals,
indices
)
_append_terrain_triangle(
near_right,
far_right,
far_left,
vertices,
normals,
indices
)
for normal_index in normals.size():
normals[normal_index] = normals[normal_index].normalized()
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array(vertices)
arrays[Mesh.ARRAY_NORMAL] = PackedVector3Array(normals)
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array(indices)
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _append_terrain_triangle(
first: int,
second: int,
third: int,
vertices: Array[Vector3],
normals: Array[Vector3],
indices: Array[int]
) -> void:
var normal := (vertices[second] - vertices[first]).cross(
vertices[third] - vertices[first]
).normalized()
if normal.y < 0.0:
var swapped := second
second = third
third = swapped
normal = -normal
indices.append_array([first, second, third])
normals[first] += normal
normals[second] += normal
normals[third] += normal
func _terrain_height_at(x: float, z: float) -> float:
var base := -39.4 + (z + 12.0) * 0.018
base += sin(x * 0.11) * 0.28 + cos(z * 0.085) * 0.24
var height := base
height += _gaussian_height(x, z, -12.0, -8.0, 9.2, 6.0, 8.0)
height += _gaussian_height(x, z, 14.0, -15.0, 8.2, 6.5, 8.5)
height += _gaussian_height(x, z, -10.0, -42.0, 16.0, 7.5, 14.0)
height += _gaussian_height(x, z, 10.0, -42.0, 16.0, 7.5, 14.0)
height += _gaussian_height(x, z, 0.0, -53.0, 12.0, 15.0, 7.5)
# This narrow negative ridge cuts a readable entrance into the mound. It is
# intentionally a bathymetric approximation rather than an impossible
# overhang in a height field.
height -= _gaussian_height(x, z, 0.0, -41.0, 19.0, 4.2, 16.0)
return maxf(height, base - 1.0)
func _gaussian_height(
x: float,
z: float,
center_x: float,
center_z: float,
amplitude: float,
width_x: float,
width_z: float
) -> float:
var normalized_x := (x - center_x) / maxf(width_x, 0.001)
var normalized_z := (z - center_z) / maxf(width_z, 0.001)
return amplitude * exp(-0.5 * (normalized_x * normalized_x + normalized_z * normalized_z))
func _add_rock_box(
node_name: String,
box_position: Vector3,
box_size: Vector3,
box_rotation_degrees: Vector3 = Vector3.ZERO
) -> StaticBody3D:
var body := StaticBody3D.new()
body.name = node_name
body.position = box_position
body.rotation_degrees = box_rotation_degrees
body.set_meta("sonar_material", SonarModelScript.MaterialCategory.ROCK)
body.set_meta("sonar_reflectivity", 0.68)
add_child(body)
var mesh_instance := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = box_size
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = Color("315b59")
material.roughness = 0.92
mesh_instance.material_override = material
body.add_child(mesh_instance)
var collision := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = box_size
collision.shape = shape
body.add_child(collision)
return body
func _add_metal_contact() -> void:
var contact := StaticBody3D.new()
contact.name = "MetalCalibrationObject"
contact.position = Vector3(
METAL_CONTACT_POSITION.x,
_terrain_height_at(METAL_CONTACT_POSITION.x, METAL_CONTACT_POSITION.z) + 2.5,
METAL_CONTACT_POSITION.z
)
contact.rotation_degrees.y = 16.0
contact.set_meta("sonar_material", SonarModelScript.MaterialCategory.METAL)
contact.set_meta("sonar_reflectivity", 1.0)
add_child(contact)
var mesh_instance := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = Vector3(8.0, 5.0, 7.0)
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = Color("855a35")
material.metallic = 0.88
material.roughness = 0.30
mesh_instance.material_override = material
contact.add_child(mesh_instance)
var collision := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = Vector3(8.0, 5.0, 7.0)
collision.shape = shape
contact.add_child(collision)
func _add_water_particles() -> void:
var particles := GPUParticles3D.new()
particles.name = "CalibrationWaterParticles"
particles.position = Vector3(0.0, -28.0, -15.0)
particles.amount = 280
particles.lifetime = 13.0
particles.preprocess = 13.0
particles.visibility_aabb = AABB(
Vector3(-55.0, -24.0, -65.0),
Vector3(110.0, 48.0, 130.0)
)
var motion := ParticleProcessMaterial.new()
motion.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
motion.emission_box_extents = Vector3(50.0, 20.0, 58.0)
motion.direction = Vector3(0.2, 0.08, 0.05)
motion.spread = 45.0
motion.initial_velocity_min = 0.02
motion.initial_velocity_max = 0.09
motion.gravity = Vector3.ZERO
motion.scale_min = 0.035
motion.scale_max = 0.10
particles.process_material = motion
var quad := QuadMesh.new()
quad.size = Vector2(0.07, 0.07)
var material := StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
material.albedo_color = Color(0.58, 0.86, 0.90, 0.35)
quad.material = material
particles.draw_pass_1 = quad
add_child(particles)
func _play_sonar_ping() -> void:
var generator := AudioStreamGenerator.new()
generator.mix_rate = 22050.0
generator.buffer_length = 0.5
_ping_audio.stream = generator
_ping_audio.play()
var playback := _ping_audio.get_stream_playback() as AudioStreamGeneratorPlayback
if playback == null:
return
var duration := 0.42
_ping_audio_time_remaining = duration + 0.08
var frame_count := int(generator.mix_rate * duration)
for frame_index in frame_count:
var time := float(frame_index) / generator.mix_rate
var frequency := lerpf(1180.0, 640.0, time / duration)
var envelope := exp(-time * 7.5) * minf(time * 80.0, 1.0)
var sample := sin(TAU * frequency * time) * envelope * 0.22
playback.push_frame(Vector2(sample, sample))
func _ensure_input_actions() -> void:
_register_key_action("move_forward", KEY_W)
_register_key_action("move_backward", KEY_S)
_register_key_action("move_left", KEY_A)
_register_key_action("move_right", KEY_D)
_register_key_action("ascend", KEY_SPACE)
_register_key_action("descend", KEY_CTRL)
_register_key_action("boost", KEY_SHIFT)
_register_key_action("abandon_vehicle", KEY_X)
_register_key_action("toggle_mouse_capture", KEY_ESCAPE)
_register_key_action("sonar_ping", KEY_F)
_register_key_action("toggle_sonar_map", KEY_TAB)
func _register_key_action(action_name: StringName, physical_keycode: Key) -> void:
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
if InputMap.action_get_events(action_name).is_empty():
var key_event := InputEventKey.new()
key_event.physical_keycode = physical_keycode
InputMap.action_add_event(action_name, key_event)
+1
View File
@@ -0,0 +1 @@
uid://cohfpdwraxtc0
+202
View File
@@ -0,0 +1,202 @@
class_name PocHud
extends CanvasLayer
var _oxygen_bar: ProgressBar
var _battery_bar: ProgressBar
var _hull_bar: ProgressBar
var _oxygen_label: Label
var _battery_label: Label
var _hull_label: Label
var _depth_label: Label
var _speed_label: Label
var _return_label: Label
var _objective_label: Label
var _warning_label: Label
var _ejection_label: Label
var _terminal_panel: PanelContainer
var _terminal_title: Label
var _terminal_body: Label
func _ready() -> void:
_build_hud()
func update_telemetry(snapshot: Dictionary) -> void:
_oxygen_bar.value = float(snapshot["oxygen_ratio"]) * 100.0
_battery_bar.value = float(snapshot["battery_ratio"]) * 100.0
_hull_bar.value = float(snapshot["hull_ratio"]) * 100.0
var oxygen_seconds := int(ceil(float(snapshot["oxygen"])))
_oxygen_label.text = "OXYGÈNE %02d:%02d" % [oxygen_seconds / 60, oxygen_seconds % 60]
_battery_label.text = "PROPULSION %d%%" % int(round(float(snapshot["battery"])))
_hull_label.text = "COQUE %d%%" % int(round(float(snapshot["hull"])))
_depth_label.text = "PROFONDEUR %.1f m / %.0f m" % [
float(snapshot["depth"]),
float(snapshot["nominal_depth"]),
]
_speed_label.text = "VITESSE %.1f m/s" % float(snapshot.get("speed", 0.0))
_return_label.text = "RETOUR %.0f m • coût estimé %.0f%%" % [
float(snapshot.get("return_distance", 0.0)),
float(snapshot.get("return_energy", 0.0)),
]
var highest_alert := maxi(
maxi(int(snapshot["oxygen_alert"]), int(snapshot["battery_alert"])),
maxi(int(snapshot["hull_alert"]), int(snapshot["depth_alert"]))
)
_apply_alert_style(highest_alert, snapshot)
func set_objective(text: String, distance: float = -1.0) -> void:
_objective_label.text = text if distance < 0.0 else "%s • %.0f m" % [text, distance]
func set_ejection_progress(progress: float) -> void:
if progress <= 0.01:
_ejection_label.text = ""
else:
_ejection_label.text = "ÉJECTION %d%% — maintenir X" % int(round(progress * 100.0))
func show_terminal(title: String, body: String) -> void:
_terminal_title.text = title
_terminal_body.text = body + "\n\nEntrée : recommencer le POC"
_terminal_panel.visible = true
func hide_terminal() -> void:
_terminal_panel.visible = false
func _apply_alert_style(severity: int, snapshot: Dictionary) -> void:
var color := Color("9be7ff")
var warning := ""
match severity:
SurvivalModel.AlertLevel.CAUTION:
color = Color("ffd166")
warning = "ATTENTION — vérifiez votre marge de retour"
SurvivalModel.AlertLevel.RETURN:
color = Color("ff9f43")
warning = "RETOUR RECOMMANDÉ"
SurvivalModel.AlertLevel.CRITICAL:
color = Color("ff4d5a")
warning = "DANGER CRITIQUE — remontez ou éjectez-vous"
SurvivalModel.AlertLevel.EMPTY:
color = Color("ff2340")
warning = "SYSTÈME EN DÉFAILLANCE"
if int(snapshot["depth_alert"]) >= SurvivalModel.AlertLevel.RETURN:
warning = "PRESSION EXCESSIVE — REMONTEZ"
_warning_label.text = warning
_warning_label.modulate = color
for bar in [_oxygen_bar, _battery_bar, _hull_bar]:
bar.modulate = color
func _build_hud() -> void:
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root)
var telemetry_panel := PanelContainer.new()
telemetry_panel.position = Vector2(22.0, 22.0)
telemetry_panel.size = Vector2(330.0, 260.0)
root.add_child(telemetry_panel)
var telemetry_box := VBoxContainer.new()
telemetry_box.add_theme_constant_override("separation", 7)
telemetry_panel.add_child(telemetry_box)
var title := Label.new()
title.text = "RANSOM’S WAKE • POC A"
title.add_theme_font_size_override("font_size", 19)
telemetry_box.add_child(title)
_oxygen_label = _add_metric(telemetry_box, "OXYGÈNE")
_oxygen_bar = _add_bar(telemetry_box)
_battery_label = _add_metric(telemetry_box, "PROPULSION")
_battery_bar = _add_bar(telemetry_box)
_hull_label = _add_metric(telemetry_box, "COQUE")
_hull_bar = _add_bar(telemetry_box)
_depth_label = _add_metric(telemetry_box, "PROFONDEUR")
_speed_label = _add_metric(telemetry_box, "VITESSE")
_return_label = _add_metric(telemetry_box, "RETOUR")
_objective_label = Label.new()
_objective_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
_objective_label.position = Vector2(-360.0, 24.0)
_objective_label.size = Vector2(720.0, 42.0)
_objective_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_objective_label.add_theme_font_size_override("font_size", 22)
root.add_child(_objective_label)
_warning_label = Label.new()
_warning_label.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_warning_label.position = Vector2(-400.0, -118.0)
_warning_label.size = Vector2(800.0, 38.0)
_warning_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_warning_label.add_theme_font_size_override("font_size", 21)
root.add_child(_warning_label)
_ejection_label = Label.new()
_ejection_label.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_ejection_label.position = Vector2(-300.0, -78.0)
_ejection_label.size = Vector2(600.0, 34.0)
_ejection_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_ejection_label.modulate = Color("ff6b6b")
root.add_child(_ejection_label)
var help := Label.new()
help.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
help.offset_left = 22.0
help.offset_top = -40.0
help.offset_right = -22.0
help.offset_bottom = -12.0
help.text = "W/S avancer-reculer • A/D tourner • Espace/Ctrl profondeur • Maj poussée • Souris orientation • X éjection • F9 critique • Échap souris"
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
help.modulate = Color(0.75, 0.88, 0.92, 0.88)
root.add_child(help)
_terminal_panel = PanelContainer.new()
_terminal_panel.set_anchors_preset(Control.PRESET_CENTER)
_terminal_panel.position = Vector2(-290.0, -150.0)
_terminal_panel.size = Vector2(580.0, 300.0)
_terminal_panel.visible = false
root.add_child(_terminal_panel)
var terminal_box := VBoxContainer.new()
terminal_box.alignment = BoxContainer.ALIGNMENT_CENTER
terminal_box.add_theme_constant_override("separation", 18)
_terminal_panel.add_child(terminal_box)
_terminal_title = Label.new()
_terminal_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_terminal_title.add_theme_font_size_override("font_size", 30)
terminal_box.add_child(_terminal_title)
_terminal_body = Label.new()
_terminal_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_terminal_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_terminal_body.add_theme_font_size_override("font_size", 17)
terminal_box.add_child(_terminal_body)
func _add_metric(parent: VBoxContainer, initial_text: String) -> Label:
var label := Label.new()
label.text = initial_text
label.add_theme_font_size_override("font_size", 14)
parent.add_child(label)
return label
func _add_bar(parent: VBoxContainer) -> ProgressBar:
var bar := ProgressBar.new()
bar.min_value = 0.0
bar.max_value = 100.0
bar.value = 100.0
bar.show_percentage = false
bar.custom_minimum_size = Vector2(300.0, 10.0)
parent.add_child(bar)
return bar
+1
View File
@@ -0,0 +1 @@
uid://cat260ck8g6bd
+251
View File
@@ -0,0 +1,251 @@
class_name SonarHud
extends CanvasLayer
const SonarModelScript = preload("res://src/sonar_model.gd")
const SonarReliefRendererScript = preload("res://src/sonar_relief_renderer.gd")
signal expanded_changed(is_expanded: bool)
var _renderer: SubViewport
var _telemetry_label: Label
var _compact_panel: PanelContainer
var _compact_status: Label
var _expanded_overlay: ColorRect
var _expanded_status: Label
var _help_label: Label
var _expanded := false
func _ready() -> void:
layer = 2
_renderer = SonarReliefRendererScript.new()
_renderer.name = "SonarReliefRenderer"
add_child(_renderer)
_build_hud()
func update_telemetry(snapshot: Dictionary) -> void:
_telemetry_label.text = "PROFONDEUR %.1f m\nVITESSE %.1f m/s\nPROPULSION %d%%" % [
float(snapshot.get("depth", 0.0)),
float(snapshot.get("speed", 0.0)),
int(round(float(snapshot.get("battery", 0.0)))),
]
func update_sonar_pose(new_transform: Transform3D) -> void:
_renderer.update_submarine_pose(new_transform)
func show_ping_started(_max_range: float) -> void:
_renderer.start_scan()
_set_status(
"SCAN 0% • PREMIERS RETOURS EN ATTENTE",
Color("9cf7f2"),
"SCAN 0%"
)
func show_ping_progress(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
_max_range: float,
progress: float
) -> void:
_renderer.update_scan(current_cells, previous_cells, progress)
var metal_detected := _metal_cell_count(current_cells) > 0
var color := Color("ffad52") if metal_detected else Color("9cf7f2")
var suffix := " • ANOMALIE" if metal_detected else ""
var status := "SCAN %d%%%s" % [int(round(progress * 100.0)), suffix]
_set_status(status, color, status)
func show_ping_results(
current_cells: Array[Dictionary],
_previous_cells: Array[Dictionary],
_max_range: float
) -> void:
_renderer.finish_scan(current_cells)
var metal_count := _metal_cell_count(current_cells)
var status := "SCAN TERMINÉ • RELIEF RECONSTRUIT"
var color := Color("9cf7f2")
if metal_count > 0:
status += " • ANOMALIE ORANGE"
color = Color("ffad52")
_set_status(
status,
color,
"SCAN TERMINÉ • ANOMALIE" if metal_count > 0 else "SCAN TERMINÉ"
)
func toggle_expanded() -> void:
set_expanded(not _expanded)
func set_expanded(is_expanded: bool) -> void:
if _expanded == is_expanded:
return
_expanded = is_expanded
_expanded_overlay.visible = is_expanded
_compact_panel.visible = not is_expanded
_help_label.visible = not is_expanded
expanded_changed.emit(is_expanded)
func is_expanded() -> bool:
return _expanded
func relief_renderer() -> SubViewport:
return _renderer
func _set_status(text: String, color: Color, compact_text: String = "") -> void:
_compact_status.text = text if compact_text.is_empty() else compact_text
_compact_status.modulate = color
_expanded_status.text = text
_expanded_status.modulate = color
func _metal_cell_count(cells: Array[Dictionary]) -> int:
var count := 0
for cell: Dictionary in cells:
if int(cell["material"]) == SonarModelScript.MaterialCategory.METAL:
count += 1
return count
func _build_hud() -> void:
var root := Control.new()
root.name = "SonarHudRoot"
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root)
var objective := Label.new()
objective.set_anchors_preset(Control.PRESET_CENTER_TOP)
objective.position = Vector2(-390.0, 24.0)
objective.size = Vector2(780.0, 38.0)
objective.text = "LISEZ LE RELIEF • FRANCHISSEZ L'OUVERTURE • IDENTIFIEZ L'ANOMALIE"
objective.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
objective.add_theme_font_size_override("font_size", 18)
root.add_child(objective)
var telemetry_panel := PanelContainer.new()
telemetry_panel.position = Vector2(22.0, 82.0)
telemetry_panel.size = Vector2(280.0, 118.0)
root.add_child(telemetry_panel)
_telemetry_label = Label.new()
_telemetry_label.text = "PROFONDEUR\nVITESSE\nPROPULSION"
_telemetry_label.add_theme_font_size_override("font_size", 16)
telemetry_panel.add_child(_telemetry_label)
var instruction_panel := PanelContainer.new()
instruction_panel.position = Vector2(22.0, 214.0)
instruction_panel.size = Vector2(294.0, 112.0)
root.add_child(instruction_panel)
var instruction := Label.new()
instruction.text = (
"F ÉMETTRE UN SCAN MANUEL\n"
+ "Le relief apparaît du proche au lointain.\n"
+ "L'orange signale un retour anormal."
)
instruction.modulate = Color(0.78, 0.94, 0.95, 0.94)
instruction_panel.add_child(instruction)
_compact_panel = PanelContainer.new()
_compact_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_compact_panel.position = Vector2(-342.0, 82.0)
_compact_panel.size = Vector2(320.0, 276.0)
root.add_child(_compact_panel)
var compact_box := VBoxContainer.new()
compact_box.add_theme_constant_override("separation", 6)
_compact_panel.add_child(compact_box)
var compact_title := Label.new()
compact_title.text = "SONAR BATHYMÉTRIQUE • AGRANDIR"
compact_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
compact_title.add_theme_font_size_override("font_size", 14)
compact_box.add_child(compact_title)
var compact_view := _make_sonar_texture_rect(Vector2(298.0, 190.0))
compact_view.tooltip_text = "Cliquez pour ouvrir le grand écran sonar"
compact_view.mouse_filter = Control.MOUSE_FILTER_STOP
compact_view.gui_input.connect(_on_compact_view_input)
compact_box.add_child(compact_view)
_compact_status = Label.new()
_compact_status.text = "F • LANCER LE PREMIER SCAN"
_compact_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
compact_box.add_child(_compact_status)
var compact_reference := Label.new()
compact_reference.text = "PORTÉE 95 M • SOUS-MARIN EN BAS • CAP VERS LE HAUT"
compact_reference.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
compact_reference.modulate = Color(0.62, 0.82, 0.84, 0.86)
compact_reference.add_theme_font_size_override("font_size", 11)
compact_box.add_child(compact_reference)
_expanded_overlay = ColorRect.new()
_expanded_overlay.name = "ExpandedSonarOverlay"
_expanded_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_expanded_overlay.color = Color(0.0, 0.015, 0.02, 0.96)
_expanded_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
_expanded_overlay.visible = false
root.add_child(_expanded_overlay)
var expanded_panel := PanelContainer.new()
expanded_panel.position = Vector2(110.0, 34.0)
expanded_panel.size = Vector2(1060.0, 652.0)
_expanded_overlay.add_child(expanded_panel)
var expanded_box := VBoxContainer.new()
expanded_box.add_theme_constant_override("separation", 7)
expanded_panel.add_child(expanded_box)
var expanded_title := Label.new()
expanded_title.text = "SONAR BATHYMÉTRIQUE • PORTÉE 95 M • CAP VERS LE HAUT"
expanded_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
expanded_title.add_theme_font_size_override("font_size", 19)
expanded_box.add_child(expanded_title)
var expanded_view := _make_sonar_texture_rect(Vector2(1038.0, 556.0))
expanded_box.add_child(expanded_view)
_expanded_status = Label.new()
_expanded_status.text = "F • LANCER LE PREMIER SCAN"
_expanded_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_expanded_status.add_theme_font_size_override("font_size", 16)
expanded_box.add_child(_expanded_status)
var legend := Label.new()
legend.text = "BLEU PROFOND • VERT/JAUNE HAUTEUR • ORANGE CAISSE/ANOMALIE • NOIR INCONNU"
legend.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
legend.modulate = Color(0.68, 0.88, 0.90, 0.90)
expanded_box.add_child(legend)
var close_button := Button.new()
close_button.position = Vector2(1044.0, 46.0)
close_button.size = Vector2(108.0, 36.0)
close_button.text = "FERMER ×"
close_button.pressed.connect(func() -> void: set_expanded(false))
_expanded_overlay.add_child(close_button)
_help_label = Label.new()
_help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_help_label.offset_left = 20.0
_help_label.offset_top = -42.0
_help_label.offset_right = -20.0
_help_label.offset_bottom = -12.0
_help_label.text = "F scanner • Tab agrandir/fermer • W/S propulsion • A/D gouvernail • Espace/Ctrl profondeur • Échap souris"
_help_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_help_label.modulate = Color(0.75, 0.90, 0.94, 0.9)
root.add_child(_help_label)
func _make_sonar_texture_rect(minimum_size: Vector2) -> TextureRect:
var view := TextureRect.new()
view.custom_minimum_size = minimum_size
view.texture = _renderer.get_texture()
view.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
view.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
return view
func _on_compact_view_input(event: InputEvent) -> void:
if (
event is InputEventMouseButton
and event.button_index == MOUSE_BUTTON_LEFT
and event.pressed
):
set_expanded(true)
+1
View File
@@ -0,0 +1 @@
uid://c3jt5jfbwtl2t
+72
View File
@@ -0,0 +1,72 @@
class_name SonarModel
extends RefCounted
enum MaterialCategory {
ROCK,
METAL,
VOID,
}
var cell_size := 0.75
var _observed_cells: Dictionary = {}
func record_echo(
world_position: Vector3,
intensity: float,
material_category: MaterialCategory
) -> bool:
var cell := _cell_for_position(world_position)
var clamped_intensity := clampf(intensity, 0.0, 1.0)
if _observed_cells.has(cell):
var existing: Dictionary = _observed_cells[cell]
if clamped_intensity <= float(existing["intensity"]):
return false
_observed_cells[cell] = {
"position": world_position,
"intensity": clamped_intensity,
"material": material_category,
}
return true
func observed_points() -> Array[Dictionary]:
var points: Array[Dictionary] = []
for point: Dictionary in _observed_cells.values():
points.append(point.duplicate())
return points
func observed_point_count() -> int:
return _observed_cells.size()
func clear() -> void:
_observed_cells.clear()
func calculate_intensity(
distance: float,
max_range: float,
incidence: float,
reflectivity: float
) -> float:
if max_range <= 0.0 or distance < 0.0 or distance > max_range:
return 0.0
var distance_falloff := 1.0 - distance / max_range
var incidence_response := lerpf(0.18, 1.0, clampf(incidence, 0.0, 1.0))
return clampf(
distance_falloff * incidence_response * clampf(reflectivity, 0.0, 1.0),
0.0,
1.0
)
func _cell_for_position(world_position: Vector3) -> Vector3i:
var safe_cell_size := maxf(cell_size, 0.001)
return Vector3i(
floori(world_position.x / safe_cell_size),
floori(world_position.y / safe_cell_size),
floori(world_position.z / safe_cell_size)
)
+1
View File
@@ -0,0 +1 @@
uid://rfiw5isteare
+430
View File
@@ -0,0 +1,430 @@
class_name SonarReliefRenderer
extends SubViewport
const SonarModelScript = preload("res://src/sonar_model.gd")
@export var terrain_skirt_depth := 4.5
var _camera: Camera3D
var _current_mesh: MeshInstance3D
var _previous_mesh: MeshInstance3D
var _current_anomaly: MeshInstance3D
var _previous_anomaly: MeshInstance3D
var _submarine_marker: Node3D
var _current_cells: Array[Dictionary] = []
var _previous_cells: Array[Dictionary] = []
var _last_built_cell_count := -1
func _ready() -> void:
size = Vector2i(960, 540)
disable_3d = false
render_target_update_mode = SubViewport.UPDATE_ALWAYS
world_3d = World3D.new()
_build_world()
func start_scan() -> void:
_previous_cells = _current_cells.duplicate(true)
_current_cells.clear()
_last_built_cell_count = -1
_rebuild_terrain(_previous_mesh, _previous_cells)
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_previous_anomaly, _previous_cells)
_update_anomaly(_current_anomaly, _current_cells)
_previous_mesh.transparency = 0.48 if not _previous_cells.is_empty() else 1.0
_previous_anomaly.transparency = _previous_mesh.transparency
func update_scan(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
progress: float
) -> void:
_current_cells = current_cells.duplicate(true)
_previous_cells = previous_cells.duplicate(true)
if _current_cells.size() != _last_built_cell_count:
_last_built_cell_count = _current_cells.size()
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_current_anomaly, _current_cells)
if _previous_mesh.mesh == null and not _previous_cells.is_empty():
_rebuild_terrain(_previous_mesh, _previous_cells)
_update_anomaly(_previous_anomaly, _previous_cells)
var previous_alpha := lerpf(0.48, 1.0, clampf(progress, 0.0, 1.0))
_previous_mesh.transparency = previous_alpha
_previous_anomaly.transparency = previous_alpha
func finish_scan(current_cells: Array[Dictionary]) -> void:
_current_cells = current_cells.duplicate(true)
_last_built_cell_count = _current_cells.size()
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_current_anomaly, _current_cells)
_previous_cells.clear()
_previous_mesh.mesh = null
_previous_mesh.transparency = 1.0
_previous_anomaly.visible = false
_previous_anomaly.transparency = 1.0
func update_submarine_pose(submarine_transform: Transform3D) -> void:
if _camera == null:
return
var forward := -submarine_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var camera_position := submarine_transform.origin - forward * 30.0 + Vector3.UP * 65.0
var focus := submarine_transform.origin + forward * 43.0 + Vector3.DOWN * 8.0
_camera.global_position = camera_position
_camera.look_at(focus, Vector3.UP)
_submarine_marker.global_position = submarine_transform.origin
_submarine_marker.rotation = Vector3(0.0, submarine_transform.basis.get_euler().y, 0.0)
func current_face_count() -> int:
if _current_mesh.mesh == null:
return 0
return int(_current_mesh.mesh.get_faces().size() / 3)
func _build_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("8fc9d1")
environment.ambient_light_energy = 0.42
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
environment_node.environment = environment
add_child(environment_node)
_previous_mesh = _make_terrain_instance("PreviousBathymetry")
_current_mesh = _make_terrain_instance("CurrentBathymetry")
_previous_anomaly = _make_anomaly_instance("PreviousAnomaly")
_current_anomaly = _make_anomaly_instance("CurrentAnomaly")
var key_light := DirectionalLight3D.new()
key_light.name = "ReliefKeyLight"
key_light.rotation_degrees = Vector3(-58.0, -34.0, 0.0)
key_light.light_color = Color("d8fbff")
key_light.light_energy = 1.25
key_light.shadow_enabled = true
add_child(key_light)
var fill_light := DirectionalLight3D.new()
fill_light.name = "ReliefFillLight"
fill_light.rotation_degrees = Vector3(-35.0, 145.0, 0.0)
fill_light.light_color = Color("3d86ad")
fill_light.light_energy = 0.38
add_child(fill_light)
_camera = Camera3D.new()
_camera.name = "BathymetryCamera"
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
_camera.size = 116.0
_camera.far = 240.0
_camera.current = true
add_child(_camera)
_build_submarine_marker()
func _make_terrain_instance(node_name: String) -> MeshInstance3D:
var instance := MeshInstance3D.new()
instance.name = node_name
var material := StandardMaterial3D.new()
material.vertex_color_use_as_albedo = true
material.roughness = 0.84
material.metallic = 0.0
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
# The scan may be inspected while the submarine pitches or passes above it.
# Rendering both sides keeps the reconstructed sheet readable from every
# permitted sonar-camera angle.
material.cull_mode = BaseMaterial3D.CULL_DISABLED
instance.material_override = material
add_child(instance)
return instance
func _make_anomaly_instance(node_name: String) -> MeshInstance3D:
var instance := MeshInstance3D.new()
instance.name = node_name
var crate := BoxMesh.new()
crate.size = Vector3(8.0, 4.5, 8.0)
instance.mesh = crate
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = Color("ff9d1f")
material.emission_enabled = true
material.emission = Color("ff6800")
material.emission_energy_multiplier = 4.0
material.roughness = 0.32
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
instance.material_override = material
instance.visible = false
add_child(instance)
return instance
func _build_submarine_marker() -> void:
_submarine_marker = Node3D.new()
_submarine_marker.name = "SubmarineReference"
add_child(_submarine_marker)
var body := MeshInstance3D.new()
var body_mesh := CapsuleMesh.new()
body_mesh.radius = 1.15
body_mesh.height = 5.8
body_mesh.radial_segments = 16
body_mesh.rings = 6
body.mesh = body_mesh
body.rotation_degrees.x = 90.0
var body_material := StandardMaterial3D.new()
body_material.albedo_color = Color("eaffff")
body_material.emission_enabled = true
body_material.emission = Color("a7f8ff")
body_material.emission_energy_multiplier = 1.45
body.material_override = body_material
_submarine_marker.add_child(body)
var nose := MeshInstance3D.new()
var nose_mesh := CylinderMesh.new()
nose_mesh.top_radius = 0.0
nose_mesh.bottom_radius = 0.72
nose_mesh.height = 1.6
nose_mesh.radial_segments = 16
nose.mesh = nose_mesh
nose.position.z = -3.5
nose.rotation_degrees.x = -90.0
nose.material_override = body_material
_submarine_marker.add_child(nose)
func _rebuild_terrain(instance: MeshInstance3D, cells: Array[Dictionary]) -> void:
var samples: Dictionary = {}
for cell: Dictionary in cells:
if String(cell.get("sample_type", "bathymetry")) != "bathymetry":
continue
samples[Vector2i(int(cell["column"]), int(cell["row"]))] = cell
if samples.size() < 4:
instance.mesh = null
return
var keys: Array[Vector2i] = []
for key: Vector2i in samples:
keys.append(key)
keys.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return a.y < b.y or (a.y == b.y and a.x < b.x)
)
var vertices: Array[Vector3] = []
var colors: Array[Color] = []
var normals: Array[Vector3] = []
var indices: Array[int] = []
var vertex_for_key: Dictionary = {}
for key: Vector2i in keys:
var cell: Dictionary = samples[key]
vertex_for_key[key] = vertices.size()
vertices.append(cell["position"])
colors.append(_bathymetry_color(cell))
normals.append(Vector3.ZERO)
for key: Vector2i in keys:
var right_key := key + Vector2i.RIGHT
var far_key := key + Vector2i.DOWN
var far_right_key := key + Vector2i(1, 1)
if not (
vertex_for_key.has(right_key)
and vertex_for_key.has(far_key)
and vertex_for_key.has(far_right_key)
):
continue
_append_top_triangle(
int(vertex_for_key[key]),
int(vertex_for_key[right_key]),
int(vertex_for_key[far_key]),
vertices,
normals,
indices
)
_append_top_triangle(
int(vertex_for_key[right_key]),
int(vertex_for_key[far_right_key]),
int(vertex_for_key[far_key]),
vertices,
normals,
indices
)
if indices.is_empty():
instance.mesh = null
return
for normal_index in normals.size():
normals[normal_index] = normals[normal_index].normalized()
_append_terrain_skirt(samples, vertices, normals, colors, indices)
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array(vertices)
arrays[Mesh.ARRAY_NORMAL] = PackedVector3Array(normals)
arrays[Mesh.ARRAY_COLOR] = PackedColorArray(colors)
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array(indices)
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
instance.mesh = mesh
func _append_top_triangle(
first: int,
second: int,
third: int,
vertices: Array[Vector3],
normals: Array[Vector3],
indices: Array[int]
) -> void:
var face_normal := (vertices[second] - vertices[first]).cross(
vertices[third] - vertices[first]
).normalized()
if face_normal.y < 0.0:
var swapped := second
second = third
third = swapped
face_normal = -face_normal
indices.append_array([first, second, third])
normals[first] += face_normal
normals[second] += face_normal
normals[third] += face_normal
func _append_terrain_skirt(
samples: Dictionary,
vertices: Array[Vector3],
normals: Array[Vector3],
colors: Array[Color],
indices: Array[int]
) -> void:
var row_bounds := _sample_bounds(samples)
if row_bounds.is_empty():
return
var minimum_row: int = row_bounds["min_row"]
var maximum_row: int = row_bounds["max_row"]
var minimum_column: int = row_bounds["min_column"]
var maximum_column: int = row_bounds["max_column"]
for column in range(minimum_column, maximum_column):
_append_skirt_between(
samples,
Vector2i(column, minimum_row),
Vector2i(column + 1, minimum_row),
vertices,
normals,
colors,
indices
)
_append_skirt_between(
samples,
Vector2i(column + 1, maximum_row),
Vector2i(column, maximum_row),
vertices,
normals,
colors,
indices
)
for row in range(minimum_row, maximum_row):
_append_skirt_between(
samples,
Vector2i(minimum_column, row + 1),
Vector2i(minimum_column, row),
vertices,
normals,
colors,
indices
)
_append_skirt_between(
samples,
Vector2i(maximum_column, row),
Vector2i(maximum_column, row + 1),
vertices,
normals,
colors,
indices
)
func _append_skirt_between(
samples: Dictionary,
first_key: Vector2i,
second_key: Vector2i,
vertices: Array[Vector3],
normals: Array[Vector3],
colors: Array[Color],
indices: Array[int]
) -> void:
if not samples.has(first_key) or not samples.has(second_key):
return
var first: Vector3 = samples[first_key]["position"]
var second: Vector3 = samples[second_key]["position"]
var first_bottom := first + Vector3.DOWN * terrain_skirt_depth
var second_bottom := second + Vector3.DOWN * terrain_skirt_depth
var side_normal := (second - first).cross(first_bottom - first).normalized()
var base_index := vertices.size()
vertices.append_array([first, second, first_bottom, second_bottom])
normals.append_array([side_normal, side_normal, side_normal, side_normal])
var side_color := Color("152a32")
colors.append_array([side_color, side_color, side_color, side_color])
indices.append_array([
base_index,
base_index + 1,
base_index + 2,
base_index + 1,
base_index + 3,
base_index + 2,
])
func _sample_bounds(samples: Dictionary) -> Dictionary:
var minimum_row := 1 << 30
var maximum_row := -(1 << 30)
var minimum_column := 1 << 30
var maximum_column := -(1 << 30)
for key: Vector2i in samples:
minimum_row = mini(minimum_row, key.y)
maximum_row = maxi(maximum_row, key.y)
minimum_column = mini(minimum_column, key.x)
maximum_column = maxi(maximum_column, key.x)
return {
"min_row": minimum_row,
"max_row": maximum_row,
"min_column": minimum_column,
"max_column": maximum_column,
}
func _bathymetry_color(cell: Dictionary) -> Color:
if int(cell["material"]) == SonarModelScript.MaterialCategory.METAL:
return Color("ff6b22")
var elevation := clampf(float(cell.get("elevation", 0.0)), 0.0, 1.0)
var color: Color
if elevation < 0.28:
color = Color("173f91").lerp(Color("168fc1"), elevation / 0.28)
elif elevation < 0.52:
color = Color("168fc1").lerp(Color("36b979"), (elevation - 0.28) / 0.24)
elif elevation < 0.76:
color = Color("36b979").lerp(Color("d7d93d"), (elevation - 0.52) / 0.24)
else:
color = Color("d7d93d").lerp(Color("ee5734"), (elevation - 0.76) / 0.24)
var return_strength := clampf(float(cell.get("intensity", 0.5)), 0.0, 1.0)
return color.lerp(Color.WHITE, return_strength * 0.08)
func _update_anomaly(instance: MeshInstance3D, cells: Array[Dictionary]) -> void:
var position_sum := Vector3.ZERO
var anomaly_count := 0
for cell: Dictionary in cells:
if int(cell.get("material", SonarModelScript.MaterialCategory.ROCK)) != SonarModelScript.MaterialCategory.METAL:
continue
position_sum += cell["position"]
anomaly_count += 1
instance.visible = anomaly_count > 0
if anomaly_count > 0:
instance.global_position = position_sum / float(anomaly_count) + Vector3.UP * 1.4
+1
View File
@@ -0,0 +1 @@
uid://dtorud30m7smk
+308
View File
@@ -0,0 +1,308 @@
class_name SonarSystem
extends Node
const SonarModelScript = preload("res://src/sonar_model.gd")
signal ping_started()
signal ping_progress(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
progress: float
)
signal ping_completed(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary]
)
@export var max_range := 95.0
@export var min_range := 5.0
@export var horizontal_fov_degrees := 76.0
@export var horizontal_ray_count := 49
@export var vertical_ray_count := 31
@export var scan_duration := 2.5
@export var collision_mask := 1
@export var bathymetry_probe_height := 48.0
@export var bathymetry_probe_depth := 92.0
@export var anomaly_vertical_fov_degrees := 64.0
@export var anomaly_down_angle_degrees := -18.0
var model := SonarModelScript.new()
var _submarine: SubmarineController
var _ping_requested := false
var _ping_active := false
var _cooldown := 0.0
var _ping_elapsed := 0.0
var _next_cell_index := 0
var _scheduled_cells: Array[Dictionary] = []
var _current_cells: Array[Dictionary] = []
var _previous_cells: Array[Dictionary] = []
func setup(submarine: SubmarineController, _world_parent: Node3D) -> void:
_submarine = submarine
model.cell_size = 0.65
func request_ping() -> bool:
if _submarine == null or _ping_requested or _ping_active or _cooldown > 0.0:
return false
_ping_requested = true
return true
func ping_duration() -> float:
return scan_duration
func current_cells() -> Array[Dictionary]:
return _current_cells.duplicate(true)
func previous_cells() -> Array[Dictionary]:
return _previous_cells.duplicate(true)
func _physics_process(delta: float) -> void:
_cooldown = maxf(0.0, _cooldown - delta)
if _ping_requested:
_ping_requested = false
_begin_ping()
if _ping_active:
_advance_ping(delta)
func _begin_ping() -> void:
_previous_cells = _current_cells.duplicate(true)
_current_cells.clear()
model.clear()
_ping_active = true
_ping_elapsed = 0.0
_next_cell_index = 0
_scheduled_cells = _collect_cells()
_scheduled_cells.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a["return_time"]) < float(b["return_time"])
)
ping_started.emit()
ping_progress.emit(_current_cells, _previous_cells, 0.0)
func _advance_ping(delta: float) -> void:
_ping_elapsed = minf(_ping_elapsed + delta, scan_duration)
while (
_next_cell_index < _scheduled_cells.size()
and float(_scheduled_cells[_next_cell_index]["return_time"]) <= _ping_elapsed
):
var cell: Dictionary = _scheduled_cells[_next_cell_index]
model.record_echo(cell["position"], cell["intensity"], cell["material"])
_current_cells.append(cell)
_next_cell_index += 1
var progress := clampf(_ping_elapsed / maxf(scan_duration, 0.001), 0.0, 1.0)
ping_progress.emit(_current_cells, _previous_cells, progress)
if _ping_elapsed >= scan_duration:
_ping_active = false
_cooldown = 0.35
ping_completed.emit(_current_cells, _previous_cells)
func _collect_cells() -> Array[Dictionary]:
var cells := _collect_bathymetry_cells()
cells.append_array(_collect_anomaly_cells())
_apply_elevation_scale(cells)
return cells
func _collect_bathymetry_cells() -> Array[Dictionary]:
var cells: Array[Dictionary] = []
var basis := _submarine.global_transform.basis
var origin := (
_submarine.global_position
+ basis * Vector3(0.0, -0.75, -2.2)
)
var forward := -basis.z
forward.y = 0.0
forward = forward.normalized()
var right := basis.x
right.y = 0.0
right = right.normalized()
var space_state := _submarine.get_world_3d().direct_space_state
var exclusions: Array[RID] = [_submarine.get_rid()]
var half_fov_tangent := tan(deg_to_rad(horizontal_fov_degrees * 0.5))
for row in vertical_ray_count:
var distance_ratio := (
0.5
if vertical_ray_count <= 1
else float(row) / float(vertical_ray_count - 1)
)
var sample_distance := lerpf(min_range, max_range, distance_ratio)
var half_width := maxf(2.5, sample_distance * half_fov_tangent)
for column in horizontal_ray_count:
var horizontal_ratio := (
0.5
if horizontal_ray_count <= 1
else float(column) / float(horizontal_ray_count - 1)
)
var lateral_offset := lerpf(-half_width, half_width, horizontal_ratio)
var sample_center := (
origin
+ forward * sample_distance
+ right * lateral_offset
)
var probe_start := Vector3(
sample_center.x,
origin.y + bathymetry_probe_height,
sample_center.z
)
var probe_end := Vector3(
sample_center.x,
origin.y - bathymetry_probe_depth,
sample_center.z
)
var query := PhysicsRayQueryParameters3D.create(
probe_start,
probe_end,
collision_mask,
exclusions
)
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var hit_position: Vector3 = hit["position"]
var hit_normal: Vector3 = hit["normal"]
var collider := hit.get("collider") as CollisionObject3D
var material_category := SonarModelScript.MaterialCategory.ROCK
var reflectivity := 0.66
if collider != null:
material_category = int(collider.get_meta(
"sonar_material",
SonarModelScript.MaterialCategory.ROCK
))
reflectivity = float(collider.get_meta("sonar_reflectivity", reflectivity))
var distance := origin.distance_to(hit_position)
var direction := origin.direction_to(hit_position)
var incidence := clampf(-direction.dot(hit_normal), 0.0, 1.0)
var intensity := model.calculate_intensity(
distance,
max_range * 1.4,
incidence,
reflectivity
)
if intensity <= 0.005:
continue
cells.append({
"position": hit_position,
"normal": hit_normal,
"distance": distance,
"column": column,
"row": row,
"intensity": intensity,
"material": material_category,
"sample_type": "bathymetry",
"return_time": scan_duration * lerpf(0.04, 1.0, distance_ratio),
})
return cells
func _collect_anomaly_cells() -> Array[Dictionary]:
var cells: Array[Dictionary] = []
var basis := _submarine.global_transform.basis
var origin := _submarine.global_position + basis * Vector3(0.0, -0.75, -2.2)
var space_state := _submarine.get_world_3d().direct_space_state
var exclusions: Array[RID] = [_submarine.get_rid()]
var anomaly_rows := 21
var anomaly_columns := 31
for row in anomaly_rows:
var vertical_ratio := float(row) / float(anomaly_rows - 1)
var elevation := deg_to_rad(
anomaly_down_angle_degrees
+ lerpf(
-anomaly_vertical_fov_degrees * 0.5,
anomaly_vertical_fov_degrees * 0.5,
vertical_ratio
)
)
for column in anomaly_columns:
var horizontal_ratio := float(column) / float(anomaly_columns - 1)
var azimuth := deg_to_rad(lerpf(
-horizontal_fov_degrees * 0.5,
horizontal_fov_degrees * 0.5,
horizontal_ratio
))
var local_direction := Vector3(
tan(azimuth),
tan(elevation),
-1.0
).normalized()
var direction := (basis * local_direction).normalized()
var query := PhysicsRayQueryParameters3D.create(
origin,
origin + direction * max_range,
collision_mask,
exclusions
)
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var collider := hit.get("collider") as CollisionObject3D
if collider == null:
continue
var material_category := int(collider.get_meta(
"sonar_material",
SonarModelScript.MaterialCategory.ROCK
))
if material_category != SonarModelScript.MaterialCategory.METAL:
continue
var hit_position: Vector3 = hit["position"]
var hit_normal: Vector3 = hit["normal"]
var distance := origin.distance_to(hit_position)
var incidence := clampf(-direction.dot(hit_normal), 0.0, 1.0)
var intensity := model.calculate_intensity(
distance,
max_range,
incidence,
float(collider.get_meta("sonar_reflectivity", 1.0))
)
cells.append({
"position": hit_position,
"normal": hit_normal,
"distance": distance,
"column": column,
"row": row,
"intensity": intensity,
"material": material_category,
"sample_type": "anomaly",
"return_time": scan_duration * distance / maxf(max_range, 0.001),
})
return cells
func _apply_elevation_scale(cells: Array[Dictionary]) -> void:
var minimum_elevation := INF
var maximum_elevation := -INF
for cell: Dictionary in cells:
if String(cell.get("sample_type", "bathymetry")) != "bathymetry":
continue
var elevation := float((cell["position"] as Vector3).y)
minimum_elevation = minf(minimum_elevation, elevation)
maximum_elevation = maxf(maximum_elevation, elevation)
if minimum_elevation == INF:
return
var elevation_span := maxf(maximum_elevation - minimum_elevation, 6.0)
for cell: Dictionary in cells:
if String(cell.get("sample_type", "bathymetry")) != "bathymetry":
continue
cell["elevation"] = clampf(
(float((cell["position"] as Vector3).y) - minimum_elevation) / elevation_span,
0.0,
1.0
)
+1
View File
@@ -0,0 +1 @@
uid://ruu8hrvkoln0
+550
View File
@@ -0,0 +1,550 @@
class_name SubmarineController
extends CharacterBody3D
signal telemetry_updated(snapshot: Dictionary)
signal loss_triggered(reason: StringName)
signal abandon_progress_changed(progress: float)
@export var cruise_speed := 6.0
@export var boost_speed := 9.5
@export var acceleration := 2.0
@export var braking := 1.35
@export var mouse_sensitivity := 0.0025
@export var keyboard_turn_speed_degrees := 55.0
@export var max_turn_speed_degrees := 45.0
@export var turn_acceleration_degrees := 30.0
@export var turn_braking_degrees := 24.0
@export var lateral_grip := 2.2
@export var camera_position_response := 5.0
@export var camera_rotation_response := 2.8
@export var camera_max_lag := 2.5
@export var visual_tilt_response := 4.5
@export var max_visual_pitch_degrees := 18.0
@export var max_visual_roll_degrees := 16.0
@export var headlight_range := 58.0
@export var headlight_energy := 38.0
@export var headlight_angle_degrees := 24.0
@export var first_person_view := true
@export var first_person_fov := 82.0
@export var cockpit_camera_offset := Vector3(0.0, 0.52, -0.48)
var survival := SurvivalModel.new()
var controls_enabled := true
var mission_origin := Vector3.ZERO
var _yaw := 0.0
var _target_yaw := 0.0
var _camera_yaw := 0.0
var _yaw_velocity := 0.0
var _pitch := -0.22
var _abandon_progress := 0.0
var _collision_cooldown := 0.0
var _last_snapshot: Dictionary = {}
var _camera_pivot: Node3D
var _vehicle_visual: Node3D
func _ready() -> void:
if first_person_view:
_pitch = -0.04
_build_vehicle()
_yaw = rotation.y
_target_yaw = _yaw
_camera_yaw = _yaw
_reset_camera_transform()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_last_snapshot = survival.snapshot(current_depth())
telemetry_updated.emit(_last_snapshot)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and controls_enabled:
_target_yaw = wrapf(
_target_yaw - event.relative.x * mouse_sensitivity,
-PI,
PI
)
var min_pitch := -0.30 if first_person_view else -0.75
var max_pitch := 0.20 if first_person_view else 0.45
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, min_pitch, max_pitch)
elif event.is_action_pressed("toggle_mouse_capture"):
Input.mouse_mode = (
Input.MOUSE_MODE_VISIBLE
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
else Input.MOUSE_MODE_CAPTURED
)
func _physics_process(delta: float) -> void:
if not controls_enabled:
return
_collision_cooldown = maxf(0.0, _collision_cooldown - delta)
var steering_input := Input.get_axis("move_left", "move_right")
_target_yaw = wrapf(
_target_yaw - steering_input * deg_to_rad(keyboard_turn_speed_degrees) * delta,
-PI,
PI
)
_update_yaw(delta)
var forward_input := Input.get_axis("move_forward", "move_backward")
var vertical_input := Input.get_axis("descend", "ascend")
var local_input := Vector3(0.0, vertical_input, forward_input)
if local_input.length() > 1.0:
local_input = local_input.normalized()
var has_power := survival.battery > 0.0
var boosting := Input.is_action_pressed("boost") and has_power
var requested_speed := boost_speed if boosting else cruise_speed
var world_input := (global_transform.basis * local_input).normalized() if local_input.length() > 0.0 else Vector3.ZERO
var target_velocity := world_input * requested_speed if has_power else Vector3.ZERO
var change_rate := acceleration if target_velocity.length() > 0.0 else braking
velocity = velocity.move_toward(target_velocity, change_rate * delta)
_apply_lateral_grip(delta)
var velocity_before_move := velocity
move_and_slide()
if global_position.y > -2.0:
global_position.y = -2.0
velocity.y = minf(velocity.y, 0.0)
_update_vehicle_visual(delta)
_update_camera(delta)
var collision_damage := _calculate_collision_damage(velocity_before_move)
var thrust_ratio := maxf(local_input.length(), absf(steering_input) * 0.35)
if boosting:
thrust_ratio = minf(1.0, thrust_ratio * 1.8)
_last_snapshot = survival.tick(delta, thrust_ratio, current_depth(), collision_damage)
_last_snapshot["speed"] = velocity.length()
_last_snapshot["return_distance"] = global_position.distance_to(mission_origin)
_last_snapshot["return_energy"] = survival.estimated_return_energy(
_last_snapshot["return_distance"],
maxf(current_depth() - 12.0, 0.0)
)
telemetry_updated.emit(_last_snapshot)
_update_abandon(delta)
var reason: StringName = _last_snapshot["loss_reason"]
if not reason.is_empty():
_trigger_loss(reason)
func current_depth() -> float:
return maxf(-global_position.y, 0.0)
func force_critical_state() -> void:
survival.force_critical_state()
func _update_abandon(delta: float) -> void:
if Input.is_action_pressed("abandon_vehicle"):
_abandon_progress = minf(1.0, _abandon_progress + delta / 3.0)
else:
_abandon_progress = maxf(0.0, _abandon_progress - delta * 1.5)
abandon_progress_changed.emit(_abandon_progress)
if _abandon_progress >= 1.0:
_trigger_loss(&"manual_ejection")
func _trigger_loss(reason: StringName) -> void:
if not controls_enabled:
return
controls_enabled = false
velocity = Vector3.ZERO
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
loss_triggered.emit(reason)
func _calculate_collision_damage(previous_velocity: Vector3) -> float:
if _collision_cooldown > 0.0:
return 0.0
var strongest_impact := 0.0
for collision_index in get_slide_collision_count():
var collision := get_slide_collision(collision_index)
var impact := absf(previous_velocity.dot(collision.get_normal()))
strongest_impact = maxf(strongest_impact, impact)
if strongest_impact <= 3.5:
return 0.0
_collision_cooldown = 0.45
return (strongest_impact - 3.5) * 4.0
func _build_vehicle() -> void:
var collision_shape := CollisionShape3D.new()
var capsule := CapsuleShape3D.new()
capsule.radius = 0.72
capsule.height = 3.8
collision_shape.shape = capsule
collision_shape.rotation.x = PI / 2.0
add_child(collision_shape)
_vehicle_visual = Node3D.new()
_vehicle_visual.name = "VehicleVisual"
add_child(_vehicle_visual)
var hull_mesh := MeshInstance3D.new()
var hull := CapsuleMesh.new()
hull.radius = 0.72
hull.height = 3.8
hull_mesh.mesh = hull
hull_mesh.rotation.x = PI / 2.0
hull_mesh.material_override = _material(Color("d8b24c"), 0.18)
_vehicle_visual.add_child(hull_mesh)
var canopy := MeshInstance3D.new()
var canopy_mesh := SphereMesh.new()
canopy_mesh.radius = 0.58
canopy_mesh.height = 1.05
canopy.mesh = canopy_mesh
canopy.position = Vector3(0.0, 0.36, -0.45)
canopy.scale = Vector3(0.85, 0.55, 1.0)
canopy.material_override = _material(Color("163c54"), 0.6)
_vehicle_visual.add_child(canopy)
_add_fin(Vector3(0.0, 0.68, 1.15), Vector3(0.12, 0.7, 1.0))
_add_fin(Vector3(-0.92, 0.0, 0.85), Vector3(0.9, 0.10, 0.8))
_add_fin(Vector3(0.92, 0.0, 0.85), Vector3(0.9, 0.10, 0.8))
for light_x in [-0.42, 0.42]:
_add_headlight(light_x)
_camera_pivot = Node3D.new()
_camera_pivot.name = "CameraPivot"
add_child(_camera_pivot)
if not first_person_view:
_camera_pivot.set_as_top_level(true)
var camera := Camera3D.new()
camera.name = "CockpitCamera" if first_person_view else "ChaseCamera"
camera.current = true
if first_person_view:
camera.fov = first_person_fov
camera.near = 0.05
_camera_pivot.add_child(camera)
_build_cockpit_overlay()
else:
var spring_arm := SpringArm3D.new()
spring_arm.spring_length = 7.0
spring_arm.margin = 0.25
_camera_pivot.add_child(spring_arm)
camera.fov = 72.0
spring_arm.add_child(camera)
func _add_fin(fin_position: Vector3, fin_size: Vector3) -> void:
var fin := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = fin_size
fin.mesh = box
fin.position = fin_position
fin.material_override = _material(Color("bb9134"), 0.05)
_vehicle_visual.add_child(fin)
func _add_headlight(light_x: float) -> void:
var outward_yaw := -signf(light_x) * deg_to_rad(6.0)
var downward_pitch := deg_to_rad(-3.0)
var housing := MeshInstance3D.new()
var housing_mesh := CylinderMesh.new()
housing_mesh.top_radius = 0.22
housing_mesh.bottom_radius = 0.28
housing_mesh.height = 0.48
housing.mesh = housing_mesh
housing.position = Vector3(light_x, 0.05, -1.72)
housing.rotation.x = PI / 2.0
housing.material_override = _material(Color("273238"), 0.72)
housing.visible = not first_person_view
_vehicle_visual.add_child(housing)
var lens := MeshInstance3D.new()
var lens_mesh := SphereMesh.new()
lens_mesh.radius = 0.22
lens_mesh.height = 0.44
lens.mesh = lens_mesh
lens.position = Vector3(light_x, 0.05, -1.98)
lens.scale = Vector3(1.0, 1.0, 0.16)
lens.material_override = _emissive_material(Color("d7f3ff"), 8.0)
lens.visible = not first_person_view
_vehicle_visual.add_child(lens)
# Keep the light source beyond the hull so the submarine cannot cast a
# shadow over its own beams.
var headlight := SpotLight3D.new()
headlight.position = Vector3(light_x, 0.05, -2.03)
headlight.spot_range = headlight_range
headlight.spot_angle = headlight_angle_degrees
headlight.light_energy = headlight_energy
headlight.light_color = Color("d7f3ff")
headlight.rotation = Vector3(downward_pitch, outward_yaw, 0.0)
headlight.shadow_enabled = false
_vehicle_visual.add_child(headlight)
_add_light_beam(light_x, outward_yaw, downward_pitch)
func _add_light_beam(light_x: float, outward_yaw: float, downward_pitch: float) -> void:
var beam_length := headlight_range * 0.76
var beam_direction := (
Basis(Vector3.UP, outward_yaw)
* Basis(Vector3.RIGHT, downward_pitch)
)
var beam_origin := Vector3(light_x, 0.05, -2.03)
if first_person_view:
_add_first_person_beam_haze(beam_origin, beam_direction)
return
var beam_radius := tan(deg_to_rad(headlight_angle_degrees * 0.5)) * beam_length
var beam_mesh := CylinderMesh.new()
beam_mesh.top_radius = 0.10
beam_mesh.bottom_radius = beam_radius
beam_mesh.height = beam_length
beam_mesh.radial_segments = 32
beam_mesh.rings = 1
beam_mesh.cap_top = false
beam_mesh.cap_bottom = false
var shader := Shader.new()
shader.code = """
shader_type spatial;
render_mode unshaded, blend_add, cull_disabled, depth_draw_never;
uniform vec4 beam_color : source_color = vec4(0.42, 0.82, 1.0, 1.0);
uniform float beam_alpha = 0.13;
uniform float beam_length = 36.0;
varying float beam_depth;
void vertex() {
beam_depth = clamp(0.5 - VERTEX.y / beam_length, 0.0, 1.0);
}
void fragment() {
float near_fade = smoothstep(0.0, 0.10, beam_depth);
float distance_fade = 1.0 - smoothstep(0.32, 1.0, beam_depth);
ALBEDO = beam_color.rgb;
EMISSION = beam_color.rgb;
ALPHA = beam_alpha * near_fade * distance_fade;
}
"""
var beam_material := ShaderMaterial.new()
beam_material.shader = shader
beam_material.set_shader_parameter("beam_length", beam_length)
var beam := MeshInstance3D.new()
beam.name = "HeadlightBeam"
beam.mesh = beam_mesh
beam.position = beam_origin + beam_direction * Vector3(0.0, 0.0, -beam_length * 0.5)
beam.basis = beam_direction * Basis(Vector3.RIGHT, PI / 2.0)
beam.material_override = beam_material
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
beam.extra_cull_margin = beam_length
_vehicle_visual.add_child(beam)
func _add_first_person_beam_haze(beam_origin: Vector3, beam_direction: Basis) -> void:
var haze_shader := Shader.new()
haze_shader.code = """
shader_type spatial;
render_mode unshaded, blend_add, cull_disabled, depth_draw_never;
uniform vec4 beam_color : source_color = vec4(0.46, 0.86, 1.0, 1.0);
uniform float slice_alpha = 0.16;
void fragment() {
vec2 centered_uv = UV * 2.0 - 1.0;
float radius = length(centered_uv);
float soft_disc = 1.0 - smoothstep(0.18, 1.0, radius);
ALBEDO = beam_color.rgb;
EMISSION = beam_color.rgb * 2.6;
ALPHA = slice_alpha * soft_disc;
}
"""
var slice_distances := [4.0, 7.5, 12.0, 18.0, 25.0, 32.0]
for slice_index in slice_distances.size():
var distance: float = slice_distances[slice_index]
var radius := tan(deg_to_rad(headlight_angle_degrees * 0.5)) * distance
var slice_mesh := QuadMesh.new()
slice_mesh.size = Vector2(radius * 2.0, radius * 2.0)
var slice_material := ShaderMaterial.new()
slice_material.shader = haze_shader
slice_material.set_shader_parameter(
"slice_alpha",
lerpf(0.19, 0.055, float(slice_index) / float(slice_distances.size() - 1))
)
var haze_slice := MeshInstance3D.new()
haze_slice.name = "HeadlightHaze"
haze_slice.mesh = slice_mesh
haze_slice.position = beam_origin + beam_direction * Vector3(0.0, 0.0, -distance)
haze_slice.basis = beam_direction
haze_slice.material_override = slice_material
haze_slice.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
haze_slice.extra_cull_margin = headlight_range
_vehicle_visual.add_child(haze_slice)
func _build_cockpit_overlay() -> void:
var overlay := CanvasLayer.new()
overlay.name = "CockpitOverlay"
overlay.layer = 0
add_child(overlay)
var glass := ColorRect.new()
glass.name = "PanoramicPorthole"
glass.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
glass.mouse_filter = Control.MOUSE_FILTER_IGNORE
overlay.add_child(glass)
var shader := Shader.new()
shader.code = """
shader_type canvas_item;
void fragment() {
float aspect = SCREEN_PIXEL_SIZE.y / SCREEN_PIXEL_SIZE.x;
vec2 point = (UV - vec2(0.5)) * vec2(aspect, 1.0);
float distance_to_glass = length(point) - 0.435;
vec3 deep_frame = vec3(0.012, 0.025, 0.030);
vec3 frame_highlight = vec3(0.10, 0.16, 0.18);
if (distance_to_glass > 0.030) {
COLOR = vec4(deep_frame, 0.995);
} else if (distance_to_glass > -0.026) {
float bevel = smoothstep(-0.026, 0.002, distance_to_glass);
float upper_glint = 0.35 + 0.65 * (1.0 - UV.y);
vec3 frame_color = mix(deep_frame, frame_highlight, bevel * upper_glint);
COLOR = vec4(frame_color, 0.97);
} else {
float edge_vignette = smoothstep(-0.12, -0.026, distance_to_glass);
float glass_sheen = pow(max(0.0, 1.0 - length(UV - vec2(0.40, 0.30)) * 1.7), 5.0);
vec3 glass_color = vec3(0.08, 0.25, 0.30);
float glass_alpha = 0.012 + edge_vignette * 0.13 + glass_sheen * 0.025;
COLOR = vec4(glass_color, glass_alpha);
}
}
"""
var material := ShaderMaterial.new()
material.shader = shader
glass.material = material
func _update_vehicle_visual(delta: float) -> void:
var local_velocity := global_transform.basis.inverse() * velocity
var pitch_limit := deg_to_rad(max_visual_pitch_degrees)
var roll_limit := deg_to_rad(max_visual_roll_degrees)
var reference_speed := maxf(cruise_speed, 0.001)
# The hull points in the direction of vertical travel instead of remaining
# perfectly level while the world slides past it.
var target_pitch := clampf(local_velocity.y / reference_speed, -1.0, 1.0) * pitch_limit
var turn_ratio := clampf(angle_difference(_yaw, _target_yaw) / 0.55, -1.0, 1.0)
var strafe_ratio := clampf(local_velocity.x / reference_speed, -1.0, 1.0)
var target_roll := clampf(turn_ratio - strafe_ratio * 0.65, -1.0, 1.0) * roll_limit
var weight := _response_weight(visual_tilt_response, delta)
_vehicle_visual.rotation.x = lerp_angle(_vehicle_visual.rotation.x, target_pitch, weight)
_vehicle_visual.rotation.z = lerp_angle(_vehicle_visual.rotation.z, target_roll, weight)
func _update_yaw(delta: float) -> void:
var heading_error := angle_difference(_yaw, _target_yaw)
var max_turn_speed := deg_to_rad(max_turn_speed_degrees)
var desired_yaw_velocity := clampf(
heading_error * 1.8,
-max_turn_speed,
max_turn_speed
)
var change_rate := deg_to_rad(turn_acceleration_degrees)
if absf(desired_yaw_velocity) < absf(_yaw_velocity):
change_rate = deg_to_rad(turn_braking_degrees)
_yaw_velocity = move_toward(_yaw_velocity, desired_yaw_velocity, change_rate * delta)
var yaw_step := _yaw_velocity * delta
if absf(yaw_step) >= absf(heading_error) and signf(yaw_step) == signf(heading_error):
_yaw = _target_yaw
_yaw_velocity = 0.0
else:
_yaw = wrapf(_yaw + yaw_step, -PI, PI)
rotation.y = _yaw
func _apply_lateral_grip(delta: float) -> void:
# A submarine keeps some inertia, but its hull and fins resist a prolonged
# sideways slide. This makes the velocity follow the new heading in a turn.
var local_velocity := global_transform.basis.inverse() * velocity
local_velocity.x = move_toward(local_velocity.x, 0.0, lateral_grip * delta)
velocity = global_transform.basis * local_velocity
func _update_camera(delta: float) -> void:
if first_person_view:
# The POV remains a rigid child of the hull: it can rotate inside the
# porthole, but its position can never leave the submarine.
_camera_pivot.position = cockpit_camera_offset
_camera_yaw = _yaw
_camera_pivot.rotation = Vector3(
_pitch + _vehicle_visual.rotation.x,
0.0,
_vehicle_visual.rotation.z * 0.55
)
return
# This rig ignores the submarine transform and pursues it in world space.
# The small delay lets the vehicle visibly turn and move vertically on screen.
var desired_position := global_position + Vector3(0.0, 0.55, 0.0)
_camera_pivot.global_position = _camera_pivot.global_position.lerp(
desired_position,
_response_weight(camera_position_response, delta)
)
var lag := desired_position - _camera_pivot.global_position
if lag.length() > camera_max_lag:
_camera_pivot.global_position = desired_position - lag.normalized() * camera_max_lag
_camera_yaw = lerp_angle(
_camera_yaw,
_yaw,
_response_weight(camera_rotation_response, delta)
)
_camera_pivot.global_rotation = Vector3(_pitch, _camera_yaw, 0.0)
func _reset_camera_transform() -> void:
if first_person_view:
_camera_pivot.position = cockpit_camera_offset
_camera_pivot.rotation = Vector3(_pitch, 0.0, 0.0)
else:
_camera_pivot.global_position = global_position + Vector3(0.0, 0.55, 0.0)
_camera_pivot.global_rotation = Vector3(_pitch, _camera_yaw, 0.0)
func _response_weight(response: float, delta: float) -> float:
return 1.0 - exp(-maxf(response, 0.0) * delta)
func _material(color: Color, metallic: float) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.metallic = metallic
material.roughness = 0.55
return material
func _emissive_material(color: Color, energy: float) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = energy
material.roughness = 0.18
return material
+1
View File
@@ -0,0 +1 @@
uid://02uauyco6kv5
+139
View File
@@ -0,0 +1,139 @@
class_name SurvivalModel
extends RefCounted
enum AlertLevel {
NORMAL,
CAUTION,
RETURN,
CRITICAL,
EMPTY,
}
const DEFAULT_OXYGEN_SECONDS := 600.0
const DEFAULT_BATTERY := 100.0
const DEFAULT_HULL := 100.0
const DEFAULT_NOMINAL_DEPTH := 60.0
const DEFAULT_CRITICAL_DEPTH := 72.0
const DEFAULT_CRUSH_DEPTH := 78.0
var oxygen_max := DEFAULT_OXYGEN_SECONDS
var battery_max := DEFAULT_BATTERY
var hull_max := DEFAULT_HULL
var oxygen := DEFAULT_OXYGEN_SECONDS
var battery := DEFAULT_BATTERY
var hull := DEFAULT_HULL
var nominal_depth := DEFAULT_NOMINAL_DEPTH
var critical_depth := DEFAULT_CRITICAL_DEPTH
var crush_depth := DEFAULT_CRUSH_DEPTH
var oxygen_drain_per_second := 1.0
var battery_drain_per_second_at_full_thrust := 0.60
func reset() -> void:
oxygen = oxygen_max
battery = battery_max
hull = hull_max
func tick(delta: float, thrust_ratio: float, depth: float, collision_damage: float = 0.0) -> Dictionary:
if delta <= 0.0:
return snapshot(depth)
oxygen = maxf(0.0, oxygen - oxygen_drain_per_second * delta)
battery = maxf(
0.0,
battery - battery_drain_per_second_at_full_thrust * clampf(thrust_ratio, 0.0, 1.0) * delta
)
if depth > nominal_depth:
if depth >= crush_depth:
hull -= 25.0 * delta
else:
var pressure_range := maxf(critical_depth - nominal_depth, 0.001)
var pressure_ratio := clampf((depth - nominal_depth) / pressure_range, 0.0, 1.0)
var damage_rate := lerpf(0.15, 5.0, pressure_ratio * pressure_ratio)
if depth > critical_depth:
var critical_ratio := (depth - critical_depth) / maxf(crush_depth - critical_depth, 0.001)
damage_rate = lerpf(5.0, 18.0, clampf(critical_ratio, 0.0, 1.0))
hull -= damage_rate * delta
hull = clampf(hull - maxf(collision_damage, 0.0), 0.0, hull_max)
return snapshot(depth)
func snapshot(depth: float) -> Dictionary:
return {
"oxygen": oxygen,
"oxygen_max": oxygen_max,
"oxygen_ratio": _ratio(oxygen, oxygen_max),
"battery": battery,
"battery_max": battery_max,
"battery_ratio": _ratio(battery, battery_max),
"hull": hull,
"hull_max": hull_max,
"hull_ratio": _ratio(hull, hull_max),
"depth": maxf(depth, 0.0),
"nominal_depth": nominal_depth,
"critical_depth": critical_depth,
"crush_depth": crush_depth,
"oxygen_alert": alert_level(oxygen, oxygen_max),
"battery_alert": alert_level(battery, battery_max),
"hull_alert": alert_level(hull, hull_max),
"depth_alert": depth_alert_level(depth),
"loss_reason": loss_reason(),
}
func loss_reason() -> StringName:
if hull <= 0.0:
return &"hull_failure"
if oxygen <= 0.0:
return &"oxygen_depleted"
return &""
func alert_level(value: float, maximum: float) -> AlertLevel:
var ratio := _ratio(value, maximum)
if ratio <= 0.0:
return AlertLevel.EMPTY
if ratio <= 0.05:
return AlertLevel.CRITICAL
if ratio <= 0.15:
return AlertLevel.RETURN
if ratio <= 0.30:
return AlertLevel.CAUTION
return AlertLevel.NORMAL
func depth_alert_level(depth: float) -> AlertLevel:
if depth >= crush_depth:
return AlertLevel.EMPTY
if depth >= critical_depth:
return AlertLevel.CRITICAL
if depth > nominal_depth:
return AlertLevel.RETURN
if depth >= nominal_depth * 0.85:
return AlertLevel.CAUTION
return AlertLevel.NORMAL
func estimated_return_energy(distance: float, vertical_gain: float = 0.0) -> float:
var seconds_at_cruise := maxf(distance, 0.0) / 6.0
var base_cost := seconds_at_cruise * battery_drain_per_second_at_full_thrust
var vertical_cost := maxf(vertical_gain, 0.0) * 0.035
return base_cost + vertical_cost
func force_critical_state() -> void:
oxygen = minf(oxygen, 30.0)
battery = minf(battery, 12.0)
hull = minf(hull, 28.0)
func _ratio(value: float, maximum: float) -> float:
if maximum <= 0.0:
return 0.0
return clampf(value / maximum, 0.0, 1.0)
+1
View File
@@ -0,0 +1 @@
uid://bp5rah23csyra
+10
View File
@@ -0,0 +1,10 @@
# Test organization
- `unit/` : domaine pur, sans scène ni I/O.
- `integration/` : assemblage Godot minimal et signaux.
- `contract/` : ressources, contenu, sauvegardes et migrations.
- `fixtures/` : données de référence versionnées et anonymes.
Les runners historiques à la racine de `tests/` couvrent les POC. Les suites de
production devront être ajoutées à `scripts/quality_gate.sh`; créer un fichier
de test qui n'est jamais exécuté n'est pas une validation.
+64
View File
@@ -0,0 +1,64 @@
extends SceneTree
const SEARCH_ROOTS := [
"res://src",
"res://game",
"res://content",
"res://scenes",
"res://tests",
]
var _failures: Array[String] = []
var _loaded_resource_count := 0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
for root: String in SEARCH_ROOTS:
if DirAccess.dir_exists_absolute(root):
_load_directory(root)
if _failures.is_empty():
print("Resource loading: %d passed, 0 failed." % _loaded_resource_count)
quit(0)
else:
for failure: String in _failures:
push_error(failure)
print("Resource loading: %d failed." % _failures.size())
quit(1)
func _load_directory(directory_path: String) -> void:
var directory := DirAccess.open(directory_path)
if directory == null:
_failures.append("cannot open %s" % directory_path)
return
var subdirectories := directory.get_directories()
for subdirectory: String in subdirectories:
_load_directory(directory_path.path_join(subdirectory))
var files := directory.get_files()
for file_name: String in files:
if not (
file_name.ends_with(".gd")
or file_name.ends_with(".tscn")
or file_name.ends_with(".tres")
or file_name.ends_with(".res")
):
continue
var resource_path := directory_path.path_join(file_name)
var resource := ResourceLoader.load(resource_path)
if resource == null:
_failures.append("cannot load %s" % resource_path)
continue
if resource is PackedScene:
var instance := (resource as PackedScene).instantiate()
if instance == null:
_failures.append("cannot instantiate %s" % resource_path)
continue
instance.free()
_loaded_resource_count += 1
+136
View File
@@ -0,0 +1,136 @@
extends SceneTree
const SonarModelScript = preload("res://src/sonar_model.gd")
var _last_cells: Array[Dictionary] = []
var _last_previous_cells: Array[Dictionary] = []
var _progress_cells: Array[Dictionary] = []
var _failures: Array[String] = []
var _completion_count := 0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
change_scene_to_file("res://scenes/poc_b.tscn")
await process_frame
await physics_frame
var submarine := current_scene.get_node("Submarine") as CharacterBody3D
var sonar := current_scene.get_node("SonarSystem")
var hud := current_scene.get_node("SonarHud")
sonar.ping_progress.connect(_on_ping_progress)
sonar.ping_completed.connect(_on_ping_completed)
_expect(absf(float(sonar.ping_duration()) - 2.5) < 0.001, "scan lasts 2.5 seconds")
_expect(float(sonar.max_range) >= 95.0, "bathymetric range reaches 95 metres")
_expect(float(sonar.horizontal_fov_degrees) >= 76.0, "scan covers a wider frontal area")
sonar.request_ping()
for frame in 3:
await physics_frame
_expect(_completion_count == 0, "scan does not return immediately")
for frame in 157:
await physics_frame
_expect(not _last_cells.is_empty(), "calibration scan returns structured relief cells")
_expect(
_bathymetry_cell_count(_last_cells) > 500,
"scan reconstructs a dense bathymetric surface"
)
_expect(
_last_cells[0].has("row") and _last_cells[0].has("column"),
"each return keeps its grid coordinates"
)
_expect(
_last_cells[0].has("elevation"),
"bathymetric samples encode normalized elevation"
)
_expect(_metal_cell_count(_last_cells) > 0, "calibration scan detects the metal object")
_expect(
int(hud.relief_renderer().current_face_count()) > 0,
"relief cells produce connected facets"
)
hud.set_expanded(true)
_expect(hud.is_expanded(), "sonar can expand without pausing the submarine")
hud.set_expanded(false)
var first_scan_size := _last_cells.size()
for frame in 16:
await physics_frame
submarine.controls_enabled = false
submarine.rotation.y = 0.35
sonar.request_ping()
for frame in 3:
await physics_frame
_expect(
_last_previous_cells.size() == first_scan_size,
"new scan keeps exactly one previous relief during replacement"
)
_expect(
_progress_cells.size() < first_scan_size,
"replacement starts near the submarine instead of appearing at once"
)
for frame in 157:
await physics_frame
_expect(_completion_count == 2, "second scan completes after progressive replacement")
var exit_code := 0
if _failures.is_empty():
print("POC B integration: 14 passed, 0 failed.")
else:
for failure in _failures:
push_error(failure)
print("POC B integration: %d failed." % _failures.size())
exit_code = 1
# AudioStreamGeneratorPlayback is reference-counted by the active player.
# Stop it and release the scene before quitting so the integration suite also
# verifies a clean ObjectDB shutdown.
var audio_player := current_scene.get_node_or_null("SonarAudio") as AudioStreamPlayer
if audio_player != null:
audio_player.stop()
audio_player.stream = null
await process_frame
current_scene.queue_free()
await process_frame
quit(exit_code)
func _on_ping_progress(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
_progress: float
) -> void:
_progress_cells = current_cells.duplicate(true)
_last_previous_cells = previous_cells.duplicate(true)
func _on_ping_completed(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary]
) -> void:
_last_cells = current_cells.duplicate(true)
_last_previous_cells = previous_cells.duplicate(true)
_completion_count += 1
func _metal_cell_count(cells: Array[Dictionary]) -> int:
var count := 0
for cell: Dictionary in cells:
if int(cell["material"]) == SonarModelScript.MaterialCategory.METAL:
count += 1
return count
func _bathymetry_cell_count(cells: Array[Dictionary]) -> int:
var count := 0
for cell: Dictionary in cells:
if String(cell.get("sample_type", "")) == "bathymetry":
count += 1
return count
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)
+1
View File
@@ -0,0 +1 @@
uid://b4r5708tp4mtm
+141
View File
@@ -0,0 +1,141 @@
extends SceneTree
const SurvivalModelScript = preload("res://src/survival_model.gd")
const SonarModelScript = preload("res://src/sonar_model.gd")
var _failures: Array[String] = []
func _init() -> void:
_test_oxygen_drains_while_idle()
_test_battery_only_drains_under_thrust()
_test_nominal_depth_is_safe()
_test_excess_depth_damages_hull()
_test_collision_damage_is_applied()
_test_battery_depletion_is_not_immediate_death()
_test_alert_thresholds()
_test_return_estimate()
_test_critical_debug_state()
_test_sonar_deduplicates_nearby_echoes()
_test_sonar_keeps_strongest_echo()
_test_sonar_intensity_falls_with_distance()
_test_sonar_does_not_reveal_unobserved_space()
if _failures.is_empty():
print("Prototype tests: 13 passed, 0 failed.")
quit(0)
else:
for failure in _failures:
push_error(failure)
print("POC A tests: %d failed." % _failures.size())
quit(1)
func _test_oxygen_drains_while_idle() -> void:
var model = SurvivalModelScript.new()
model.tick(10.0, 0.0, 12.0)
_expect_near(model.oxygen, 590.0, 0.001, "oxygen drains while idle")
func _test_battery_only_drains_under_thrust() -> void:
var model = SurvivalModelScript.new()
model.tick(10.0, 0.0, 12.0)
_expect_near(model.battery, 100.0, 0.001, "battery remains stable at idle")
model.tick(10.0, 1.0, 12.0)
_expect_near(model.battery, 94.0, 0.001, "battery drains under full thrust")
func _test_nominal_depth_is_safe() -> void:
var model = SurvivalModelScript.new()
model.tick(30.0, 0.0, 60.0)
_expect_near(model.hull, 100.0, 0.001, "nominal depth does not damage hull")
func _test_excess_depth_damages_hull() -> void:
var model = SurvivalModelScript.new()
model.tick(2.0, 0.0, 74.0)
_expect(model.hull < 100.0, "depth above critical damages hull")
func _test_collision_damage_is_applied() -> void:
var model = SurvivalModelScript.new()
model.tick(0.1, 0.0, 12.0, 12.0)
_expect_near(model.hull, 88.0, 0.001, "collision damage is applied exactly once")
func _test_battery_depletion_is_not_immediate_death() -> void:
var model = SurvivalModelScript.new()
model.battery = 0.0
_expect(model.loss_reason().is_empty(), "empty battery is not immediate death")
func _test_alert_thresholds() -> void:
var model = SurvivalModelScript.new()
_expect(
model.alert_level(30.0, 100.0) == SurvivalModel.AlertLevel.CAUTION,
"30 percent is caution"
)
_expect(
model.alert_level(15.0, 100.0) == SurvivalModel.AlertLevel.RETURN,
"15 percent recommends return"
)
_expect(
model.alert_level(5.0, 100.0) == SurvivalModel.AlertLevel.CRITICAL,
"5 percent is critical"
)
func _test_return_estimate() -> void:
var model = SurvivalModelScript.new()
var short_trip: float = model.estimated_return_energy(100.0, 0.0)
var long_trip: float = model.estimated_return_energy(300.0, 20.0)
_expect(long_trip > short_trip, "return estimate grows with distance and ascent")
func _test_critical_debug_state() -> void:
var model = SurvivalModelScript.new()
model.force_critical_state()
_expect(model.oxygen == 30.0, "critical debug state lowers oxygen")
_expect(model.battery == 12.0, "critical debug state lowers battery")
_expect(model.hull == 28.0, "critical debug state lowers hull")
func _test_sonar_deduplicates_nearby_echoes() -> void:
var model = SonarModelScript.new()
model.record_echo(Vector3(1.0, 2.0, 3.0), 0.7, SonarModelScript.MaterialCategory.ROCK)
model.record_echo(Vector3(1.1, 2.1, 3.1), 0.5, SonarModelScript.MaterialCategory.ROCK)
_expect(model.observed_point_count() == 1, "nearby sonar echoes share one spatial cell")
func _test_sonar_keeps_strongest_echo() -> void:
var model = SonarModelScript.new()
model.record_echo(Vector3.ZERO, 0.25, SonarModelScript.MaterialCategory.ROCK)
model.record_echo(Vector3(0.1, 0.1, 0.1), 0.85, SonarModelScript.MaterialCategory.METAL)
var point: Dictionary = model.observed_points()[0]
_expect_near(float(point["intensity"]), 0.85, 0.001, "stronger sonar echo replaces weak echo")
_expect(
int(point["material"]) == SonarModelScript.MaterialCategory.METAL,
"stronger sonar echo updates material"
)
func _test_sonar_intensity_falls_with_distance() -> void:
var model = SonarModelScript.new()
var near_echo: float = model.calculate_intensity(10.0, 100.0, 1.0, 1.0)
var far_echo: float = model.calculate_intensity(80.0, 100.0, 1.0, 1.0)
_expect(near_echo > far_echo, "sonar intensity falls with distance")
func _test_sonar_does_not_reveal_unobserved_space() -> void:
var model = SonarModelScript.new()
_expect(model.observed_points().is_empty(), "unobserved sonar space starts empty")
func _expect(condition: bool, label: String) -> void:
if not condition:
_failures.append(label)
func _expect_near(actual: float, expected: float, tolerance: float, label: String) -> void:
if absf(actual - expected) > tolerance:
_failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
+1
View File
@@ -0,0 +1 @@
uid://bjmonaev2kyor