Validate POC A and POC B foundations
This commit is contained in:
1 parent
44fd4b4b43
commit
c38e09a663
47 files changed
+4990
-35
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.godot/
|
||||
.DS_Store
|
||||
*.tmp
|
||||
*.log
|
||||
builds/
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Executable
+104
@@ -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'
|
||||
Executable
+75
@@ -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'
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://ya1ugkxp82l5
|
||||
+394
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hftqmbdbs7ch
|
||||
+398
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cohfpdwraxtc0
|
||||
+202
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cat260ck8g6bd
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3jt5jfbwtl2t
|
||||
@@ -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)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://rfiw5isteare
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtorud30m7smk
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ruu8hrvkoln0
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://02uauyco6kv5
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bp5rah23csyra
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4r5708tp4mtm
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjmonaev2kyor
|
||||
Reference in new issue
Block a user