diff --git a/Design-Proposal/README.md b/Design-Proposal/README.md new file mode 100644 index 0000000..38e46db --- /dev/null +++ b/Design-Proposal/README.md @@ -0,0 +1,44 @@ +# Propositions de design UI + +Ce dossier conserve les explorations visuelles des menus et réglages. La +spécification autoritaire se trouve dans +[`docs/UI_DESIGN_SYSTEM.md`](../docs/UI_DESIGN_SYSTEM.md). + +## Statut des fichiers + +### V4 — références conservées + +| Proposition | Usage dans le design system | +|---|---| +| `menu_principal_v4_1080p_ransom_s_wake/` | référence visuelle principale | +| `menu_pause_v4_1080p_ransom_s_wake/` | ambiance de pause, à corriger selon la baseline | +| `confirmation_abandon_v4_1080p_ransom_s_wake/` | texte et traitement du danger | +| `r_glages_commandes_v4_1080p_ransom_s_wake/` | remappage et conflit, contenu à corriger | +| `r_glages_accessibilit_v4_1080p_ransom_s_wake/` | groupement des options, sonar à remplacer | +| `r_glages_audio_v4_1080p_ransom_s_wake/` | référence de densité et de curseurs | +| `r_glages_affichage_v4_1080p_ransom_s_wake/` | composants d'affichage, coque à remplacer | +| `variantes_confirmations_v4_1080p_ransom_s_wake/` | variantes de composition et de texte | +| `planche_composants_v4_1080p_ransom_s_wake/` | états et redondance visuelle | +| `sp_cifications_design_v4_ransom_s_wake.md` | note d'intention ayant mené à la baseline | + +Les exports PNG actuels font 1600 × 1280 ou 1600 × 1310 malgré leur nom. Ils +sont donc des moodboards, pas une preuve de compatibilité 1080p ou 720p. + +### V3 — historique conservé + +- `ransom_s_wake_v3/DESIGN.md` ; +- `variantes_confirmations_v3_ransom_s_wake/`. + +Ces fichiers documentent une étape de recherche. Ils ne doivent pas guider une +implémentation lorsque la V4 ou la baseline tranche différemment. + +## Règles de reprise + +- Ne pas copier les HTML dans `game/`. +- Ne pas ajouter Tailwind, Google Fonts, Material Symbols ou une image distante + parce qu'un prototype les référence. +- Ne pas reprendre les données fictives, le jargon système, le combat, la + gestion de flotte ou le radar circulaire. +- Recréer les composants derrière les contrats de présentation et de réglages + définis pour la production. +- Vérifier les écrans réels à 1920 × 1080 et 1280 × 720. diff --git a/Design-Proposal/confirmation_abandon_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/confirmation_abandon_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..918d3b0 --- /dev/null +++ b/Design-Proposal/confirmation_abandon_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,250 @@ + + + + + +DIVE_OS - Abort Sequence + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+ +
+warning +
+ +
+

+ Abandonner la plongée ? +

+

+ Vous serez éjecté puis évacué. Le sous-marin et sa cargaison non sécurisée resteront au fond. Une franchise sera appliquée. +

+
+ +
+ + + + +
+
+ +
+ +
+
+
+
+
+
+ \ No newline at end of file diff --git a/Design-Proposal/confirmation_abandon_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/confirmation_abandon_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..de3188f Binary files /dev/null and b/Design-Proposal/confirmation_abandon_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/menu_pause_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/menu_pause_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..2122ed5 --- /dev/null +++ b/Design-Proposal/menu_pause_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,248 @@ + + + + + +Pause Menu - RANSOM'S WAKE + + + + + + + + + + + + + + +
+
+
+
+ +
+ + + +
+ +
+

PAUSE

+
+ +
+ +
+ +
+
+
+
+ +
+warning +
+ +

Protocole d'Évacuation

+ +
+

+terminal + > SYSTÈME EN ATTENTE +

+

Vous serez évacué immédiatement.

+

+ Le sous-marin et toutes les découvertes non sécurisées resteront de manière permanente sur le site. Cette action est irréversible. +

+
+ +
+En attente d'entrée utilisateur... +
+
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/Design-Proposal/menu_pause_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/menu_pause_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..d491214 Binary files /dev/null and b/Design-Proposal/menu_pause_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/menu_principal_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/menu_principal_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..787cdbd --- /dev/null +++ b/Design-Proposal/menu_principal_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,210 @@ + + + + + +Menu Principal - Ransom's Wake + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+ \ No newline at end of file diff --git a/Design-Proposal/menu_principal_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/menu_principal_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..376b24c Binary files /dev/null and b/Design-Proposal/menu_principal_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/planche_composants_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/planche_composants_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..b85d199 --- /dev/null +++ b/Design-Proposal/planche_composants_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,397 @@ + + + + + +Planche de Composants - RANSOM'S WAKE V4 + + + + + + + + + + + +
+
+

RANSOM'S WAKE V4

+

COMPONENT STATES & SPECIFICATIONS - 1920x1080

+
+
+
SYSTEM: ONLINE
+
RENDER: STATIC_PREVIEW
+
+
+
+ +
+ +
+

PRIMARY ACTIONS

+
+ +
+NORMAL + +
+ +
+HOVER + +
+ +
+ACTIVE / SELECTED + +
+ +
+PRESSED + +
+ +
+DISABLED + +
+ +
+DANGEROUS + +
+
+
+ +
+

DATA INPUTS

+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+ +error +
+
+
+
+
+ +
+
+

TRANSACTION STATES

+
+ +
+sync +
+SAUVEGARDE EN COURS... +WRITING TO VOLATILE MEMORY +
+
+ +
+check_circle +
+SAUVEGARDE TERMINÉE +CHECKSUM VERIFIED +
+
+ +
+
+
+CHARGEMENT... +READING SECTORS +
+
+ +
+description +
+AUCUNE SAUVEGARDE +SYSTEM IDLE +
+
+ +
+warning +
+SAUVEGARDE INVALIDE +CORRUPTION DETECTED +
+
+ +
+gpp_bad +
+VERSION INCOMPATIBLE +SCHEMA MISMATCH V3 != V4 +
+
+
+
+
+ +
+
+

NAVIGATION & METRICS

+
+ +
+ +
+
+
+
+OPERATOR 04 +
+ +ONLINE +
+
+ +
+REACTOR CORE +
+TEMP +842.1°K +
+
+PRESSURE +120.4 PSI +
+
+ +
+SIDENAV LOGIC PREVIEW +
+terminal + COMMAND (ACTIVE) +
+
+radar + TACTICAL (INACTIVE) +
+
+
+
+
+
+ \ No newline at end of file diff --git a/Design-Proposal/planche_composants_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/planche_composants_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..1021812 Binary files /dev/null and b/Design-Proposal/planche_composants_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/r_glages_accessibilit_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/r_glages_accessibilit_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..6f0cc03 --- /dev/null +++ b/Design-Proposal/r_glages_accessibilit_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,506 @@ + + + + + +DIVE_OS - Accessibility + + + + + + + + + + + +
+ +
+
+
SYS.CFG // ACC.4
+

Réglages - Accessibilité V4

+
+
+info + MODIFICATIONS APPLIED IMMEDIATELY +
+
+ +
+ +
+
+
+

+text_fields + TAILLE DU TEXTE +

+
+ + + +
+
+ +
+
+
+

+waves + CINÉTIQUE & EFFETS +

+
+ +
+
+
Réduction Mouvements
+
DISABLE UI PARALLAX
+
+
+ + +
+
+ +
+
+
Désactiver Roulis
+
STABILIZE CAMERA
+
+
+ + +
+
+ +
+
+
Atténuer Flashs
+
PREVENT STROBE FX
+
+
+ + +
+
+
+
+ +
+
+
+

+hearing + ALERTES & AUDIO +

+
+
+
+ + SYSTEM LOG +
+
+ > [CRITICAL] HULL PRESSURE EXCEEDS NOMINAL PARAMETERS +
+
+
+
+
Sous-titrage alarmes
+
VISUAL ALERT OVERLAYS
+
+
+ + +
+
+
+ +
+
+
+

+radar + CALIBRATION SONAR +

+
+ +
+ +
+
Contraste Sonar
+
+ + + +
+
+ +
+
+Palette Sonar +SHAPE/COLOR REDUNDANCY ACTIVE +
+
+ + + + + + + + + + + + +
+
+
+ +
+
APERÇU IMMÉDIAT
+
CYAN (DÉFAUT)
+ +
+ +
+
+
+ +
+
+ +
+
+ + +
+change_history +OBJ-01 +
+
+change_history +
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/Design-Proposal/r_glages_accessibilit_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/r_glages_accessibilit_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..ecf393b Binary files /dev/null and b/Design-Proposal/r_glages_accessibilit_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/r_glages_affichage_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/r_glages_affichage_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..fb2ec07 --- /dev/null +++ b/Design-Proposal/r_glages_affichage_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,355 @@ + + + + + +Réglages - Affichage V4 + + + + + + + + + +
+
+ RANSOM'S WAKE V4 +
+ +
+ + +
+
+ + + +
+ +
+
+

ENGINEERING / AFFICHAGE

+

Configuration des paramètres de rendu visuel.

+
+
+ + + +
+
+ +
+ +
+ +
+
+desktop_windows +

Mode d'affichage

+
+
+ + + +
+
+ +
+ +
+
+aspect_ratio +

Résolution

+
+
+ +
+
+ +
+
+
+sync +

Sync Verticale

+
+ +
+

Synchronise le framerate avec le taux de rafraîchissement de l'écran pour éviter les déchirements d'image.

+
+
+
+ +
+ +
+
+speed +

Framerate Limit

+
+
+
+Cible FPS +144 +
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+30 +60 +120 +144 +240 +UNLOCKED +
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/Design-Proposal/r_glages_affichage_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/r_glages_affichage_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..7ba6a4c Binary files /dev/null and b/Design-Proposal/r_glages_affichage_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/r_glages_audio_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/r_glages_audio_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..f155804 --- /dev/null +++ b/Design-Proposal/r_glages_audio_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,340 @@ + + + + + +Réglages - Audio V4 + + + + + + + + + + +
+
+ +
+

RÉGLAGES - AUDIO

+
+
+ + + + +
+
+ +
+
+ +
+
+
+

NIVEAUX SONORES

+

Ajustez les volumes des différents canaux du système.

+
+
+volume_up +
+
+
+ +
+
+volume_up + +
+
+ +
+
+80% +
+
+ +
+ +
+ +
+
+ +65% +
+ +
+ +
+
+ +90% +
+ +
+ +
+
+ +75% +
+ +
+ +
+
+ +50% +
+ +
+ +
+
+ +100% +
+ + +
+
+
+
+ +
+
+
+
+
+
+
SYS.AUDIO.DIAG.OK
+
+
+
+ + \ No newline at end of file diff --git a/Design-Proposal/r_glages_audio_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/r_glages_audio_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..9acbff8 Binary files /dev/null and b/Design-Proposal/r_glages_audio_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/r_glages_commandes_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/r_glages_commandes_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..3436f6c --- /dev/null +++ b/Design-Proposal/r_glages_commandes_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,347 @@ + + + + + +Réglages - Commandes V4 + + + + + + + + + + + + + + +
+ +
+ +
+
+

+settings_applications + COMMANDES SYSTÈME | V4 +

+

CONFIG: MATÉRIEL DE PONT ET MANŒUVRES

+
+
+ +
+ +
+ +
+
ACTION SYSTÈME
+
ENTRÉE (TOUCHE)
+
MÉTHODE D'ENTRÉE
+
MODIFICATION
+
+ +
+
+keyboard_double_arrow_up +Avancer +
+
+
+Z +
+
+
+ +
+ + +
+
+
+ +
+
+ +
+
+my_location +Tirer +
+
+
+Souris 1 +
+
+
+
+ + +
+
+
+ +
+
+ +
+ +
+
+radar +Sonar +
+
+
+E +
+
+
+
+ + +
+
+
+
+CONFLIT D'ENTRÉE +Assigné à: INTERAGIR +
+ +
+
+ +
+
+keyboard_double_arrow_down +Plongée +
+
+
+L-CTRL +
+
+
+
+ + +
+
+
+ +
+
+
+
+ + +
+ \ No newline at end of file diff --git a/Design-Proposal/r_glages_commandes_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/r_glages_commandes_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..95e6a28 Binary files /dev/null and b/Design-Proposal/r_glages_commandes_v4_1080p_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/ransom_s_wake_v3/DESIGN.md b/Design-Proposal/ransom_s_wake_v3/DESIGN.md new file mode 100644 index 0000000..a51ce59 --- /dev/null +++ b/Design-Proposal/ransom_s_wake_v3/DESIGN.md @@ -0,0 +1,139 @@ +--- +name: Ransom's Wake V3 +colors: + surface: '#09141e' + surface-dim: '#09141e' + surface-bright: '#2f3a46' + surface-container-lowest: '#040f19' + surface-container-low: '#111d27' + surface-container: '#15212b' + surface-container-high: '#202b36' + surface-container-highest: '#2b3641' + on-surface: '#d8e4f2' + on-surface-variant: '#bacac6' + inverse-surface: '#d8e4f2' + inverse-on-surface: '#26313d' + outline: '#859491' + outline-variant: '#3c4a47' + surface-tint: '#3adccc' + primary: '#66fdec' + on-primary: '#003732' + primary-container: '#40e0d0' + on-primary-container: '#006058' + inverse-primary: '#006a62' + secondary: '#ffe2ab' + on-secondary: '#402d00' + secondary-container: '#ffbf00' + on-secondary-container: '#6d5000' + tertiary: '#ffdfdc' + on-tertiary: '#690007' + tertiary-container: '#ffb9b2' + on-tertiary-container: '#a7191c' + error: '#ffb4ab' + on-error: '#690005' + error-container: '#93000a' + on-error-container: '#ffdad6' + primary-fixed: '#61f9e9' + primary-fixed-dim: '#3adccc' + on-primary-fixed: '#00201d' + on-primary-fixed-variant: '#005049' + secondary-fixed: '#ffdfa0' + secondary-fixed-dim: '#fbbc00' + on-secondary-fixed: '#261a00' + on-secondary-fixed-variant: '#5c4300' + tertiary-fixed: '#ffdad6' + tertiary-fixed-dim: '#ffb4ac' + on-tertiary-fixed: '#410003' + on-tertiary-fixed-variant: '#92030f' + background: '#09141e' + on-background: '#d8e4f2' + surface-variant: '#2b3641' +typography: + headline-lg: + fontFamily: Inter + fontSize: 32px + fontWeight: '700' + lineHeight: '1.2' + letterSpacing: -0.02em + headline-md: + fontFamily: Inter + fontSize: 24px + fontWeight: '600' + lineHeight: '1.2' + body-lg: + fontFamily: Inter + fontSize: 16px + fontWeight: '400' + lineHeight: '1.6' + body-sm: + fontFamily: Inter + fontSize: 14px + fontWeight: '400' + lineHeight: '1.5' + label-caps: + fontFamily: Inter + fontSize: 12px + fontWeight: '700' + lineHeight: '1' + letterSpacing: 0.1em + data-lg: + fontFamily: JetBrains Mono + fontSize: 18px + fontWeight: '500' + lineHeight: '1' + data-md: + fontFamily: JetBrains Mono + fontSize: 14px + fontWeight: '400' + lineHeight: '1' +spacing: + unit: 4px + gutter: 24px + margin: 32px + panel-padding: 16px +--- + +## Brand & Style +The design system for this product is rooted in **Industrial Maritime** aesthetics, specifically the utilitarian and heavy-duty nature of mid-century naval equipment. The UI evokes a sense of "historical inquiry" and "slow tension," prioritizing functional credibility over modern digital sleekness. + +The design style is a hybrid of **Tactile/Skeuomorphic** and **Minimalist Brutalism**. Interfaces should feel like physical control consoles—machined, painted, and worn by environment. Every element must appear to have a physical weight and a specific purpose, avoiding any unnecessary "sci-fi" decorative clutter. Surfaces mimic heavy gauge steel, while interactions feel deliberate and mechanical. + +## Colors +The palette is dominated by the **Deep Petrol Blue (#05101a)** base, representing the oppressive depth of the ocean and painted steel hulls. + +- **Primary (Desaturated Cyan):** Reserved for active states, selection markers, and focused elements. It should feel like a functional backlight or a steady signal. +- **Secondary (Amber):** Used for safety notices, warnings, and secondary call-outs. It mimics filament-bulb indicators. +- **Tertiary (Muted Red):** Strictly for critical failures, danger, and irreversible actions. +- **Neutrals:** Range from deep navy to weathered greys, used for structural borders and panel segregation. + +## Typography +Typography is split between the institutional **Inter** for narrative and labels, and **JetBrains Mono** for technical readouts and numerical data. + +To support full French localization, line heights are generous to accommodate frequent diacritics (é, à, ç). Labels should often use uppercase with increased tracking to mimic stamped metal nameplates. Headlines are heavy and condensed, appearing as if stenciled onto surfaces. + +## Layout & Spacing +The layout follows a **Fixed Grid** philosophy, organizing content into rigid "Technical Panels." This mimics the modular construction of a submarine bulkhead. + +- **Grid:** 12-column system for desktop, collapsing to 4-column for mobile. +- **Margins:** Heavy outer margins (32px+) to create a sense of being "contained" within a vessel. +- **Panel Logic:** Use thick 2px borders to define separate functional areas. Content should not float; it must be housed within a defined container. + +## Elevation & Depth +Depth is conveyed through **Tonal Layering** and physical metaphors rather than soft shadows. +- **Base Level:** Deep Petrol (#05101a). +- **Raised Panels:** Slightly lighter blue-grey with a "beveled" edge look achieved via a 1px top-light highlight and a 1px bottom-dark shadow. +- **Glass Elements:** Use high-blur backdrop filters (30px+) with a subtle "condensation" noise texture to simulate thick gauge viewports. +- **Recessed Areas:** Use inner shadows to simulate meters or inputs set deep into the dashboard. + +## Shapes +The shape language is strictly **Sharp (0px)** or exceptionally low-radius (2px) to emphasize the industrial, machined nature of the equipment. + +Avoid circles where possible; use octagons or chamfered corners for "round" elements like gauges or circular buttons. This reinforces the "angular shapes" requirement and maintains the mechanical aesthetic. + +## Components +- **Buttons:** Large, blocky, and high-contrast. The "Active" state must be indicated by a 2px solid border in Primary Cyan (#40e0d0), never just a glow. Include a "pressed" state that shifts the element down 2px. +- **Nameplates:** Use the `label-caps` style on a slightly lighter grey background with visible "screw head" icons in the corners. +- **Input Fields:** Recessed rectangles with a mono-font cursor. Use a subtle "scanline" overlay to simulate a low-refresh rate CRT monitor. +- **Selection Markers:** Use a vertical or horizontal "physical" bar next to the selected item in the Primary color. +- **Status Indicators:** Small square "lamps" that toggle between off (dark) and Secondary Amber or Tertiary Red. +- **Data Cards:** Heavy borders, clear headers using the stenciled headline style, and mono-space values for legibility. \ No newline at end of file diff --git a/Design-Proposal/sp_cifications_design_v4_ransom_s_wake.md b/Design-Proposal/sp_cifications_design_v4_ransom_s_wake.md new file mode 100644 index 0000000..3e3e374 --- /dev/null +++ b/Design-Proposal/sp_cifications_design_v4_ransom_s_wake.md @@ -0,0 +1,22 @@ +# Ransom's Wake - Direction Visuelle V4 + +## Vision +L'interface V4 incarne un équipement maritime industriel moderne, robuste et entretenu. Elle s'éloigne du jargon technique fictif pour privilégier une clarté opérationnelle absolue, renforçant l'immersion dans un environnement sous-marin isolé. + +## Palette & Identité +- **Fond :** Bleu pétrole sombre (Surface-dim/lowest). +- **Focus :** Cyan vibrant (Bordure structurelle + icône). +- **Alerte :** Ambre (Sécurité et modifications). +- **Danger :** Rouge (Actions irréversibles). +- **Typographie :** Inter (Sans-serif industrielle) pour la lisibilité, Roboto Mono pour les données numériques. + +## Accessibilité +- **Texte :** Minimum 16px pour le corps, 14px pour les labels secondaires. +- **Navigation :** 100% Clavier avec indicateurs de focus par forme (crochets ou bordures doubles). +- **Redondance :** Formes, icônes et couleurs combinées pour chaque état critique. +- **Interaction :** Zones tactiles/clic minimum 44x44px. + +## Hypothèses Produit à Confirmer +1. **Sortie en plongée :** Le retour au menu principal ou la fermeture du jeu pendant une mission déclenche les mêmes conséquences qu'un abandon (perte de cargaison non sécurisée) pour préserver l'enjeu du gameplay. +2. **Sauvegarde Manuelle :** Aucune sauvegarde manuelle en mission n'est prévue pour cette verticale, favorisant la tension des ressources. +3. **Traduction :** Les layouts prévoient une expansion de texte de 30% pour la localisation anglaise/allemande sans chevauchement. \ No newline at end of file diff --git a/Design-Proposal/variantes_confirmations_v3_ransom_s_wake/code.html b/Design-Proposal/variantes_confirmations_v3_ransom_s_wake/code.html new file mode 100644 index 0000000..9594dee --- /dev/null +++ b/Design-Proposal/variantes_confirmations_v3_ransom_s_wake/code.html @@ -0,0 +1,244 @@ + + + + + +Confirmation Variants - Ransom's Wake + + + + + + + + +
+ +
+
+ +
+
+
+
+warning +
+
+

NOUVELLE PARTIE

+

Une expédition est déjà en cours. Commencer une nouvelle partie écrasera vos données actuelles.

+
+
+
+ + +
+
+ +
+
+
+
+logout +
+
+

RETOUR MENU

+

Votre progression actuelle sera perdue. Le sous-marin reviendra au port.

+
+
+
+ + +
+
+ +
+
+
+
+power_settings_new +
+
+

QUITTER LE JEU

+

Voulez-vous vraiment quitter ? Les données non sauvegardées seront perdues.

+
+
+
+ + +
+
+ +
+
+
+
+settings_backup_restore +
+
+

RÉTABLIR PAR DÉFAUT

+

Rétablir tous les réglages d'origine ?

+
+
+
+ + +
+
+
+ \ No newline at end of file diff --git a/Design-Proposal/variantes_confirmations_v3_ransom_s_wake/screen.png b/Design-Proposal/variantes_confirmations_v3_ransom_s_wake/screen.png new file mode 100644 index 0000000..0644fa8 Binary files /dev/null and b/Design-Proposal/variantes_confirmations_v3_ransom_s_wake/screen.png differ diff --git a/Design-Proposal/variantes_confirmations_v4_1080p_ransom_s_wake/code.html b/Design-Proposal/variantes_confirmations_v4_1080p_ransom_s_wake/code.html new file mode 100644 index 0000000..0682959 --- /dev/null +++ b/Design-Proposal/variantes_confirmations_v4_1080p_ransom_s_wake/code.html @@ -0,0 +1,298 @@ + + + + + +Variantes de Confirmation V4 + + + + + + + + + +
+
+RANSOM'S WAKE V4 + +
+
+terminal +OP_ACTIVE +
+
+ +
+ +
+ +
+ +
+
+
+
+save +

Nouvelle Partie

+
+close +
+
+

Créer une nouvelle partie écrasera votre sauvegarde actuelle.

+

Cette action est irréversible.

+
+
+ + +
+
+
+VAR_01_NEW_GAME +
+
+ +
+
+
+
+
+
+logout +

Retour au Menu

+
+
+
+

Toute progression non sauvegardée depuis le dernier point de contrôle sera perdue.

+
+history +Dernière sauvegarde: 14:02 T-MINUS +
+
+
+ + +
+
+
+VAR_02_MAIN_MENU +
+
+ +
+
+
+
+
+
+power_settings_new +

Quitter le système

+
+
+
+

Voulez-vous vraiment fermer l'application et retourner au bureau de l'OS ?

+
+
+ + +
+
+
+VAR_03_QUIT_APP +
+
+ +
+
+
+
+
+
+settings_backup_restore +

Rétablir réglages

+
+
+
+

Cette action réinitialisera toutes les configurations audio, vidéo et de contrôle aux paramètres d'usine.

+
+
+ + +
+
+
+VAR_04_RESET_DEFAULTS +
+
+
+ \ No newline at end of file diff --git a/Design-Proposal/variantes_confirmations_v4_1080p_ransom_s_wake/screen.png b/Design-Proposal/variantes_confirmations_v4_1080p_ransom_s_wake/screen.png new file mode 100644 index 0000000..77827bc Binary files /dev/null and b/Design-Proposal/variantes_confirmations_v4_1080p_ransom_s_wake/screen.png differ diff --git a/README.md b/README.md index a092999..6f286f8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,31 @@ -# Ransom’s Wake — Prototypes +# Ransom’s Wake + +La première tranche de pilotage de la verticale est désormais la scène +principale. `Nouvelle partie` ouvre un court parcours sous-marin en vue cockpit : +le clavier pilote une coque inertielle, la souris déplace seulement le regard et +les snapshots du domaine alimentent les instruments physiques à 60 Hz. + +Les POC A et B restent disponibles comme références jetables ; aucun code de +production sous `game/` n'en dépend. + +## Fondations de production + +- Renderer Forward+ unique. +- Règles de plongée résolues selon améliorations, difficulté et accessibilité. +- Oxygène, batterie, coque, pression, alertes et éjection possédés par + `DiveSession`. +- Contrats séparés pour tick, télémétrie, collisions, outils et retour. +- `DiveUpdate` cohérent composé d'un snapshot et d'événements typés. +- Acteur sous-marin de production derrière des contrats de commande et de + télémétrie typés. +- Habitacle 3D solidaire de la coque : hublot occultant, consoles, commandes, + éclairage et emplacement réservé au sonar. +- Instruments physiques à valeurs réelles ; le HUD reste réservé aux alertes, + à l'éjection et aux états terminaux. +- Pause qui suspend les ticks, confirmations de sortie et premier remappage avec + détection des conflits. + +## Prototypes validés 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. @@ -28,7 +55,7 @@ Le sonar, les épaves, le commerce, la narration et les sauvegardes de campagne ## Prérequis -- Godot 4.7.1. +- Godot 4.7.2. ## Lancer @@ -36,15 +63,16 @@ Depuis le dossier du projet : godot --editor --path . -Ou lancer directement le POC : +Ou lancer directement la fondation de production : godot --path . -Le projet lance désormais le POC B. Pour relancer le POC A validé : +Pour relancer les POC validés : godot --path . --scene res://scenes/poc_a.tscn + godot --path . --scene res://scenes/poc_b.tscn -## Contrôles +## Contrôles de la tranche de production | Touche | Action | |---|---| @@ -52,13 +80,12 @@ Le projet lance désormais le POC B. Pour relancer le POC A validé : | A / D | Tourner à gauche / droite | | Espace / Ctrl | Monter / descendre | | Maj | Forte poussée | -| Souris | Caméra et orientation | +| Souris | Déplacer le regard dans le hublot uniquement | | 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 | +| R | Recentrer le regard | +| Échap | Pause / retour | + +Les commandes propres aux POC restent décrites dans leurs documents respectifs. ## Tests @@ -72,6 +99,9 @@ puis exécute les tests unitaires et d'intégration. Commandes individuelles pour diagnostic : godot --headless --path . --script res://tests/run_tests.gd + godot --headless --path . --script res://tests/unit/run_dive_foundation_tests.gd + godot --headless --path . --script res://tests/unit/run_vehicle_application_tests.gd + godot --headless --path . --script res://tests/integration/run_vertical_slice_foundation.gd Test d'intégration du POC B : @@ -86,4 +116,6 @@ Test d'intégration du POC B : - Bilan technique du POC B : docs/POC_B_TECHNICAL_BASELINE.md - Barrières techniques de production : docs/ENGINEERING_GUARDRAILS.md - Cadrage technique de la verticale : docs/VERTICAL_SLICE_TECHNICAL_PLAN.md +- Design system UI de la verticale : docs/UI_DESIGN_SYSTEM.md +- Propositions visuelles UI : Design-Proposal/README.md - Décisions d'architecture : docs/adr/ diff --git a/docs/GAME_DESIGN_PLAN.md b/docs/GAME_DESIGN_PLAN.md index 0a569a2..38969b4 100644 --- a/docs/GAME_DESIGN_PLAN.md +++ b/docs/GAME_DESIGN_PLAN.md @@ -5,15 +5,16 @@ ## Statut du document - Nature : plan directeur de conception et de production -- Version : 0.3 -- État : POC A et POC B validés ; cadrage technique de la verticale proposé +- Version : 0.4 +- État : POC A et POC B validés ; cadrage technique et boucle de la verticale + approuvés - 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). -Le périmètre et l'architecture proposés pour le prochain jalon sont décrits dans +Le périmètre et l'architecture retenus pour le prochain jalon sont décrits dans [VERTICAL_SLICE_TECHNICAL_PLAN.md](VERTICAL_SLICE_TECHNICAL_PLAN.md). ## Pitch @@ -65,7 +66,10 @@ Le jeu ne doit pas être présenté comme un simple cycle « trouver du loot, ve #### Chercher avant de récupérer -Trouver l’accès doit être plus intéressant que suivre un marqueur. Les coordonnées obtenues sont relativement précises, mais le relief, les grottes, le sédiment, les courants et les champs de débris cachent la cible. +Trouver l’accès doit être plus intéressant que suivre un marqueur. Une piste +donne une zone, le sonar du navire la resserre sans produire un point exact, +puis le relief, les grottes, le sédiment, les courants et les champs de débris +cachent la cible locale. #### Risque prévisible @@ -115,7 +119,7 @@ La première version ne comprend pas : 2. Observer les projecteurs, la caméra et le retour sonar. 3. Envoyer une impulsion et tourner autour d’un contact. 4. Reconstruire progressivement le relief et la forme du contact. -5. Comparer les indices avec la fiche du navire. +5. Examiner des faits concrets et les comparer au dossier et au registre. 6. Surveiller oxygène, batterie, profondeur et intégrité. 7. Choisir de continuer, revenir ou abandonner le véhicule. 8. Employer les outils contextuels dans le bon ordre. @@ -123,16 +127,17 @@ La première version ne comprend pas : ### 2.2 Boucle d’expédition -1. Sélectionner une piste. -2. Consulter la fiche historique, la bathymétrie et la météo prévue. -3. Choisir un capteur, deux outils et un module de survie. -4. Acheter le ravitaillement. -5. Tracer une route maritime. -6. Positionner localement le bateau sur la zone. -7. Déployer le sous-marin. -8. Cartographier, identifier, ouvrir et récupérer. -9. Revenir au bateau et transférer la cargaison. -10. Décider de poursuivre la tournée ou rentrer vendre. +1. Sélectionner une piste donnant une zone probable, jamais un point exact. +2. Consulter le contrat, le registre public et les archives acquises. +3. Préparer le sous-marin et le navire. +4. Tracer une route maritime sur la carte. +5. Positionner le navire et lancer des relevés sonar de surface imprécis. +6. Choisir une zone d'écho et déployer le sous-marin. +7. Cartographier, identifier, ouvrir et récupérer. +8. Revenir près du navire, faire surface et treuiller le sous-marin. +9. Remplir manuellement oxygène et batterie, puis vider le panier. +10. Analyser les données et décider de replonger ou de rentrer au port. +11. Au port, décharger la cale, vendre, réparer et améliorer. ### 2.3 Boucle de campagne @@ -164,21 +169,29 @@ Fonctions d’assistance : - limitation du roulis ; - caméra fixée à un point intérieur de la cabine ; - regard vertical limité par le champ utile du hublot ; +- regard horizontal limité par le champ utile du hublot ; +- recentrage manuel du regard, sans recentrage automatique par défaut ; - réduction indépendante du roulis et des mouvements de caméra pour l'accessibilité. +La verticale initiale n'implémente ni ballasts ni courants. Les courants entrent +au jalon V0.5 sous forme de zones légères ou fortes, à direction fixe ou +variable. Toute variation est progressive, déterministe et pilotée par une +graine. La gestion manuelle des ballasts reste une piste V2 ou V3. + ### 3.2 Contrôles clavier-souris par défaut - 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. +- Souris horizontale et verticale : regard limité dans le hublot, sans effet + sur l'orientation du sous-marin. - Espace/Ctrl : monter et descendre. - Maj : forte poussée, plus rapide mais moins efficace. - F : impulsion sonar. - E : interaction contextuelle. - 1 à 4 : sélection des modules. - Tab : agrandir ou fermer l'écran sonar. -- Commande d’éjection maintenue trois secondes : abandon volontaire. +- Action remappable : recentrer le regard. +- Commande d'éjection maintenue trois secondes : abandon volontaire. 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 @@ -195,6 +208,7 @@ Ces valeurs sont des cibles de départ pour les playtests, pas des constantes d | Ressource | Valeur Standard initiale | |---|---:| | Oxygène | environ 16 minutes | +| Batterie | 100 kWh | | Autonomie de propulsion | environ 1,2 km à vitesse de croisière | | Profondeur nominale coque I | 60 m | | Profondeur nominale coque II | 150 m | @@ -206,22 +220,33 @@ Ces valeurs sont des cibles de départ pour les playtests, pas des constantes d - Diminue continuellement pendant une plongée. - Ne s’arrête pas lorsque le sous-marin est immobile. -- Est affiché en minutes restantes au rythme actuel. +- Est mesuré en litres standards et en L/min. +- Affiche volume restant, capacité, pourcentage, consommation actuelle et + autonomie estimée au rythme courant. - Peut être assoupli par les options d’accessibilité. - À zéro, une courte séquence d’inconscience précède la perte du véhicule. ### 4.3 Batterie -- Représente uniquement la batterie de propulsion. -- Diminue lorsque le joueur applique de la poussée. -- Ne diminue pas à l’arrêt. -- Le sonar, les menus et les outils ne consomment pas cette batterie. +- Est mesurée en kWh ; la puissance instantanée est mesurée en kW. +- Alimente propulsion, forte poussée, stabilisation et outils de salvage. +- Ne diminue pas lorsque ces consommateurs sont inactifs. +- Le sonar, les menus, l'éclairage et les instruments ne consomment pas cette + batterie dans la verticale. - La forte poussée consomme davantage d’énergie par mètre. -- La montée, les forts courants et les manœuvres de dégagement augmentent le coût. -- L’interface estime en permanence la batterie nécessaire pour revenir par le trajet déjà cartographié. +- La montée, les futurs courants et les manœuvres de dégagement augmentent le + coût. +- L'interface affiche énergie restante, capacité, pourcentage, puissance totale + actuelle et autonomie au rythme courant. +- L’interface estime la batterie nécessaire pour revenir par le trajet connu ; + en Expert, cette estimation chiffrée est masquée. ### 4.4 Profondeur et intégrité +- L'intégrité et la pression sont deux mesures distinctes. +- L'interface affiche les points d'intégrité actuels et maximaux. +- Elle affiche aussi la pression extérieure actuelle et la pression maximale + recommandée, ainsi que leurs profondeurs équivalentes. - Jusqu’à 100 % de la profondeur certifiée : aucun dégât de pression. - Entre 100 et 115 % : craquements, distorsions et dégâts croissants. - Entre 115 et 125 % : perte rapide d’intégrité. @@ -250,18 +275,26 @@ Aucune information critique ne dépend uniquement de la couleur. | Paramètre | Exploration | Standard | Expert | |---|---:|---:|---:| -| Durée d’oxygène | ×1,5 | ×1 | ×0,85 | -| Autonomie batterie | ×1,35 | ×1 | ×0,9 | -| Dégâts de pression | ×0,5 | ×1 | ×1,5 | -| Identification | automatique à proximité | 2 indices sur 3 | 3 indices sans surbrillance | -| Retour | guidage complet | trace et estimation | trace sans surbrillance | -| Faux contacts | réduits | normaux | plus ambigus | +| Consommation d'oxygène | ×0,75 | ×1 | ×1,20 | +| Consommation de batterie | ×0,80 | ×1 | ×1,15 | +| Dégâts de collision | ×0,65 | ×1 | ×1,25 | +| Dégâts de pression | ×0,50 | ×1 | ×1,35 | +| Grâce avant rupture | ×1,50 | ×1 | ×0,75 | +| Points d'examen | surlignés | indication discrète | sans surbrillance | +| Indices avant hypothèse | 2 | 2 | 3 | +| Retour au navire | direction et distance permanentes | direction et distance permanentes | ping manuel directionnel | +| Remplacement | configuration restaurée | modèle basique | modèle basique | +| Franchise | 0 € | 500 € | 750 € | + +En Expert, un ping demandé au navire affiche uniquement sa direction pendant +environ quatre secondes, avec un délai d'environ vingt secondes avant une +nouvelle demande. Le coût chiffré du retour est remplacé par une alerte +qualitative. Le sonar de surface conserve le même comportement dans les trois +modes. ### 5.2 Réglages indépendants - Multiplicateurs d’oxygène, batterie et pression. -- Sévérité économique de la perte. -- Pause des jauges dans la carte et les fiches. - Taille du texte. - Contraste et palette du sonar. - Modes de daltonisme. @@ -270,8 +303,16 @@ Aucune information critique ne dépend uniquement de la couleur. - Sous-titrage des alarmes. - Remappage complet. +La difficulté peut changer uniquement hors plongée. La prochaine plongée +utilise un jeu de règles immuable résolu à son départ. Aucun changement ne +modifie rétroactivement une perte, une dette ou une récompense. + Aucun succès ne doit être verrouillé par ces options. +Le dossier complet met toujours la plongée en pause : aucun tick, mouvement ou +coût de ressource n'est produit pendant sa lecture. Un résumé compact des faits +récents reste visible dans le HUD sans ouvrir le dossier. + --- ## 6. Mort, assurance et sauvegarde @@ -289,25 +330,54 @@ Causes : ### 6.2 Conséquences -- Autosauvegarde immédiate au résultat. -- Paiement d’une franchise. -- Remplacement par une coque de même classe. -- Plans et améliorations permanentes conservés. -- Loadout standard fourni. -- Modules avancés à réinstaller contre une fraction de leur valeur. +- Le protagoniste remonte sur son navire, qui reste positionné sur le site. +- Aucune nouvelle plongée n'est possible tant qu'il n'a pas navigué jusqu'au + port et obtenu un remplacement au chantier naval. +- En Exploration, l'assurance restaure la configuration perdue sans franchise. +- En Standard et Expert, le remplacement est un sous-marin basique : toutes les + améliorations et tous les modules installés sont perdus. +- Les plans débloqués, le navire et ses améliorations restent acquis. +- La franchise vaut 500 € en Standard et 750 € en Expert. - Crédit automatique sans intérêt si le joueur est insolvable. -- 20 % des gains suivants remboursent cette dette. -- Une balise marque le sous-marin perdu. -- Seule une partie de la cargaison peut être récupérée. +- 20 % de chaque paiement de contrat et de chaque vente rembourse cette dette ; + un remboursement manuel supplémentaire reste possible. +- La cargaison déjà transférée dans la cale du navire reste acquise ; le + contenu du panier du sous-marin est perdu. - Les objets indispensables à la campagne retournent à leur point d’extraction s’ils n’avaient pas atteint le bateau. -Les sauvegardes de port protègent contre les bugs, pas contre les conséquences normales d’une perte. +Les observations de la plongée sont conservées lors d'une éjection volontaire, +mais perdues après destruction de la coque ou épuisement de l'oxygène. La +récupération physique du sous-marin perdu reste hors de la verticale. + +Les sauvegardes effectuées hors plongée protègent contre les bugs, pas contre +les conséquences normales d’une perte. --- ## 7. Sonar et recherche -### 7.1 Principe +### 7.1 Sonar du navire + +Une piste narrative ouvre une zone probable, jamais la position exacte d'une +épave. Le navire peut y lancer des relevés de surface déterministes : + +- portée initiale d'environ 400 m autour du navire ; +- première zone d'incertitude d'environ 150 m ; +- recoupement depuis une autre position ramenant cette zone vers 75 m ; +- précision minimale de 50 m, jamais une coordonnée exacte ; +- aucune silhouette, matière ou identité révélée ; +- un nouveau relevé au même endroit donne le même résultat ; +- aucun coût d'argent ou de carburant dans la verticale, seulement une courte + animation de balayage ; +- les zones d'écho sont persistantes, mais pas les données sonar brutes. + +Dans la verticale, chaque écho persistant conduit à une épave ou à un site +documentable. Les échos produits par des rochers restent une piste post-V1 et +ne seront ajoutés que si les tests montrent une déduction intéressante plutôt +qu'une plongée vide. Les améliorations futures peuvent agir sur la portée, la +résolution ou la séparation des contacts, jamais sur leur identité. + +### 7.2 Sonar du sous-marin Le sonar est le principal risque technique et doit être validé avant la production de la campagne. @@ -319,8 +389,12 @@ Le sonar est le principal risque technique et doit être validé avant la produc 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. +9. L'échelle de couleur est relative à chaque scan ; la vue agrandie affiche + une légende avec les valeurs réelles en mètres. +10. Plusieurs anomalies spatiales restent des contacts séparés et ne sont + jamais fusionnées par une moyenne globale. -### 7.2 Matériaux sonar +### 7.3 Matériaux sonar - Roche. - Sable et vase. @@ -330,7 +404,7 @@ Le sonar est le principal risque technique et doit être validé avant la produc - Vide ou cavité. - Matériau surnaturel tardif. -### 7.3 Progression sonar +### 7.4 Progression sonar - Sonar I : courte portée, relief grossier et impulsions manuelles. - Sonar II : portée et densité supérieures, remplacement plus rapide. @@ -343,7 +417,7 @@ Modules de capteur : - Magnétomètre : excellent pour acier, canons et ancres. - Analyse spectrale : révèle certains signaux surnaturels tardifs. -### 7.4 Faux contacts +### 7.5 Faux contacts Les faux contacts sont rares et utiles : @@ -357,7 +431,7 @@ Les faux contacts sont rares et utiles : Une recherche ne doit pas se terminer par vingt minutes dans une zone entièrement vide. -### 7.5 Manières de cacher une épave +### 7.6 Manières de cacher une épave - Grotte révélée par un courant sortant. - Écho sous un surplomb. @@ -373,6 +447,8 @@ Une recherche ne doit pas se terminer par vingt minutes dans une zone entièreme ## 8. Identification et fiches historiques +Une zone de recherche contient plusieurs épaves candidates. La cible n'est +jamais distinguée des leurres par le sonar ou par une condition d'interface. Chaque épave majeure possède au minimum trois preuves parmi : - dimensions et silhouette ; @@ -393,7 +469,45 @@ Chaque fiche distingue : - preuves encore manquantes ; - bibliographie. -En mode Exploration, les preuves sont surlignées et enregistrées automatiquement à proximité. En Standard, deux preuves doivent être scannées. En Expert, les trois sont requises sans surbrillance. +Les informations certifiées sont fiables. Une estimation ou un témoignage est +explicitement signalé comme incertain ; le registre peut être incomplet, mais ne +ment jamais silencieusement au joueur. + +Le registre public d'un secteur est gratuit. Un pack d'archives détaillées, +acheté une seule fois en ville, ajoute photographies, dimensions, équipements +et zones approximatives. Il reste facultatif et persiste dans la sauvegarde. Les +épaves leurres y figurent ; une cible récente ou secrète peut n'apparaître que +comme bâtiment disparu. + +Pour examiner un indice, le joueur approche à environ 8 à 12 m, regarde la zone +depuis l'habitacle et maintient l'interaction environ 1,5 seconde. La zone de +visée reste généreuse. Exploration surligne les points, Standard emploie une +indication discrète et Expert ne les surligne pas. + +Chaque observation enregistre un fait littéral. Le dossier le rapproche des +fiches et affiche `Concorde`, `Contredit` ou `Non déterminant`, sans calculer de +probabilité ni choisir la cible. Après deux observations en Exploration et +Standard, trois en Expert, le joueur peut désigner n'importe quelle épave comme +cible présumée. Cette hypothèse reste révisable et n'est jamais validée sous +l'eau. Une conclusion erronée ne devient un échec qu'au moment de transmettre +explicitement le rapport final. + +La carte globale distingue : + +- aucun marqueur avant localisation ; +- `Épave non identifiée` après sécurisation de ses coordonnées ; +- le nom du navire après identification ; +- une icône colorée, doublée d'un marqueur d'activité, lorsqu'une action connue + reste à accomplir ; +- une icône en noir et blanc lorsqu'aucune activité connue ne reste. + +Une épave identifiée reste toujours revisitable. Une activité future peut lui +rendre sa couleur ; un secret encore inconnu ne colore jamais l'icône et ne se +révèle donc pas par l'interface. + +Toute épave déjà inscrite sur la carte devient un site de plongée sélectionnable. +Le navire peut s'y repositionner et le HUD fournit un repère local pour la +retrouver sans recommencer sa recherche initiale au sonar. --- @@ -429,6 +543,17 @@ Les cinq premiers sont introduits pendant l’acte I. Les trois derniers apparai Découpes, étais, objets déplacés et accès ouverts persistent entre les plongées. +Les outils consomment la batterie du sous-marin selon leur puissance électrique +réelle. La puissance de l'outil et celle des propulseurs de stabilisation sont +additionnées. Une opération interrompue par un mauvais alignement se met en +pause au lieu de recommencer ; la stabilisation aide le joueur sans figer le +sous-marin. + +La verticale emploie un cutter de référence à 30 kW pendant environ 12 secondes +(0,10 kWh hors stabilisation) et un bras simple à 8 kW pendant environ six +secondes (0,013 kWh hors stabilisation). Ces valeurs sont des cibles de +playtest. + Une erreur peut : - abîmer la valeur d’un objet ; @@ -447,11 +572,29 @@ Toutes les épaves, sauf la caravelle finale, doivent être terminables en une o ### 10.1 Inventaires -- Panier du sous-marin : arbitrage immédiat. -- Cale du bateau : choix de poursuivre ou rentrer. +- Panier du sous-marin : 6 unités initiales et arbitrage immédiat. +- Cale du navire : 12 unités initiales, puis améliorations à 18 et 24. - Stockage de port : illimité pour les objets conservés. -Les objets occupent 1, 2, 4 ou 6 cases. L’argent, les preuves et les journaux ne prennent aucune place. +Les objets occupent 1, 2, 4 ou 6 unités. L’argent, les preuves et les journaux +ne prennent aucune place. + +Faire surface ne transfère rien. À proximité du navire, l'action `Treuiller` +replace le sous-marin sur son berceau et termine la plongée. Le joueur doit +ensuite cliquer séparément sur `Recharger la batterie`, `Remplir l'oxygène` et +`Vider le panier`. Ces services sont gratuits dans la verticale, mais jamais +automatiques. Replonger sans les utiliser conserve les ressources et la +cargaison dans leur état actuel. + +`Vider le panier` transfère tout en une action si la cale dispose de la place +nécessaire. Au port, `Décharger la cale` transfère manuellement son contenu vers +le stockage illimité. Un objet de campagne n'est sécurisé qu'après son transfert +hors du panier. + +Un objet secondaire peut être relâché sous l'eau pour libérer de la place et +reste récupérable à cette position. Un objet indispensable ne peut être ni vendu +ni détruit ; s'il est perdu avec le sous-marin avant transfert, il retourne à son +point d'extraction. ### 10.2 Catégories @@ -466,10 +609,22 @@ Les objets occupent 1, 2, 4 ou 6 cases. L’argent, les preuves et les journaux ### 10.3 Ports +Les ports sont des écrans illustrés et non des espaces parcourus à pied. Leur +présentation est incarnée par des lieux et des interlocuteurs : nom de +l'entreprise plutôt que `Commanditaire`, `Archives maritimes`, `Chantier naval` +et `Le Comptoir` tenu par un courtier maritime aux méthodes ambiguës. + +Le Chantier naval sépare clairement les onglets `Sous-marin` et `Navire`. Il +répare la coque seulement au port et installe les améliorations correspondantes. +Le Comptoir achète les objets secondaires sélectionnés ; aucune vente n'est +automatique et aucun objet narratif n'apparaît comme vendable. Il peut proposer +quelques objets ou informations utiles, mais aucun onglet d'achat vide ou achat +obligatoire n'est ajouté à la verticale. + #### Port industriel - Meilleurs prix pour métal, mécanique et technologie. -- Atelier principal. +- Chantier naval principal. - Assurance et réparations. #### Port touristique et collectionneur @@ -487,27 +642,50 @@ Chaque port possède des préférences fixes et des événements temporaires ann ### 10.4 Principes économiques -- Une mission principale réussie rembourse route, ravitaillement et deux plongées. +- Une mission principale réussie finance la poursuite et une amélioration utile. - Les trésors et objectifs optionnels financent les upgrades. -- Une seule monnaie. +- Une seule monnaie, l'euro, affichée sans centimes au format français. - Aucun craft. - Les plans peuvent exiger un seuil de relation. - Une aide portuaire évite toujours le blocage financier. - Le prix commercial du jeu n’est pas décidé dans ce document. +Valeurs initiales de la verticale, à ajuster par playtest : + +| Élément | Valeur | +|---|---:| +| Argent initial | 500 € | +| Pack d'archives du secteur | 500 € | +| Premier contrat | 1 000 € | +| Objet secondaire de référence | environ 200 € | +| Cale 12 vers 18 | 750 € | +| Réparation | 4 € par point d'intégrité | + --- ## 11. Surface, voyage et temps - Carte maritime interactive. - Route tracée entre ports, secteurs et mouillages. -- Pilotage manuel seulement près d’un port ou d’une zone de plongée. +- Dans la verticale, déplacement du navire sur la carte avec transition courte, + sans pilotage 3D, carburant ni attente en temps réel. +- Le navire existe néanmoins comme repère 3D simplifié dans la zone de plongée, + avec silhouette, feux, point de récupération et treuil. +- Le pilotage manuel et une représentation détaillée du navire restent une + piste V2 ou V3 derrière le même modèle de position persistante. - Aucun combat naval. -- Chaque voyage consomme carburant et capacité logistique. -- Chaque déplacement ou déploiement avance un tour d’expédition abstrait. -- Les trois prochains états de météo et de lumière sont visibles. -- Il n’existe pas de calendrier continu. -- Replonger restaure oxygène et batterie, mais coûte un ravitaillement et un tour. +- Pendant une plongée, le navire reste fixe à la position choisie. +- En Exploration et Standard, sa direction et sa distance sont toujours + disponibles. En Expert, seul un ping directionnel temporaire peut être + demandé. +- Le treuillage exige une remontée à faible vitesse dans un rayon initial + d'environ 25 m. +- Les fonctions de base du navire sont la navigation, le sonar de surface, + l'analyse des données, les archives embarquées, les communications, le treuil, + la recharge, l'oxygène et la cale. +- Ces instruments ne s'usent pas, ne tombent pas en panne et ne consomment pas + de ressources dans la verticale. +- Il n’existe pas de calendrier continu dans la verticale. - La corruption remplace progressivement davantage d’états lumineux par du crépuscule ou de la nuit. --- @@ -562,7 +740,28 @@ Ancien technicien d’inspection offshore. Il maîtrise machines et sonar, mais Le précédent opérateur de la société a fait naufrage quelques mois auparavant. Le joueur est engagé pour récupérer le matériel coûteux de son navire de prospection. -Le manifeste officiel décrit une cartographie banale. Les journaux récupérés montrent que l’équipage cherchait un U-Boot non répertorié. +La société sait dès le départ qu'elle recherche la rançon et emploie le +contrat comme couverture. Le manifeste officiel décrit une cartographie banale ; +son objectif réel est de récupérer les derniers relevés du précédent opérateur sur +un U-Boot non répertorié et d'empêcher leur diffusion. + +La première zone contient le navire de prospection comme épave majeure et deux +épaves leurres légères documentées dans le registre. La cible récente est +absente des épaves localisées du registre ; sa fiche technique vient de la +société. Elle est identifiable par son portique, son immatriculation partielle +et ses équipements spécifiques. Elle contient : + +- un module de cartographie bathymétrique de 4 unités, demandé par la société ; +- le journal ou les données du précédent opérateur, sans volume de cargaison ; +- une première pièce mystérieuse de 1 unité ; +- un petit objet secondaire optionnel de 1 unité. + +Après transfert du module vers le navire, son contenu est sauvegardé sans +risque de perte. Le joueur déclenche manuellement son analyse à bord ; elle +révèle les trajectoires non déclarées et la zone probable du U-Boot. Le module +physique peut ensuite être remis explicitement à la société pour obtenir le +paiement. La société peut prétendre que l'ancien opérateur agissait seul, mais +les données installent le doute. ### 13.3 Chaîne narrative @@ -581,7 +780,7 @@ L’enquête remonte donc la chaîne des propriétaires à rebours. Après le U-Boot : -- le joueur comprend qu’un trésor est recherché ; +- le joueur prouve que la société recherchait délibérément le trésor ; - il confronte son responsable ; - la société exige les données ; - le contrat est résilié ; @@ -766,8 +965,10 @@ Deux niveaux de détail par grande épave. Collisions simplifiées séparées de Écrans illustrés animés : - vue du quai ; -- atelier ; -- marché ; +- entreprise ou contact narratif nommé ; +- Archives maritimes ; +- Chantier naval avec onglets Sous-marin et Navire ; +- Comptoir du courtier maritime ; - carte ; - portraits des personnages. @@ -793,9 +994,11 @@ Aucun personnage 3D marchable. ### 20.1 Pile -- Godot 4.7.1 stable. +- Godot 4.7.2 stable. - GDScript. -- Renderer Forward+ visé pour la production ; GL Compatibility utilisé par le POC A pour faciliter les premiers tests multiplateformes. +- Renderer Forward+ unique pour la production ; les POC validés sous GL + Compatibility restent des références historiques, pas une configuration de + jeu prise en charge. - Blender. - Export glTF 2.0. - Git. @@ -808,35 +1011,45 @@ La version mineure du moteur est figée pendant chaque jalon. Toute mise à jour | Type | Contenu | |---|---| | WreckDefinition | ID, époque, profondeur, preuves, outils, sources | -| ContractDefinition | donneur, prérequis, délai, paiement, conséquences | +| ContractTerms | donneur, informations visibles, objectif officiel, paiement | +| CampaignObjective | déclencheurs réels, progression et révélations | +| ArchivePackDefinition | secteur, prix et fiches enrichies | | ItemDefinition | volume, catégorie, valeur, légalité, unicité | | UpgradeDefinition | slot, prix, prérequis, effets | +| DifficultyProfile | consommation, dégâts, aides et assurance | +| VesselLocation | région, repère, position, cap et état du navire | | MarketProfile | préférences, événements, restrictions | | DialogueDefinition | textes, conditions, choix, flags | | DiveSiteDefinition | scène, météo, courants, faux contacts, graine | -| SaveGame | version, économie, relations, épaves, scans, cargaison | +| SaveGame | version, économie, relations, épaves, échos sémantiques, cargaison | -### 20.3 Services globaux +### 20.3 Composition des services -- GameState. -- SaveService. -- SceneRouter. -- AudioManager. -- LocalizationService. -- ContractService. -- MarketService. +Aucun service de gameplay n'est accessible par autoload dans la première +production. Le nœud racine de composition possède les services à longue durée de +vie et injecte explicitement leurs contrats aux cas d'usage et aux scènes, +conformément à `docs/adr/0008-composition-owned-service-lifetimes.md`. + +Une longue durée de vie ne justifie pas un accès global. Tout futur autoload, +notamment pour la sauvegarde, l'audio ou l'état de campagne, exige un ADR. ### 20.4 Sonar technique -- 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. +- Buffer compact en tableaux parallèles de positions, normales, intensités, + matériaux, élévations réelles et statuts d'observation. +- Surface continue par `ArrayMesh` indexé, normales lissées et palette + d'altitude relative au scan avec légende en mètres. - Caméra sonar oblique et fixe, cap vers le haut. +- Repère figé à la pose du capteur lors de l'émission. - 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. +- Clustering spatial déterministe pour conserver plusieurs anomalies distinctes. +- Comparaison entre la réécriture bathymétrique de référence et une passe + frontale par rayons, derrière un contrat commun ; une seule acquisition finale. +- Sonar de surface séparé produisant uniquement des zones d'écho sémantiques. +- Bathymétrie limitée à une surface unique en V1 ; les véritables grottes, + surfaces superposées et surplombs critiques sont différés conformément à + `docs/adr/0010-single-surface-bathymetry-for-v1.md`. - Bruit déterministe selon distance, angle, sonar et difficulté après validation de la lecture fondamentale. @@ -845,12 +1058,24 @@ La version mineure du moteur est figée pendant chaque jalon. Toute mise à jour - État explicite par interaction : intacte, accessible, terminée, endommagée. - Transformations sauvegardées seulement pour les éléments identifiés. - Aucune sérialisation de physique arbitraire. +- Aucun maillage, point brut ou couleur sonar n'est sauvegardé. +- Les zones d'écho de surface, positions d'épaves connues et observations sont + des données sémantiques versionnées. - Micro-sites régénérés depuis la graine. - Version du format obligatoire. - Migrations testées avant chaque build public. -- Trois profils de campagne. -- Sauvegardes automatiques tournantes. -- Import de la sauvegarde de démo. +- Une seule campagne narrative, avec plusieurs sauvegardes choisies par le + joueur. +- Trois autosauvegardes sémantiques : avant plongée, après plongée et progression + de campagne ; chacune conserve une sauvegarde technique de secours. +- Jusqu'à dix sauvegardes manuelles, uniquement hors plongée. +- Format JSON explicite, versionné, écrit par fichier temporaire puis validé et + remplacé atomiquement. +- Une sauvegarde inclut notamment argent, dette, position du navire, contrats, + épaves, observations, cargaison, stockage, archives et améliorations. +- Aucune restauration de la physique d'une plongée en cours. Après crash ou + fermeture forcée, la partie revient à l'état précédant la plongée sans perte + automatique du véhicule. --- @@ -884,6 +1109,7 @@ La version mineure du moteur est figée pendant chaque jalon. Toute mise à jour ### 22.1 Tests automatisés - Consommation d’oxygène et de batterie. +- Addition de la puissance des outils et de la stabilisation. - Estimation du retour. - Seuils de profondeur. - Franchise et dette. @@ -896,7 +1122,7 @@ La version mineure du moteur est figée pendant chaque jalon. Toute mise à jour ### 22.2 Tests d’intégration -- Port vers plongée puis retour et vente. +- Port vers relevé de surface, plongée, treuillage, entretien manuel et vente. - Destruction, remplacement et récupération. - Changement de difficulté en cours de partie. - Sauvegarde chargée sur un autre OS. @@ -925,7 +1151,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 de relief, lecture d'ouverture, anomalie | -| Verticale | 4 à 6 mois | Une épave, port, contrat, vente, amélioration | +| Verticale | 4 à 6 mois | Une épave majeure, deux leurres légers, 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 | | Accès anticipé | vers 18 à 24 mois | Acte I complet | diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md new file mode 100644 index 0000000..9f9037a --- /dev/null +++ b/docs/UI_DESIGN_SYSTEM.md @@ -0,0 +1,262 @@ +# Design system UI — verticale jouable + +## Statut + +- Version : 0.1. +- Date : 7 août 2026. +- État : baseline acceptée pour les menus et réglages de la verticale. +- Référence visuelle principale : menu principal V4 sous `Design-Proposal/`. +- Portée : menus, réglages, confirmations et états système de la verticale. + +Ce document est la source autoritaire. Les PNG sous `Design-Proposal/` sont des +références d'ambiance et de composition. Leurs contradictions, textes fictifs et +données simulées ne deviennent pas des exigences. Les HTML associés sont des +prototypes de présentation ; aucun code de production ne les importe ou ne les +copie. + +## 1. Intention + +L'interface évoque un équipement maritime industriel moderne, robuste, entretenu +et réparable. Elle combine la sobriété d'un poste de travail embarqué avec +quelques détails d'instrumentation plus ancienne. + +Le joueur doit percevoir : + +- l'isolement et la pression du milieu sous-marin ; +- une technologie spécialisée mais compréhensible ; +- une tension lente, annoncée et jamais décorativement alarmiste ; +- la matérialité du cockpit sans confusion avec un système militaire futuriste. + +Sont exclus du langage de production : faux systèmes d'exploitation, gestion de +flotte, réacteur, jargon de protocole, diagnostics sans fonction, interface de +combat et données de télémétrie inventées. + +## 2. Références retenues + +| Référence | Usage retenu | Ne pas reprendre | +|---|---|---| +| Menu principal V4 | ancrage visuel, panneau, calme et hiérarchie | format exporté et arrière-plan comme contrat exact | +| Menu pause V4 | contraste avec la scène en arrière-plan | navigation anglaise et protocole d'évacuation comme écran par défaut | +| Réglages Audio V4 | densité, curseurs et groupement des canaux | statut système décoratif | +| Réglages Commandes V4 | ligne d'action, conflit et remappage | action de tir, touches fictives et coque `DIVE_OS` | +| Réglages Accessibilité V4 | regroupement et aperçu immédiat | radar circulaire et choix d'une couleur isolée comme palette | +| Réglages Affichage V4 | modes d'écran, résolution, VSync et limite FPS | navigation Fleet/Logs/Nav-Com/Engineering | +| Confirmation d'abandon V4 | texte des conséquences et hiérarchie du danger | ordre inversé des actions et télémétrie décorative | +| Planche de composants V4 | états, bordures et redondance des signaux | réacteur, unités et libellés fictifs | + +Le catalogue complet et le statut de chaque proposition se trouvent dans +`Design-Proposal/README.md`. + +## 3. Couleurs + +Les valeurs ci-dessous sont des points de départ. Elles seront vérifiées sur le +renderer de production et corrigées si le contraste mesuré l'exige. + +| Rôle | Valeur de départ | Usage | +|---|---|---| +| Fond principal | `#09141e` | écran et voile de pause | +| Fond profond | `#040f19` | champs et zones en retrait | +| Panneau | `#15212b` | conteneur principal | +| Panneau actif | `#202b36` | survol et sélection secondaire | +| Texte principal | `#d8e4f2` | titres et actions | +| Texte secondaire | `#bacac6` | descriptions et métadonnées utiles | +| Focus | `#40e0d0` | bordure, crochet et repère de sélection | +| Avertissement | `#ffbf00` | risque réversible ou attention requise | +| Danger | `#ffb4ab` | perte, abandon et action irréversible | +| Bordure | `#3c4a47` | structure et séparation | + +La couleur ne suffit jamais à transmettre un état. Focus, avertissement et +danger combinent au moins une forme ou bordure, une icône et un texte. + +## 4. Typographie et densité + +- Corps : sans-serif lisible, 16 px minimum à 1080p. +- Libellé fonctionnel : 14 px minimum. +- Donnée chiffrée : monospace réservée aux valeurs réellement utiles. +- Titre d'écran : 24 à 32 px selon la hiérarchie. +- Zone interactive : 44 × 44 px minimum. +- Marge sûre : 32 px à 1080p. +- Expansion de localisation : réserver au moins 30 % de largeur supplémentaire. + +Inter et JetBrains Mono sont les candidats visuels actuels. Leur ajout au jeu +nécessitera des fichiers locaux et l'enregistrement de leurs licences. Le rendu +d'une police distante dans les HTML n'est pas une dépendance autorisée. + +Les capitales espacées sont réservées aux titres courts et aux actions. Les +paragraphes et conséquences restent en casse normale. Les microtextes +décoratifs sont supprimés. + +## 5. Formes, mouvement et profondeur + +- Panneaux rectangulaires, angles droits ou rayon maximal de 2 px. +- Bordures franches plutôt qu'une lueur seule. +- Focus clavier visible par une bordure double ou des crochets persistants. +- Appui représenté par un léger déplacement ou changement tonal. +- Flou, bloom et condensation restent décoratifs et désactivables. +- Aucun scintillement ou clignotement rapide. +- Les scanlines globales sont interdites. +- La réduction des mouvements désactive parallaxe, pulsation décorative et + transition non essentielle. + +## 6. Coque de navigation commune + +Tous les réglages partagent la même coque : titre, onglets, contenu, retour et +barre d'actions. Ils ne simulent ni un logiciel distinct ni un autre poste du +véhicule. + +Onglets de la verticale : + +1. Commandes ; +2. Accessibilité ; +3. Audio ; +4. Affichage. + +Actions persistantes : `Retour`, `Rétablir par défaut`, `Annuler` et +`Appliquer`. Un changement non appliqué porte un repère de forme en plus de sa +couleur. Les réglages pouvant être prévisualisés sans risque s'appliquent à +l'aperçu immédiatement ; leur persistance suit toujours la commande explicite. + +L'ordre de focus suit l'ordre de lecture : titre ou retour, onglets, contenu de +haut en bas, puis actions. Le retour arrière restaure le focus sur le contrôle +qui a ouvert l'écran. + +## 7. Écrans autoritaires + +### Menu principal + +Ordre des actions : + +1. `Continuer` ; +2. `Nouvelle partie` ; +3. `Réglages` ; +4. `Quitter`. + +Avec une sauvegarde valide, `Continuer` reçoit le focus. Sans sauvegarde, il +reste lisible, affiche `Aucune sauvegarde` sans état d'erreur et `Nouvelle +partie` reçoit le focus. + +### Pause + +Le vrai menu pause suspend les ticks de plongée, donc les consommations et les +dégâts temporels. L'écran sonar agrandi ne met pas le jeu en pause. + +Ordre des actions : + +1. `Reprendre` ; +2. `Réglages` ; +3. `Abandonner la plongée` ; +4. `Retour au menu principal` ; +5. `Quitter le jeu`. + +`Reprendre` reçoit toujours le focus initial. L'abandon ouvre une confirmation ; +il n'affiche pas un faux état critique et n'est jamais déclenché directement. + +### Confirmation d'abandon + +Titre : `Abandonner la plongée ?` + +Message : `Vous serez éjecté puis évacué. Le sous-marin et sa cargaison non +sécurisée resteront au fond. Une franchise sera appliquée.` + +Actions, dans cet ordre : `Rester en plongée`, puis `S'éjecter et abandonner`. +L'action sûre reçoit le focus. Une confirmation n'invente aucune profondeur, +intégrité ou autre télémétrie. + +### Commandes + +L'écran présente les actions abstraites du jeu et détecte les conflits. Il ne +contient aucune action de tir. La première baseline utilise : + +| Entrée | Action | +|---|---| +| W / S | avancer / reculer | +| A / D | gouverner à gauche / droite | +| Souris | regard horizontal et vertical limité, sans modifier le cap | +| Espace / Ctrl | monter / descendre | +| Maj | forte poussée | +| F | impulsion sonar | +| E | interaction contextuelle | +| 1 à 4 | sélectionner un module | +| Tab | agrandir ou fermer le sonar | +| action d'éjection maintenue | abandon volontaire | + +Les valeurs affichées proviennent du service de réglages. Le joueur peut +remapper, résoudre un conflit, restaurer les valeurs par défaut et choisir +`Maintenir` ou `Basculer` pour les actions prolongées compatibles. + +### Accessibilité et sonar + +L'aperçu sonar emploie un échantillon de calibration explicitement fixe. Il +représente une surface bathymétrique continue en perspective, jamais un radar +circulaire. Une palette est un ensemble cohérent de valeurs de profondeur et +d'anomalie, pas le choix d'une couleur unique. + +La palette normale conserve la grammaire validée : bleu profond, cyan et vert +intermédiaires, jaune et rouge hauts, orange pour une anomalie. Les variantes +accessibles ajoutent luminosité, contours ou formes ; elles ne suppriment pas la +lecture de l'élévation. + +Réglages minimaux : taille du texte, mouvement caméra, roulis, flashs et +distorsions, sous-titrage des alarmes, contraste et palette sonar, modes de +daltonisme, maintien ou bascule. + +### Audio et affichage + +Canaux audio : principal, musique, ambiance, véhicule, interface et alarmes. +Les alarmes restent audibles et testables indépendamment. + +Affichage : fenêtré, sans bordure, plein écran, résolution, VSync, limite de +framerate et qualité graphique. Forward+ reste l'unique renderer : aucun choix +de renderer n'est exposé. Les préréglages de qualité agissent notamment sur les +particules, les ombres, la distance d'affichage et le post-traitement ; les +effets atmosphériques coûteux suivent la même politique après mesure. Les +valeurs proposées viennent de la plateforme et de configurations validées ; +l'interface n'invente pas des modes indisponibles. Une qualité réduite ne +supprime jamais une information nécessaire à la navigation ou au sonar. + +## 8. États système + +Libellés autoritaires : + +- `Sauvegarde en cours…` ; +- `Sauvegarde terminée` ; +- `Chargement…` ; +- `Aucune sauvegarde` ; +- `Sauvegarde invalide` ; +- `Version de sauvegarde incompatible`. + +L'absence de sauvegarde est neutre, une sauvegarde invalide est un avertissement +et une incompatibilité irrécupérable est une erreur. Les détails techniques +restent dans les logs et ne remplacent pas le message joueur. + +`Continuer` reprend au dernier état stable pris en charge. La position physique, +les collisions, les particules et le maillage sonar d'une plongée ne sont pas +sérialisés dans la verticale. + +## 9. Contrat d'implémentation + +- La présentation consomme des snapshots ou réglages typés ; elle n'invente + aucune valeur de gameplay. +- Une interaction UI émet une commande d'application ; elle ne mute ni la + plongée ni la campagne directement. +- Le menu pause contrôle la progression par un cas d'usage explicite, pas en + cachant simplement la scène. +- Les réglages sont possédés par un service unique et sauvegardés séparément de + la campagne. +- Les illustrations et icônes deviennent des assets locaux avec licence connue. +- Aucun HTML, CDN, script distant ou image distante de `Design-Proposal/` ne + devient une dépendance du jeu. +- La présentation est vérifiée à 1920 × 1080, 1280 × 720 et à sa plus petite + taille réelle dans le cockpit. + +## 10. Décisions encore ouvertes + +Avant l'implémentation complète du flux de sortie, préciser dans le cadrage de +sauvegarde si `Retour au menu principal` et `Quitter le jeu` pendant une plongée +appliquent immédiatement les conséquences d'un abandon ou enregistrent un état +de plongée active à résoudre au prochain chargement. Dans les deux cas, ces +actions ne permettent pas d'annuler gratuitement une perte déjà engagée. + +Le choix final des polices et le niveau de texture décorative seront validés sur +le renderer de production. Ils ne modifient pas les règles de lisibilité et de +navigation de cette baseline. diff --git a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md index aed8add..1e7d6b6 100644 --- a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md +++ b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md @@ -1,13 +1,14 @@ -# Verticale jouable — Cadrage technique proposé +# Verticale jouable — Cadrage technique retenu ## Statut -- Date : 7 août 2026. -- État : proposition à valider avant le premier code de production. +- Date : 10 août 2026. +- Dernière révision d'implémentation : 16 août 2026. +- État : cadrage approuvé, tranches 0 et 1 implémentées. - Jalon : verticale jouable, après validation des POC A et B. - Durée directrice du plan général : quatre à six mois. -- Moteur figé pour le jalon : Godot 4.7.1. -- Références : baselines techniques des POC A et B. +- Moteur figé pour le jalon : Godot 4.7.2. +- Références : baselines techniques des POC A et B, design system UI. Ce document définit ce que la verticale doit prouver, les frontières de son implémentation et l'ordre de construction. Il ne remplace ni le game design @@ -17,9 +18,10 @@ général ni les baselines des prototypes. La verticale doit répondre à une seule question principale : -> Piloter, chercher une épave réellement cachée, l'identifier, récupérer une -> preuve et revenir sous contrainte forme-t-il une boucle claire et assez -> satisfaisante pour porter une campagne ? +> Préparer une expédition, resserrer une zone avec le sonar du navire, trouver +> et identifier une épave parmi des leurres, récupérer son objectif puis revenir +> sous contrainte forme-t-il une boucle claire et assez satisfaisante pour +> porter une campagne ? Les POC ont validé séparément le véhicule et le langage du sonar. La verticale doit vérifier leur combinaison dans une mission complète avec préparation, @@ -30,21 +32,31 @@ objectif, conséquence et progression. La verticale utilise le premier navire de la campagne : le bâtiment de prospection contemporain du précédent opérateur. -1. Consulter un message de contrat et une fiche de secteur. -2. Accepter la mission avec un équipement imposé ou très limité. -3. Déployer le mini-sous-marin à proximité de la zone probable. -4. Suivre le relief tout en surveillant oxygène, propulsion et coque. -5. Utiliser manuellement le sonar bathymétrique pour trouver un accès masqué. -6. Découvrir l'épave sans marqueur posé directement sur elle. -7. Observer deux indices permettant de confirmer son identité. -8. Dégager ou ouvrir un accès avec une interaction contextuelle simple. -9. Récupérer une caisse ou un journal lié à la piste du U-Boot. -10. Calculer la marge de retour et remonter au bateau. -11. Consulter le débriefing, conserver la preuve et vendre un objet secondaire. -12. Acheter une première amélioration dont l'effet est vérifiable en replongeant. +1. Consulter le contrat mensonger de la société, le registre public et, si + acheté, le pack d'archives à 500 €. +2. Naviguer sur la carte jusqu'à la zone probable et lancer des relevés du + sonar de surface. +3. Recouper les zones d'écho puis déployer le mini-sous-marin. +4. Suivre le relief tout en surveillant oxygène, batterie, pression et coque. +5. Utiliser manuellement le sonar bathymétrique pour trouver plusieurs épaves + candidates sans marqueur direct. +6. Examiner des indices, consulter le dossier en pause et désigner une cible + présumée sans validation immédiate. +7. Identifier le navire de prospection du précédent opérateur parmi deux leurres + extérieurs légers. +8. Stabiliser le sous-marin, découper une attache et ouvrir un compartiment. +9. Récupérer le module de cartographie, ses données, une pièce mystérieuse et, + si la place le permet, un objet secondaire. +10. Revenir au navire, faire surface à proximité et déclencher `Treuiller`. +11. Remplir manuellement oxygène et batterie, puis vider le panier vers la cale. +12. Analyser les données à bord afin de révéler la zone probable du U-Boot. +13. Rentrer au port, décharger, remettre le module, toucher 1 000 € et vendre + l'objet secondaire. +14. Acheter l'agrandissement de cale de 12 à 18 unités et vérifier son effet. -Durée cible initiale : 15 à 25 minutes pour une première réussite, hors reprise -après échec. Ce nombre sera corrigé par playtest. +Durée cible initiale : 15 à 25 minutes pour la plongée réussie et 30 à 45 +minutes pour le parcours complet avec port, relevé de surface et débriefing, +hors reprise après échec. Ces nombres seront corrigés par playtest. ## 3. Périmètre obligatoire @@ -52,25 +64,42 @@ après échec. Ce nombre sera corrigé par playtest. - POV intérieur et hublot circulaire. - Pilotage inertiel avec retour immédiat des commandes. -- Oxygène, batterie de propulsion, profondeur et intégrité. +- Oxygène, batterie électrique, profondeur, pression et intégrité. - Alarmes texte, couleur et son. - Estimation de retour compréhensible. - Sonar manuel progressif, compact et agrandissable. +- Sonar de surface déterministe et imprécis depuis la carte maritime. - Un terrain conçu à la main avec une route sûre et une route plus risquée. -- Une épave identifiable et une anomalie secondaire optionnelle. -- Une interaction d'ouverture et une récupération. +- Une épave majeure identifiable et deux épaves leurres extérieures légères. +- Un registre public, un pack d'archives facultatif et un dossier comparatif. +- Une interaction de découpe, une récupération et une stabilisation assistée. - Retour réussi, abandon volontaire et perte du véhicule. ### Métajeu minimal -- Un écran de contrat. -- Une fiche historique courte. -- Un écran de préparation limité. +- Un port illustré donnant accès à l'entreprise nommée, aux Archives + maritimes, au Chantier naval et au Comptoir. +- Un contrat, trois fiches d'épaves candidates et un pack d'archives. +- Une carte maritime, un écran de préparation et les instruments de base du + navire. - Un débriefing. -- Une vente ou restitution. -- Une amélioration achetable. +- Une remise contractuelle et une vente manuelles. +- Une réparation de coque et une amélioration de cale achetable. - Une sauvegarde locale versionnée. +### Menus et réglages + +- Un menu principal avec Continuer, Nouvelle partie, Réglages et Quitter. +- Un menu pause qui suspend réellement les ticks de plongée. +- Une confirmation explicite avant abandon, retour au menu ou sortie pendant + une plongée. +- Quatre catégories de réglages : commandes, accessibilité, audio et affichage. +- Les états de chargement, sauvegarde absente, invalide ou incompatible. +- La présentation suit `docs/UI_DESIGN_SYSTEM.md` ; les maquettes sous + `Design-Proposal/` restent des références non exécutables. +- Les nouveaux écrans de port, carte, dossier et entretien exigent une itération + de design dédiée ; seules leurs responsabilités fonctionnelles sont figées ici. + ### Qualité minimale - Clavier-souris remappable. @@ -83,16 +112,19 @@ après échec. Ce nombre sera corrigé par playtest. ## 4. Hors périmètre ferme - Deuxième grande épave. -- Carte maritime complète et pilotage de surface. +- Pilotage 3D et physique navale ; la carte maritime limitée de la mission + reste obligatoire. - Trois ports ou marchés dynamiques. - Relations de personnages complètes. - Dialogue à embranchements complexe. - Plusieurs classes de coque. - Plusieurs outils de salvage simulés. +- Loadout configurable ; le cutter et le bras requis sont imposés. - Intérieur complet de l'épave. - Véritable grotte à plusieurs niveaux ou surplomb complexe. - Génération procédurale de site majeur. - Corruption surnaturelle avancée. +- Ballasts, courants et échos géologiques présentés comme contacts prometteurs. - Doublage, cinématique ou art final. - Manette certifiée, succès, cloud ou intégration de boutique. - Import d'une sauvegarde de démo. @@ -107,6 +139,8 @@ document. - Au moins 8 testeurs sur 10 trouvent l'accès à l'épave en 5 à 10 minutes après l'arrivée dans la zone de recherche, sans aide orale. +- Au moins 8 sur 10 comprennent la différence entre zone de recherche, écho de + surface, épave non identifiée et épave identifiée. - Au moins 8 sur 10 comprennent le rapport entre bleu, vert, jaune et hauteur. - Au moins 8 sur 10 identifient la caisse ou l'anomalie sur le sonar. - Aucun testeur ne confond oxygène et batterie après le tutoriel. @@ -116,10 +150,11 @@ document. ### Boucle de jeu - Une enquête attentive réduit la recherche locale à moins de dix minutes. -- Le joueur peut expliquer les deux indices ayant confirmé l'épave. +- Le joueur peut expliquer les deux indices ayant motivé son identification et + pourquoi les leurres ne correspondent pas. - Le retour est une décision anticipable et non une surprise punitive. - Une première erreur ne rend jamais la mission principale impossible. -- L'amélioration achetée produit un changement visible lors d'une seconde sortie. +- Le passage de la cale de 12 à 18 unités est visible et persiste. ### Technique @@ -146,20 +181,28 @@ game/ inventory/ economy/ upgrades/ + campaign/ + rules/ application/ dive/ sonar/ mission/ + navigation/ + vessel/ port/ interfaces/ world/ submarine/ + vessel/ sonar/ sites/ interactions/ presentation/ cockpit/ sonar/ + dossier/ + map/ + port/ briefing/ debriefing/ settings/ @@ -175,7 +218,9 @@ content/ wrecks/ contracts/ evidence/ + archives/ items/ + rules/ upgrades/ sites/ localization/ @@ -189,14 +234,20 @@ d'usage du port maritime. | État | Propriétaire unique | Lecteurs principaux | |---|---|---| -| Oxygène, batterie, coque, profondeur autorisée | `DiveSession` du domaine | HUD, alarmes, débriefing, sauvegarde | +| Tick et temps réel | application et adaptateur d'horloge | `DiveSession` via `DiveTick` explicite | +| Oxygène, batterie, coque, pression, alertes et fin de plongée | `DiveSession` du domaine | HUD, audio, débriefing | +| Règles résolues de la plongée | `DiveRulesResolver` du domaine | `DiveSession`, navigation, outils | | Transform, vitesses et collisions | `SubmarineActor` dans `world` | télémétrie, caméra, capteur | | Commande de propulsion | cas d'usage de pilotage | adaptateur physique | -| Scan courant et précédent | service applicatif sonar | renderer, HUD, sauvegarde si retenue | +| Orientation de la caméra | contrôleur de caméra de présentation | vue cockpit uniquement | +| Observations de collision | adaptateur physique du monde | `DiveSession` | +| Scan courant et précédent | service applicatif sonar | renderer, HUD | | Échantillons physiques bruts | adaptateur sonar du monde | service applicatif sonar | -| Objectifs et preuves observées | `MissionProgress` du domaine | HUD, fiche, débriefing | +| Échos sonar de surface persistants | progression de campagne | carte, préparation, sauvegarde | +| Objectifs, épaves connues et faits observés | `MissionProgress` du domaine | dossier, carte, débriefing | | Cargaison du panier | `DiveInventory` du domaine | HUD, récupération, débriefing | -| Argent et améliorations | `CampaignState` du domaine | port, préparation, sauvegarde | +| Cale, stockage, argent, dette et améliorations | `CampaignState` du domaine | navire, port, sauvegarde | +| Position et disponibilité du navire | `CampaignState` via `VesselLocation` | carte, déploiement, récupération | | Écran ouvert et sélection locale | présentation concernée | aucune règle de gameplay | | Réglages joueur | service de réglages | input, caméra, audio, présentation | @@ -204,58 +255,129 @@ La profondeur affichée est calculée à partir de la position physique, puis transmise à `DiveSession` par un échantillon de télémétrie. Le HUD ne la recalcule jamais depuis un nœud 3D. +Le domaine n'interroge jamais une horloge. L'application fournit exactement un +`DiveStep` par tick de physique à 60 Hz. Aucun tick n'est produit pendant une +pause et aucun temps de pause n'est rattrapé. Un pas supérieur à 0,1 s est +subdivisé ou refusé. + +Les observations et objets d'une plongée restent provisoires jusqu'à sa fin. +Le résultat détermine ce qui rejoint la campagne ; aucune sauvegarde ne tente de +restaurer la physique sous l'eau. + ## 8. Premiers contrats typés -Les noms ci-dessous sont des responsabilités proposées. Leur API exacte sera +Les noms ci-dessous fixent les responsabilités initiales. Leur API exacte sera validée par tests avant toute scène complète. ### `VehicleCommand` +- séquence ; - poussée longitudinale normalisée ; - gouvernail normalisé ; - commande verticale normalisée ; -- forte poussée active ou non ; -- tick ou instant logique. +- forte poussée active ou non. Produit par l'infrastructure d'entrée, validé par l'application et consommé par -l'acteur physique. +l'acteur physique. La souris n'y contribue jamais. + +### `CameraLookCommand` + +- variation de lacet en radians ; +- variation de tangage en radians ; +- demande de recentrage éventuelle. + +Ce contrat oriente seulement la caméra fixée dans la cabine. Le regard persiste, +reste borné par le hublot et ne se recentre pas automatiquement par défaut. Les +limites initiales de playtest sont environ ±30° horizontalement et -17° / +11° +verticalement. Un recentrage lent optionnel et un recentrage instantané en mode +réduction des mouvements restent des réglages de présentation. ### `VehicleTelemetry` - position et orientation mondiales ; -- vitesse linéaire et angulaire ; +- vitesse linéaire dans le monde et vitesse angulaire dans le repère du + véhicule ; - profondeur en mètres ; -- effort de propulsion normalisé ; -- intensité de la dernière collision ; -- tick logique. +- efforts longitudinal, de gouverne et vertical normalisés ; +- forte poussée active ; +- identifiant de session et séquence. -Snapshot immuable produit par le monde. Aucune référence de nœud n'en sort. +Snapshot temporaire immuable produit par le monde. Aucune référence de nœud +n'en sort et la position du sous-marin n'est pas sauvegardée en V1. -### `DiveSnapshot` +### Entrées d'un pas de plongée -- identifiant de session ; -- oxygène restant ; -- batterie restante ; -- intégrité ; -- profondeur nominale et critique ; -- coût de retour estimé ; -- niveau d'alarme ; -- état terminal éventuel. +- `DiveTick` : séquence et secondes écoulées ; +- `DiveTelemetrySample` : profondeur et efforts effectifs utiles aux règles ; +- `CollisionObservation` : séquences de collision et de tick, vitesse normale + d'impact en m/s ; +- `ToolPowerDemand` : ID d'outil et intensité électrique effective ; +- `EjectionIntent` : commande active ou inactive ; +- `ReturnRouteSample` : distance, ascension et statut connu ou inconnu ; +- `DiveStep` : agrégat unique de ces entrées pour un tick. -Produit par le domaine après application d'un tick et d'une télémétrie. +Les collisions sont séparées de la télémétrie continue et sont idempotentes. +Le domaine possède leurs seuils, dégâts et délais de répétition. Les efforts +représentent la sortie réelle des actionneurs, jamais une touche pressée. -### `SonarSample` +### `DiveSnapshot` et `DiveUpdate` -- ligne et colonne ; -- position et normale dans un repère déclaré ; -- distance en mètres ; -- intensité normalisée ; -- catégorie de matériau ; -- élévation ou profondeur ; -- temps de révélation ; -- statut observé ou inconnu. +`DiveSnapshot` contient : -Il remplace les dictionnaires implicites du POC B. +- `BatterySnapshot` : kWh restants et maximaux, ratio, puissance actuelle en kW + et autonomie estimée ; +- `OxygenSnapshot` : litres standards restants et maximaux, ratio, consommation + en L/min et autonomie estimée ; +- `HullSnapshot` : intégrité actuelle et maximale, ratio, pression extérieure, + certifiée, critique et d'écrasement en bar ; +- `DepthSnapshot` : profondeur actuelle, certifiée, critique et d'écrasement en + mètres ; +- `ReturnEstimate` : kWh requis, marge en kWh et ratio, statut sûr, serré, + insuffisant ou inconnu ; +- `DiveAlertState` : niveaux indépendants pour oxygène, batterie, coque et + profondeur, plus la sévérité maximale ; +- `EjectionSnapshot` : progression d'une tenue de trois secondes ; +- motif terminal éventuel : retour, éjection volontaire, oxygène épuisé ou + coque détruite. + +`DiveUpdate` regroupe ce snapshot cohérent et des événements typés comme +`AlertLevelChanged`, `ResourceDepleted`, `HullDamaged` et `DiveTerminated`. +L'UI et l'audio ne redéduisent jamais les seuils. Une batterie vide est une +défaillance, mais ne termine pas instantanément la plongée. + +Les niveaux d'alerte sont `NORMAL`, `ATTENTION`, `RETURN`, `CRITICAL` et +`FAILURE`. La tenue d'éjection dure trois secondes, se réinitialise au relâchement +et n'avance pas en pause. Une confirmation explicite depuis le menu produit le +même motif d'éjection volontaire sans demander une seconde tenue. + +L'ordre interne d'un pas est fixe : validation, consommation, pression, +collisions ordonnées, éjection, retour et alertes, état terminal, puis +publication d'un seul `DiveUpdate`. + +### `ResolvedDiveRules` + +Au début d'une plongée, `DiveRulesResolver` combine dans cet ordre : + +1. règles de base ; +2. capacités et améliorations du sous-marin ; +3. profil de difficulté ; +4. aides d'accessibilité de gameplay ; +5. validation et bornage. + +Le résultat est immuable pour cette plongée et seul le choix des sources est +sauvegardé. Les mêmes sources donnent toujours les mêmes règles. + +### `SonarSampleBuffer` et `SonarContact` + +Le scan stocke en mémoire des tableaux parallèles compacts de positions, +normales, intensités, élévations, matériaux et statuts d'observation. Un même +index décrit une cellule ; ligne et colonne sont dérivées. Un accesseur typé +`sample_at(row, column)` masque cette représentation. + +Le buffer devient immuable après publication. Des clusters spatiaux +déterministes produisent des `SonarContact` séparés avec ID, matériau, centre, +limites, intensité maximale et nombre d'échantillons. La couleur d'altitude est +relative au scan, mais les valeurs réelles en mètres restent stockées. ### `SonarScan` @@ -264,27 +386,56 @@ Il remplace les dictionnaires implicites du POC B. - portée et ouvertures ; - durée ; - dimensions de grille ; -- collection typée de `SonarSample` ; -- état : demandé, actif, terminé ou remplacé. +- `SonarSampleBuffer`, contacts et échelle de couleur ; +- état : prêt, capture, révélation ou récupération. -Le résultat stocke le repère de capture. La présentation peut ainsi orienter la -carte sans relire un acteur mutable. +Le repère est figé à l'émission, origine au capteur, +X tribord, +Y haut et +Z +avant en mètres. L'adaptateur convertit le -Z avant de Godot. La révélation dure +2,5 s, suivie de 0,35 s de récupération, sans file d'attente d'entrée. Un scan +courant et un scan précédent en fondu existent au maximum. + +Deux acquisitions de production peuvent être comparées derrière le même +contrat : réécriture bathymétrique de référence du POC et faisceau frontal par +rayons. Une seule devient autoritaire après la porte de calibration. + +### `SurfaceSurvey` et `SurfaceEchoArea` + +Un relevé de surface contient la position du navire, son empreinte, sa séquence +et des zones d'écho avec centre approximatif et rayon d'incertitude. Rejouer le +même relevé produit le même résultat. Seules les zones sémantiques sont +sauvegardées ; elles sont remplacées par une épave connue après localisation +sous l'eau. + +### `VesselLocation` + +- ID de région et de repère de coordonnées ; +- position est et nord en mètres ; +- cap en radians ; +- état au port, sur site, en eau libre ou en route ; +- ID d'un mouillage connu éventuel. + +La composition convertit cette donnée métier en transform Godot. Ni la carte, +ni la sauvegarde ne manipulent le nœud 3D du navire. ### `MissionProgress` -- contrat actif ; -- objectifs connus ; -- preuves observées ; -- identité proposée ou confirmée ; -- interaction d'accès ; -- objet principal récupéré ; -- résultat de mission. +- termes visibles du contrat et objectifs réels séparés ; +- objectifs et révélations connus ; +- `WreckRecord` pour chaque épave localisée : position, identité éventuelle, + faits observés et activités connues ; +- cible présumée révisable ; +- état de l'accès et de la découpe ; +- état des objets : sur site, dans le panier, dans la cale, remis ; +- rapport final distinct du résultat de chaque plongée. ### Définitions de contenu - `WreckDefinition` : identité, époque, dimensions, preuves et références. -- `ContractDefinition` : donneur, site, objectifs, paiement et prérequis. +- `ContractTerms` : donneur, informations visibles, objet demandé, paiement et + prérequis. +- `CampaignObjectiveDefinition` : déclencheurs réels, visibilité et révélations. - `EvidenceDefinition` : type d'observation, cible et texte de fiche. +- `ArchivePackDefinition` : secteur, prix et fiches enrichies. - `ItemDefinition` : volume, catégorie, valeur et caractère essentiel. - `UpgradeDefinition` : coût, prérequis et effet typé. - `DiveSiteDefinition` : scène, profondeur, limites et contenu autorisé. @@ -294,13 +445,16 @@ un texte libre destiné au joueur. ## 9. Flux principaux -### Démarrage de mission +### Contrat, archives et relevé de surface ```text -Briefing UI - → AcceptContract - → CampaignState valide les prérequis - → StartDive crée DiveSession et MissionProgress +Port UI + → consulter ContractTerms et registre public + → acheter éventuellement ArchivePackDefinition + → NavigateVessel met à jour VesselLocation + → RequestSurfaceSurvey produit des SurfaceEchoArea déterministes + → sélection d'une zone d'écho + → StartDive résout ResolvedDiveRules et crée DiveSession → Composition charge le DiveSite → World reçoit uniquement les paramètres nécessaires ``` @@ -313,8 +467,9 @@ InputAdapter → PilotUseCase → SubmarineActor → VehicleTelemetry - → TickDive - → DiveSnapshot + → DiveTelemetrySample + CollisionObservation + ToolPowerDemand + → DiveStep à 60 Hz + → DiveUpdate → cockpit et alarmes ``` @@ -323,10 +478,10 @@ InputAdapter ```text Action F → RequestSonarScan - → validation état/cooldown + → validation de l'état → WorldSonarAdapter capture les retours → SonarScanService structure le scan - → progression déterministe + → révélation 2,5 s puis récupération 0,35 s → SonarPresentationSnapshot → renderer compact et agrandi ``` @@ -334,44 +489,89 @@ Action F La petite et la grande vue consomment la même sortie du renderer. Elles ne maintiennent jamais deux reconstructions indépendantes. +#### Piste post-V1 : échos géologiques de surface + +La V1 ne présente pas un rocher banal comme un contact prometteur : chaque +écho persistant du sonar du bateau conduit à une épave, un site secondaire +ou un élément documentable. Une itération ultérieure pourra faire renvoyer des +échos aux rochers et autres formations géologiques afin d'enrichir +l'interprétation du relevé de surface. Cette piste ne sera retenue qu'après +playtest, si ces faux positifs créent une déduction intéressante plutôt qu'une +plongée vide ou punitive. + ### Observation et récupération ```text -Interaction monde - → ObserveEvidence ou RecoverItem - → MissionProgress / DiveInventory - → résultat explicite - → snapshot UI et sauvegarde éventuelle +Regard + proximité + vitesse valide + → ObserveEvidence ajoute un fait idempotent + → dossier compare sans conclure + → SetSuspectedWreck reste révisable + → CutAccess consomme cutter + stabilisation et persiste + → RecoverItem valide la capacité puis consomme bras + stabilisation + → DiveInventory est mis à jour à la fin du transfert ``` L'acteur d'épave ne décide pas qu'une mission est terminée. Il décrit le contact -observé ; le domaine applique les règles du contrat. +observé ; le domaine applique les règles de campagne. Une opération suspendue +par la dérive conserve sa progression. Le cutter de référence consomme 30 kW +pendant environ 12 s et le bras 8 kW pendant environ 6 s, hors stabilisation. -### Retour et débriefing +Le dossier complet met les ticks en pause. Il reste aussi consultable avant la +plongée et depuis les instruments du navire. + +### Retour, treuillage et transfert ```text -Zone de retour atteinte - → CompleteDive +Sous-marin en surface à faible vitesse dans un rayon de 25 m + → WinchSubmarine → résultat immuable - → transfert autorisé de cargaison - → calcul économique - → sauvegarde atomique - → Debriefing UI + → retour sur le navire sans restauration automatique + → actions séparées RefillOxygen / RechargeBattery / EmptyBasket + → EmptyBasket exige la place nécessaire dans la cale + → sauvegarde atomique après chaque action + → AnalyzeRecoveredData révèle la piste du U-Boot + → retour au port, UnloadHold, remise, paiement et vente explicites ``` +Une fin de plongée ne termine pas la mission. Le rapport final et la remise des +objets sont des actions de campagne explicites. Après perte du sous-marin, le +joueur revient sur le navire au site, doit naviguer au port et demander son +remplacement au Chantier naval. + +L'analyse des données est disponible à bord dès le vidage du panier et ne +consomme ni ressource ni temps simulé. La copie reste acquise même si le module +physique est remis à l'entreprise avant sa lecture. + +L'écran principal du port expose directement `Décharger la cale` et `Prendre la +mer`. Avant le départ, il récapitule batterie, oxygène, coque, panier, cale, +dossier suivi et archives possédées. Une préparation incomplète produit un +avertissement, jamais une restauration ou un blocage automatique. + ## 10. Scènes de production minimales | Scène | Responsabilité | |---|---| | `vertical_slice_root.tscn` | composition et transitions du parcours | +| `main_menu.tscn` | nouvelle partie, reprise, réglages et sortie | +| `pause_menu.tscn` | pause réelle et commandes de sortie de plongée | +| `settings_screen.tscn` | coque commune des quatre catégories de réglages | +| `confirmation_dialog.tscn` | confirmation sûre des actions destructrices | +| `port_screen.tscn` | entreprise nommée, Archives, Chantier, Comptoir et départ | +| `maritime_map.tscn` | position du navire, zones, relevés et sites connus | +| `vessel_service_screen.tscn` | ressources, panier, cale et analyse à bord | +| `dossier_screen.tscn` | contrat, registre, observations et cible présumée | | `dive_site_survey_wreck.tscn` | environnement, limites et points d'injection | +| `surface_vessel_actor.tscn` | repère 3D statique, feux et zone de treuillage | | `submarine_actor.tscn` | physique et adaptateurs du véhicule | | `survey_wreck.tscn` | géométrie, collisions et points observables | +| `decoy_wreck.tscn` | variante modulaire extérieure et points observables | | `cockpit_hud.tscn` | télémétrie et alertes | | `sonar_display.tscn` | affichage compact et agrandi partagé | -| `briefing_screen.tscn` | contrat, fiche et départ | -| `debriefing_screen.tscn` | résultat, cargaison et progression | -| `workshop_screen.tscn` | une amélioration et son installation | +| `company_screen.tscn` | termes du contrat, remise, rapport et paiement | +| `archives_screen.tscn` | registre public et pack d'archives du secteur | +| `shipyard_screen.tscn` | onglets Sous-marin et Navire, réparation et cale | +| `counter_screen.tscn` | sélection et vente manuelle de cargaison | +| `debriefing_screen.tscn` | résultat de plongée sans transfert implicite | Ces scènes reçoivent leurs dépendances depuis la composition. Aucun autoload de gameplay n'est introduit pour éviter cette injection. @@ -381,48 +581,75 @@ gameplay n'est introduit pour éviter cette injection. La sauvegarde existe avant le métajeu complet afin d'empêcher une architecture reposant sur des nœuds sérialisés. -Contenu V1 : +La campagne narrative est unique, mais le joueur peut conserver plusieurs +sauvegardes. `Continuer` charge la sauvegarde valide la plus récente et +`Charger` affiche la liste sans supprimer les sauvegardes plus récentes. -- version du format ; -- ID de campagne et date technique non utilisée par le gameplay ; -- contrat actif ou terminé ; -- argent ; -- améliorations acquises et installées ; -- preuves principales sécurisées ; -- dernier résultat de plongée ; +Organisation V1 : + +- trois autosauvegardes sémantiques : avant plongée, après plongée et progression + de campagne ; +- une sauvegarde technique de secours invisible pour chacune ; +- jusqu'à dix sauvegardes manuelles nommées, seulement hors plongée ; - réglages joueur séparés de la campagne. -La position arbitraire du sous-marin, les particules, les collisions et le -maillage sonar dynamique ne sont pas sauvegardés dans cette verticale. +Le format est un JSON explicite avec enveloppe `schema_version`, `campaign_id`, +`save_id`, `created_at_utc`, `kind` et `payload`. Le payload contient au moins : -Procédure : sérialiser dans un fichier temporaire, relire et valider, puis -remplacer atomiquement la sauvegarde précédente. Conserver une fixture V1 et un -test de round-trip dès la première implémentation. +- argent, dette et difficulté choisie ; +- `VesselLocation`, disponibilité du sous-marin et ressources non remplies ; +- contrats, objectifs connus et révélations ; +- zones d'écho, épaves localisées, identités et observations sécurisées ; +- états persistants d'ouverture ; +- panier, cale, stockage et objets narratifs ; +- archives, plans et améliorations ; +- dernier résultat de plongée. + +La position arbitraire du sous-marin, les particules, les collisions, les points +sonar, leurs couleurs et le maillage dynamique ne sont pas sauvegardés. Un +marqueur technique indique seulement qu'une plongée était active. Après crash ou +fermeture forcée, le jeu revient à l'état précédant la plongée sans restauration +physique ni perte automatique du véhicule. + +Les dictionnaires JSON restent confinés au codec d'infrastructure ; les autres +couches reçoivent des objets typés. Procédure : sérialiser dans un fichier +temporaire, relire et valider, puis remplacer atomiquement la sauvegarde +précédente. Conserver une fixture par version, des migrations explicites et un +test de round-trip dès la première implémentation. Une version inconnue est +refusée proprement. ## 12. Stratégie de tests ### Unitaires -- consommation des trois ressources ; +- tick à 60 Hz, pause sans tick et rejet ou subdivision d'un pas trop grand ; +- consommation d'oxygène, propulsion, outils et stabilisation ; - seuils de profondeur et dégâts ; - estimation du retour ; - transitions terminales ; +- ordre et idempotence des collisions ; +- résolution base, améliorations, difficulté et accessibilité ; - validation et progression temporelle d'un scan ; - remplacement d'un seul scan précédent ; - classification des matériaux ; +- clustering de plusieurs contacts sans moyenne globale ; +- relevé de surface identique depuis la même position et recoupement borné ; - ajout idempotent d'une preuve ; -- conditions d'identification ; -- capacité de cargaison ; -- résultat de mission ; -- vente, restitution et achat d'amélioration. +- hypothèse révisable sans validation implicite ; +- capacité du panier et de la cale ; +- plongée terminée distincte du rapport final ; +- perte, franchise, dette et remplacement selon la difficulté ; +- vente, remise, réparation et achat d'amélioration. ### Contrats - IDs de contenu uniques ; - références résolues ; - contrat principal terminable ; -- deux preuves valides pour l'épave ; +- une cible et deux leurres possèdent chacun des faits cohérents ; +- chaque leurre a au moins une contradiction découvrable ; - objet principal invendable ou récupérable selon la règle ; +- pack d'archives facultatif et campagne terminable sans lui ; - effet d'amélioration reconnu ; - sauvegarde V1 aller-retour ; - rejet d'une version inconnue ; @@ -431,22 +658,37 @@ test de round-trip dès la première implémentation. ### Intégration - entrée abstraite vers mouvement et télémétrie ; +- souris vers caméra sans commande d'orientation du véhicule ; - collision vers dégâts uniques ; - sonar monde vers `SonarScan` puis maillage ; - rotation du véhicule vers orientation de la carte ; - vue compacte et agrandie partageant la même texture ; +- carte vers relevé de surface puis `SurfaceEchoArea` persistante ; - observation monde vers progression de mission ; -- récupération vers inventaire ; -- retour vers débriefing et sauvegarde ; -- chargement d'une sauvegarde vers écran correct. +- outil vers consommation, progression suspendue et accès persistant ; +- récupération vers panier seulement après transfert terminé ; +- surface vers treuillage sans restauration automatique ; +- actions manuelles vers ressources, cale, analyse, paiement et sauvegarde ; +- chargement d'une sauvegarde vers écran correct ; +- pause vers suspension puis reprise des ticks sans consommation intermédiaire ; +- remappage vers commande véhicule sans accès direct de la présentation ; +- réglage modifié, annulé, appliqué puis rechargé ; +- abandon confirmé vers conséquence unique, sans double application. ### Parcours automatisés -1. Succès nominal avec deux preuves et retour. -2. Retour sans preuve principale : mission non terminée. -3. Abandon volontaire : perte appliquée, campagne encore jouable. -4. Batterie vide : pas de mort instantanée, oxygène continue. -5. Sauvegarde après débriefing puis redémarrage. +1. Relevé de surface, cible correcte parmi deux leurres, récupération et + treuillage. +2. Retour sans vider le panier puis replongée : ressources et cargaison restent + inchangées. +3. Vidage du panier, analyse du module, remise et déblocage de la piste U-Boot. +4. Mauvaise hypothèse révisée sous l'eau sans message de validation. +5. Abandon volontaire : données conservées, panier perdu, navire encore sur le + site et remplacement seulement au port. +6. Perte en Standard puis Expert : modèle basique et franchises 500 € / 750 €. +7. Batterie vide : pas de mort instantanée, oxygène continue. +8. Sauvegarde après actions manuelles puis redémarrage. +9. Marqueur de plongée active simulé : reprise avant plongée sans perte. Les parcours utilisent des adaptateurs déterministes, pas une simulation de plusieurs minutes en temps réel. @@ -465,6 +707,39 @@ après explication ne satisfait pas le critère d'autonomie. ## 13. Budgets techniques initiaux +Valeurs de gameplay de départ, toutes configurables et soumises au playtest : + +| Valeur | Cible initiale | +|---|---:| +| Fréquence de simulation | 60 Hz | +| Batterie basique | 100 kWh | +| Oxygène Standard | environ 16 min | +| Autonomie Standard | environ 1,2 km | +| Intégrité basique | 100 points | +| Panier | 6 unités | +| Cale initiale / première amélioration | 12 / 18 unités | +| Cutter | 30 kW, environ 12 s, 0,10 kWh hors stabilisation | +| Bras | 8 kW, environ 6 s, 0,013 kWh hors stabilisation | +| Examen d'un indice | environ 1,5 s à 8–12 m | +| Récupération par le navire | rayon 25 m, faible vitesse | +| Sonar de surface | portée 400 m, incertitude 150 / 75 / minimum 50 m | +| Argent initial / archives / contrat | 500 € / 500 € / 1 000 € | +| Cale 12 vers 18 / objet secondaire | 750 € / environ 200 € | +| Réparation | 4 € par point d'intégrité | + +Multiplicateurs de difficulté : + +| Règle | Exploration | Standard | Expert | +|---|---:|---:|---:| +| Oxygène | ×0,75 | ×1 | ×1,20 | +| Batterie | ×0,80 | ×1 | ×1,15 | +| Collision | ×0,65 | ×1 | ×1,25 | +| Pression | ×0,50 | ×1 | ×1,35 | +| Grâce avant rupture | ×1,50 | ×1 | ×0,75 | +| Franchise | 0 € | 500 € | 750 € | + +Budgets d'exécution : + | Budget | Cible de départ | |---|---:| | Framerate de validation locale | 60 FPS à 1080p | @@ -479,36 +754,79 @@ après explication ne satisfait pas le critère d'autonomie. Ces budgets sont des alarmes, pas des promesses commerciales. Une mesure doit indiquer la machine, le renderer, la résolution, le build et la scène. -Le renderer Forward+ reste la cible de production. Son adoption pour la -verticale nécessite un ADR et un test immédiat sur les machines disponibles ; -elle ne doit pas attendre la phase de finition graphique. +Forward+ est l'unique renderer de production conformément à +`docs/adr/0004-forward-plus-only.md`. Ses réglages de qualité et son coût sont +testés immédiatement sur les machines disponibles ; cette validation ne doit +pas attendre la phase de finition graphique. ## 14. Tranches d'implémentation ### Tranche 0 — fondations exécutables +État au 10 août 2026 : implémentée. La production démarre sur un menu principal +minimal, puis `Nouvelle partie` ouvre l'écran de diagnostic alimenté par +`DiveSession`. `Continuer` reste désactivé tant que la persistance n'existe pas +et l'écran complet de réglages appartient à une tranche ultérieure. + - Créer les sous-dossiers nécessaires, sans architecture vide exhaustive. -- Définir horloge, IDs et premiers types de résultat. -- Porter `DiveSession` comme règle pure avec tests. -- Créer une composition minimale et un écran de diagnostic. +- Définir IDs, `DiveTick`, `DiveStep`, `ResolvedDiveRules`, snapshots et + événements. +- Créer `DiveSession` comme règle pure, sans horloge interne, avec tests à 60 Hz. +- Créer une composition minimale, un menu principal fonctionnel et un écran de + diagnostic. Sortie : état de plongée déterministe, testable sans scène, affiché par une scène de production qui ne dépend pas des POC. ### Tranche 1 — véhicule de production +État au 10 août 2026 : implémentée. `Nouvelle partie` ouvre désormais un +parcours court en vue intérieure avec mouvement inertiel, caméra souris séparée, +ressources réelles, environnement Forward+, pause complète et remappage clavier +avec détection puis permutation des conflits. Les valeurs de départ reprennent +la baseline du POC A sans importer son code. + - Réécrire le contrôleur derrière `VehicleCommand` et `VehicleTelemetry`. - Reproduire les sensations validées du POC A à partir de sa baseline. -- Intégrer caméra intérieure et hublot. +- Intégrer caméra intérieure, hublot et `CameraLookCommand` indépendant de la + coque. +- Ajouter le menu pause et le premier parcours de remappage. - Ajouter les tests de mouvement et de dégâts prioritaires. Sortie : parcours de pilotage court avec ressources et retour immédiat lisible. +### Tranche 1.5 — cockpit fonctionnel représentatif + +État au 16 août 2026 : implémentée après revue visuelle native Forward+ à +1280 × 720. Cette tranche corrige le graybox initial, qui ne contenait qu'une +caméra, un cercle Canvas et des textes superposés. + +- Construire une coque intérieure 3D fixe par rapport au véhicule, indépendante + du regard de la caméra. +- Remplacer le cercle Canvas par un hublot 3D occultant avec cadre, joint, verre, + boulons et cloisons. +- Ajouter tableau de bord, consoles, leviers, bouton d'éjection, éclairage et + premiers matériaux métalliques procéduraux. +- Présenter batterie, oxygène, coque, pression, profondeur, vitesse et retour + sur des instruments physiques alimentés par `DiveUpdate` et + `VehicleTelemetry`. +- Réserver un moniteur physique au sonar de la tranche 2. +- Limiter la surimpression 2D aux alertes, à l'éjection et à la fin de plongée. +- Garantir un relief et une balise visibles dès le point de départ. + +Les textures détaillées, l'usure, les animations secondaires et l'art final +restent dans la tranche 6. Le cockpit actuel doit cependant rester assez +représentatif pour valider la composition du sonar et la lisibilité en jeu. + +Sortie : un habitacle identifiable et fonctionnel, dont les instruments +constituent la cible réelle d'intégration du sonar. + ### Tranche 2 — sonar de production -- Définir `SonarSample` et `SonarScan`. +- Définir `SonarSampleBuffer`, `SonarContact` et `SonarScan`. - Séparer acquisition, reconstruction et présentation. -- Reproduire la bathymétrie validée sans importer le POC B. +- Réécrire la bathymétrie validée sans importer le POC B et comparer le + faisceau frontal derrière le même contrat. - Mesurer le coût et passer à une mise à jour incrémentale. Sortie : relief inconnu révélé manuellement, lisible dans les deux vues, avec @@ -516,28 +834,34 @@ rotation et remplacement d'un relevé. ### Tranche 3 — site et découverte -- Construire le terrain graybox et l'épave contemporaine. -- Ajouter une entrée masquée simple, sans surplomb complexe. -- Définir deux preuves et une anomalie secondaire. +- Ajouter carte limitée, position persistante du navire et sonar de surface. +- Construire le terrain graybox, l'épave majeure contemporaine et deux leurres + extérieurs modulaires. +- Ajouter registre, pack d'archives, faits observables et dossier comparatif. +- Implémenter les états cartographiques sans révéler un secret inconnu. - Organiser le premier playtest de recherche. -Sortie : 8 testeurs sur 10 trouvent l'épave dans la fenêtre cible ou une porte de -décision est déclenchée. +Sortie : 8 testeurs sur 10 passent de la zone probable à la bonne épave et +peuvent expliquer leur choix, ou une porte de décision est déclenchée. ### Tranche 4 — interaction et retour -- Ajouter une interaction d'ouverture bornée. -- Ajouter la caisse, le panier et l'objet principal. -- Ajouter zone de retour, succès, abandon et perte. +- Ajouter cutter, stabilisation assistée, consommation électrique et ouverture + persistante. +- Ajouter bras, panier de 6 unités, module, pièce et objet secondaire. +- Ajouter navire 3D simplifié, zone de retour, treuillage, abandon et perte. - Automatiser les parcours nominaux et d'échec. Sortie : boucle de plongée complète sans métajeu. ### Tranche 5 — contrat, économie et sauvegarde -- Ajouter briefing, fiche, débriefing et atelier. -- Ajouter une vente/restitution et une amélioration. -- Écrire Save V1 et ses tests de contrat. +- Ajouter port, entreprise, Archives, Chantier naval et Comptoir. +- Ajouter entretien manuel, cale, analyse des données, remise, vente, dette et + amélioration 12 vers 18. +- Ajouter contrat visible, objectif réel et révélation de la piste U-Boot. +- Ajouter Continuer, les états de chargement et la persistance des réglages. +- Écrire les sauvegardes JSON V1 et leurs tests de contrat. Sortie : boucle verticale complète et rejouable après redémarrage. @@ -556,7 +880,10 @@ Sortie : décision documentée de poursuivre, corriger ou réduire le projet. |---|---|---| | Sensations perdues pendant la réécriture | test véhicule dès tranche 1 | comparer aux mesures POC A, ajuster avant sonar | | Sonar lisible seul mais confus en mission | playtest tranche 3 | réduire relief parasite ou resserrer la zone probable | +| Sonar de surface répétitif | nombre de relevés avant une plongée utile | conserver les résultats déterministes et réduire la zone initiale | +| Leurres aussi coûteux que la cible | mesure de production des micro-sites | extérieurs modulaires, faits seulement, aucun accès complexe | | Ouverture impossible à représenter | site graybox dédié | ajouter surfaces locales ; ne pas revenir aux voxels globaux | +| Stabilisation d'outil frustrante | taux d'interruption et corrections requises | élargir les tolérances, suspendre sans réinitialiser | | États dupliqués entre monde et HUD | tests de propriétaire et snapshots | supprimer la copie, rétablir un flux unidirectionnel | | Scènes fortement couplées | contrôle d'architecture | déplacer l'assemblage vers `composition` | | Sauvegarde conçue trop tard | Save V1 tranche 5 au plus tard | bloquer le métajeu tant que le round-trip échoue | @@ -572,11 +899,24 @@ suite indéfinie de corrections sans critère de sortie. Une fois ce document approuvé, créer des ADR séparés pour : -1. le renderer de production de la verticale ; -2. les contrats `VehicleTelemetry`, `DiveSnapshot` et `SonarScan` ; -3. la sauvegarde V1 et sa stratégie atomique ; -4. l'absence initiale d'autoload de gameplay ; -5. le modèle hybride retenu si le site exige une surface non bathymétrique. +1. le renderer Forward+ unique de production, formalisé dans + `docs/adr/0004-forward-plus-only.md` ; +2. les contrats de tick, télémétrie, snapshot/événements et règles résolues, + formalisés dans `docs/adr/0005-fixed-dive-ticks-and-typed-updates.md` ; +3. le contrat sonar commun, le buffer compact et la porte de comparaison, + formalisés dans `docs/adr/0006-sonar-contract-and-acquisition-gate.md`, puis + l'acquisition retenue dans un ADR de sélection ; +4. la sauvegarde JSON V1, ses emplacements, migrations, secours et remplacement + récupérable, formalisés dans `docs/adr/0007-versioned-json-saves.md` ; +5. l'absence d'autoload de gameplay et la propriété des services par la + composition, formalisées dans + `docs/adr/0008-composition-owned-service-lifetimes.md` ; +6. le modèle de progression qui sépare vérité du contenu, termes du contrat, + objectifs réels et connaissances du joueur, formalisé dans + `docs/adr/0009-player-knowledge-driven-progression.md` ; +7. la limitation de la V1 à une surface bathymétrique unique, formalisée dans + `docs/adr/0010-single-surface-bathymetry-for-v1.md` ; un modèle hybride + nécessitera un prototype et un nouvel ADR lorsqu'un site concret l'exigera. Les noms de classes peuvent évoluer pendant la tranche 0. Les responsabilités, les propriétaires et les directions de dépendance ne changent qu'avec un ADR. @@ -586,11 +926,12 @@ les propriétaires et les directions de dépendance ne changent qu'avec un ADR. La première implémentation ne sera ni le sous-marin ni le sonar. Elle créera une tranche verticale minuscule : -1. `DiveSession` pur avec horloge injectée ; -2. `VehicleTelemetry` minimal fourni par un faux adaptateur ; -3. `DiveSnapshot` affiché dans une scène de diagnostic ; -4. tests de consommation, profondeur et état terminal ; -5. ajout de cette suite à `scripts/quality_gate.sh`. +1. `DiveSession` pur recevant un `DiveStep` explicite ; +2. `ResolvedDiveRules` produit depuis des fixtures de base et de difficulté ; +3. `VehicleTelemetry` minimal fourni par un faux adaptateur ; +4. `DiveUpdate` affiché dans une scène de diagnostic ; +5. tests de tick, consommation, profondeur, alertes et état terminal ; +6. ajout de cette suite à `scripts/quality_gate.sh`. Cette tranche prouvera le chemin complet domaine → application → présentation et la discipline de tests avant de porter un comportement complexe. diff --git a/docs/adr/0004-forward-plus-only.md b/docs/adr/0004-forward-plus-only.md new file mode 100644 index 0000000..07abc20 --- /dev/null +++ b/docs/adr/0004-forward-plus-only.md @@ -0,0 +1,73 @@ +# 0004 — Forward+ comme renderer unique de production + +- Statut : accepté +- Date : 2026-08-10 +- Remplace : aucun + +## Contexte + +Les POC A et B ont été validés avec GL Compatibility afin de faciliter les +premiers essais. Cette configuration a aussi conduit le POC A à simuler la +diffusion des projecteurs avec des plans translucides dépendants du point de +vue. Ces compromis restent documentés dans les baselines, mais ne répondent pas +à la direction visuelle de la production : matériaux PBR, éclairage sous-marin, +brouillard, particules et ombres réglables. + +Maintenir plusieurs renderers augmenterait le nombre de configurations à +tester et encouragerait des chemins graphiques divergents. Les performances +doivent être ajustées par la qualité des effets, pas par un changement de +renderer qui modifierait le rendu et les capacités disponibles. + +## Décision + +Forward+ est l'unique renderer pris en charge par le code et les builds de +production. Le jeu ne propose ni sélection de renderer, ni repli automatique +vers GL Compatibility. + +Les POC demeurent des références historiques jetables dans leur configuration +GL Compatibility documentée. Ils ne définissent pas une seconde configuration +prise en charge et leur code ou leurs artifices graphiques ne sont pas repris +en production. + +Les réglages d'affichage permettent d'adapter les performances sans quitter +Forward+. Ils couvrent au minimum des niveaux de qualité pour les particules, +les ombres, la distance d'affichage et le post-traitement. Les effets +atmosphériques coûteux, dont le brouillard volumétrique s'il est retenu après +mesure, suivent la même politique. Des préréglages cohérents peuvent exposer +ces valeurs ensemble, avec des réglages détaillés lorsque cela reste lisible. + +Une baisse de qualité ne change aucune règle de gameplay et ne peut supprimer +une information indispensable à la navigation, aux alarmes ou à la lecture du +sonar. Les choix sont persistés dans les réglages du joueur, séparément de la +sauvegarde de campagne. + +Chaque mesure de performance indique le renderer Forward+, la résolution, le +préréglage graphique, la machine, le build et la scène testée. La première +scène de production mesure explicitement le coût des projecteurs, des ombres, +du brouillard et des particules avant la phase de finition visuelle. + +## Conséquences + +- Le rendu de production possède une seule configuration architecturale à + développer et vérifier. +- Les machines ne prenant pas correctement en charge Forward+ ne sont pas des + cibles de la V1. +- Les performances faibles se traitent par des niveaux de qualité mesurés, + des LOD et des budgets d'effets, sans branche de rendu alternative. +- Le mode bas conserve la lisibilité fonctionnelle à 1080p tout en réduisant + particules, ombres, distance d'affichage et post-traitement. +- Le comportement visuel doit être vérifié sur macOS ARM64, Windows et Linux + avant la fin de la verticale. +- Les plans translucides utilisés comme volumes de phare dans le POC A ne sont + pas promus ; les projecteurs de production sont reconstruits et profilés. + +## Alternatives considérées + +- **GL Compatibility comme renderer de production** : rejeté, car ses limites + contrarient les besoins d'éclairage et d'atmosphère visés. +- **Forward+ avec GL Compatibility comme mode de secours** : rejeté, car cela + imposerait deux chemins graphiques, deux profils de performance et davantage + de régressions visuelles possibles. +- **Mobile comme renderer de secours** : rejeté pour la V1 pour les mêmes + raisons. Une cible matérielle future nécessiterait un nouvel ADR et une + validation graphique dédiée. diff --git a/docs/adr/0005-fixed-dive-ticks-and-typed-updates.md b/docs/adr/0005-fixed-dive-ticks-and-typed-updates.md new file mode 100644 index 0000000..6881f68 --- /dev/null +++ b/docs/adr/0005-fixed-dive-ticks-and-typed-updates.md @@ -0,0 +1,139 @@ +# 0005 — Ticks de plongée fixes et mises à jour typées + +- Statut : accepté +- Date : 2026-08-10 +- Remplace : aucun + +## Contexte + +La plongée combine une physique Godot, des ressources continues, des dégâts +ponctuels, des outils électriques, des alarmes et plusieurs présentations. Si +chaque acteur ou interface utilise sa propre horloge ou recalcule une partie de +ces règles, la pause, les variations de framerate et l'ordre des signaux peuvent +produire des résultats différents. + +Les informations n'ont pas toutes la même nature. Une commande exprime une +intention, la télémétrie décrit une mesure du monde, un snapshot décrit l'état +autoritaire à un instant et un événement rapporte un fait survenu pendant une +transition. Les regrouper dans un dictionnaire ou un signal générique rendrait +leur propriétaire et leur temporalité implicites. + +## Décision + +### Horloge et cadence + +La simulation de plongée utilise une cadence de physique configurée à 60 Hz. +L'application fournit exactement un `DiveStep` au domaine pour chaque tick de +physique accepté. Le domaine n'accède ni à l'horloge système, ni au framerate, +ni à l'arbre de scènes. + +Un `DiveTick` contient au minimum l'identifiant de session, une séquence +strictement croissante et la durée du pas en secondes. La durée normale vaut +`1 / 60` seconde. Un pas nul, négatif, non fini ou désordonné est refusé. Un pas +supérieur à 0,1 seconde est subdivisé de manière déterministe par l'application +ou refusé ; il n'est jamais appliqué comme un grand saut unique. + +La pause suspend la production de `DiveStep`. À la reprise, le temps passé en +pause n'est ni simulé, ni rattrapé. Les tests utilisent des ticks construits +explicitement et n'attendent jamais le temps réel. + +### Contrats d'entrée séparés + +- `VehicleCommand` contient l'intention de propulsion issue du clavier ou d'un + autre périphérique remappé. La souris n'oriente jamais le sous-marin. +- `CameraLookCommand` contient uniquement le mouvement de regard dans la + cabine. Il ne traverse pas le contrat de pilotage du véhicule. +- `VehicleTelemetry` est un snapshot immuable produit par l'acteur physique : + transform, vitesses, profondeur et efforts réellement appliqués. +- `DiveTelemetrySample` réduit cette télémétrie aux mesures nécessaires aux + règles de plongée. Le domaine ne reçoit aucun `Node` ou `Transform3D` Godot. +- `CollisionObservation` décrit un impact ponctuel avec une séquence stable et + sa vitesse normale en mètres par seconde. Les observations sont traitées de + manière idempotente. +- `ToolPowerDemand`, `EjectionIntent` et `ReturnRouteSample` décrivent + séparément la demande électrique des outils, l'intention d'éjection et les + données connues pour estimer le retour. +- `DiveStep` agrège ces entrées pour un tick sans changer leur responsabilité. + +Ces frontières utilisent des objets typés. Les dictionnaires anonymes restent +confinés aux codecs ou aux prototypes et ne traversent pas la production. + +### Règles résolues et session + +Au début de chaque plongée, `DiveRulesResolver` construit un +`ResolvedDiveRules` immuable en appliquant, dans cet ordre : + +1. les règles de base ; +2. les capacités et améliorations du sous-marin ; +3. le profil de difficulté ; +4. les aides d'accessibilité qui modifient le gameplay ; +5. la validation et le bornage des valeurs. + +`DiveSession` est l'unique propriétaire de l'oxygène, de la batterie, de la +coque, des seuils de pression, des alertes, de l'estimation de retour et de +l'état terminal de la plongée. C'est un objet de domaine pur : il reçoit un +`DiveStep`, applique les règles résolues dans un ordre fixe et retourne un +`DiveUpdate`. + +L'ordre d'un pas est : validation, consommation, pression, collisions ordonnées, +éjection, estimation du retour, alertes, état terminal, puis publication. Une +session terminale ne rejoue pas sa conséquence et refuse toute mutation de +gameplay ultérieure. + +### Snapshot et événements + +Chaque pas accepté produit un seul `DiveUpdate` cohérent, associé à la session +et à la séquence du tick. Il contient : + +- un `DiveSnapshot` immuable représentant l'état autoritaire après le pas ; +- zéro ou plusieurs événements typés décrivant uniquement les faits survenus + pendant ce pas. + +Le snapshot contient les valeurs actuelles et maximales, leurs unités et leurs +ratios pour batterie, oxygène, coque, pression et profondeur, ainsi que +l'estimation du retour, les alertes, l'éjection et l'éventuel motif terminal. +Il permet à un nouvel écran de se synchroniser sans rejouer l'historique. + +Les événements, tels que `AlertLevelChanged`, `HullDamaged`, +`ResourceDepleted` et `DiveTerminated`, déclenchent les réactions ponctuelles : +audio, animation, journal ou transition applicative. Ils portent une identité +ou une séquence permettant aux adaptateurs d'éviter un double traitement. Un +événement ne remplace jamais une valeur persistante du snapshot. + +La présentation et l'audio consomment `DiveUpdate`. Ils ne recalculent ni +seuils, ni dégâts, ni consommation, ni fin de plongée. Ils ne mutent jamais +directement `DiveSession` ou l'acteur physique ; toute action repart sous forme +de commande applicative. + +## Conséquences + +- Une même suite de règles, commandes, télémétries et observations produit le + même résultat dans les tests et dans le jeu. +- La pause est une absence de ticks, pas un multiplicateur de temps dispersé + entre plusieurs systèmes. +- Le HUD peut afficher des valeurs exactes et des pourcentages sans posséder + une copie concurrente des ressources. +- L'audio peut réagir une seule fois aux transitions tout en consultant l'état + courant à tout moment. +- La conversion entre les types Godot et les données pures nécessite des + adaptateurs explicites. +- Ajouter une nouvelle entrée ou sortie de simulation demande un contrat nommé + et des tests, mais évite les clés implicites et les dépendances de scène. +- Les consommations indépendantes du tick de plongée, notamment les actions de + port, restent des cas d'usage de campagne distincts. + +## Alternatives considérées + +- **Chaque système utilise `_process(delta)`** : rejeté, car les règles + dépendraient du framerate et leur ordre serait difficile à garantir. +- **`DiveSession` lit directement la physique et l'horloge Godot** : rejeté, + car le domaine ne serait plus pur ni testable sans scène. +- **Un dictionnaire unique de données par frame** : rejeté, car commandes, + mesures, états et événements auraient une temporalité implicite. +- **Publier uniquement des événements** : rejeté, car un écran ouvert plus tard + devrait reconstruire l'état en rejouant l'historique. +- **Publier uniquement un snapshot** : rejeté, car les consommateurs devraient + comparer deux états et réinventer les transitions ponctuelles. +- **Rattraper les ticks après une pause ou un ralentissement important** : + rejeté, car cela provoquerait des consommations et dégâts invisibles ou des + pics de calcul au retour. diff --git a/docs/adr/0006-sonar-contract-and-acquisition-gate.md b/docs/adr/0006-sonar-contract-and-acquisition-gate.md new file mode 100644 index 0000000..d9fb115 --- /dev/null +++ b/docs/adr/0006-sonar-contract-and-acquisition-gate.md @@ -0,0 +1,149 @@ +# 0006 — Contrat sonar commun et porte de sélection de l'acquisition + +- Statut : accepté +- Date : 2026-08-10 +- Remplace : aucun + +## Contexte + +Le POC B a validé une représentation bathymétrique continue, révélée +progressivement, qui rend le relief navigable. Son acquisition principale +utilise toutefois des sondes verticales disposées devant le véhicule et peut +montrer le dessus de surfaces qu'un faisceau frontal ne verrait pas. Une passe +frontale distincte détecte les anomalies métalliques. Cette combinaison est +lisible, mais elle n'est ni une simulation acoustique fidèle, ni une +implémentation de production. + +Une acquisition entièrement frontale serait plus cohérente avec la position du +capteur, mais risque de produire des trous, de perdre la continuité du relief ou +de rendre le sonar à nouveau difficile à interpréter. Le comportement validé du +POC doit donc rester la référence pendant l'expérience, sans importer son code +jetable dans la production. + +L'acquisition, la reconstruction et la présentation doivent pouvoir évoluer +séparément. Sinon, comparer deux méthodes imposerait aussi de réécrire le +renderer et rendrait leurs résultats impossibles à évaluer équitablement. + +## Décision + +### Contrat commun + +Toutes les acquisitions candidates produisent le même `SonarScan`. Celui-ci +contient au minimum : + +- un identifiant de scan et l'identifiant de la session ; +- le transform du capteur figé à l'émission ; +- la portée, les ouvertures et la durée de révélation ; +- les dimensions logiques de la grille ; +- un `SonarSampleBuffer` immuable ; +- des `SonarContact` distincts ; +- l'échelle de couleur et les valeurs réelles minimales et maximales ; +- l'état prêt, capture, révélation ou récupération. + +Le repère du scan est figé au déclenchement, avec son origine au capteur, +X à +tribord, +Y vers le haut et +Z vers l'avant, en mètres. L'adaptateur du monde +convertit le -Z avant de Godot vers ce contrat. Aucun consommateur ne déduit ou +réinterprète cette convention. + +`SonarSampleBuffer` encapsule des tableaux parallèles compacts de positions, +normales, intensités, élévations réelles, matériaux et statuts d'observation. +Un même index décrit un échantillon ; la ligne et la colonne sont dérivées des +dimensions de grille. Un accesseur typé comme `sample_at(row, column)` masque la +représentation interne et vérifie les bornes. Le buffer devient immuable avant +sa publication. + +Les contacts ne sont pas obtenus en moyennant tous les retours d'un matériau. +Un clustering spatial déterministe produit plusieurs `SonarContact`, chacun +avec une identité, un matériau, un centre, des limites, une intensité maximale +et un nombre d'échantillons. + +### Acquisition, reconstruction et présentation + +L'adaptateur sonar du monde possède l'acquisition physique. Le service +applicatif valide et structure les échantillons ainsi que les contacts. La +présentation reconstruit et affiche le relief à partir du scan publié ; elle ne +fait ni classification métier, ni détection de mission. + +La palette d'altitude reste relative au scan pour préserver le contraste, mais +les élévations réelles en mètres sont conservées et une légende chiffrée indique +l'échelle employée. Noir représente l'inconnu, pas une profondeur arbitraire. + +Un seul scan courant et un seul scan précédent en fondu existent au maximum. +La vue compacte et la vue agrandie affichent la même texture produite par un +renderer unique. Elles ne reconstruisent pas deux maillages indépendants. + +La révélation de référence dure 2,5 secondes, puis la récupération 0,35 seconde. +Une nouvelle demande n'est pas mise en file pendant cette séquence. Ces valeurs +restent configurables et testées, mais leur temporalité appartient au service +sonar, pas au HUD. + +### Porte de sélection + +Deux acquisitions sont réécrites derrière ce contrat uniquement pendant une +calibration bornée : + +1. la méthode de référence inspirée du POC B, combinant une grille + bathymétrique lisible et une détection frontale des anomalies ; +2. une méthode par faisceau frontal, reconstruite dans le même buffer. + +La comparaison utilise les mêmes poses de capteur, scènes, portées, dimensions +de sortie et cas de test : pente, chenal, rupture de relief, épave, ouverture, +zones masquées et plusieurs contacts métalliques séparés. Elle mesure au +minimum : + +- la compréhension du relief et d'un passage par des testeurs ; +- la détection de l'épave et de plusieurs anomalies sans fusion incorrecte ; +- la stabilité face aux trous et aux rotations du véhicule ; +- le coût CPU moyen et maximal, les allocations et le coût de reconstruction ; +- la conservation de l'effet de révélation validé. + +Le faisceau frontal n'est retenu que s'il atteint une lisibilité et une +stabilité au moins équivalentes à la réécriture de référence dans les budgets +de la verticale. Dans le cas contraire, la méthode inspirée du POC devient +l'acquisition de production. La fidélité acoustique ne prime pas sur la +capacité du joueur à lire le relief et à naviguer. + +La porte produit un compte rendu et un ADR de sélection. Après cette décision, +une seule acquisition reste autoritaire dans `game/`. L'autre implémentation +n'est pas maintenue comme chemin de secours ; les scènes, données de calibration +et résultats comparatifs peuvent être conservés pour prévenir les régressions. + +### Persistance + +Ni les échantillons bruts, ni le buffer, ni le maillage, ni les couleurs d'un +scan sous-marin ne sont sauvegardés. La campagne persiste seulement les faits +sémantiques sécurisés : zones d'écho de surface, épaves localisées, identités et +observations. Une sauvegarde ne tente jamais de reconstruire une image sonar en +cours de révélation. + +## Conséquences + +- Le comportement apprécié dans le POC reste disponible comme référence et + issue sûre sans promouvoir son code. +- Une acquisition peut être remplacée sans modifier le renderer, le HUD ou les + règles de mission. +- Le buffer compact limite les allocations, mais demande un accesseur typé et + des tests d'alignement de tous les tableaux. +- Les conversions de coordonnées sont explicites et testables. +- Plusieurs anomalies restent distinctes, contrairement à la moyenne globale + provisoire du POC. +- Le rendu sonar possède une seule reconstruction et une seule texture, ce qui + réduit les divergences entre les deux tailles d'affichage. +- La calibration ajoute un coût court à la tranche sonar, borné par une porte + de décision et supprimant ensuite le chemin perdant. + +## Alternatives considérées + +- **Copier le sonar du POC B dans la production** : rejeté, car cela + introduirait ses dépendances, sa reconstruction complète et ses contrats + implicites dans `game/`. +- **Choisir immédiatement le faisceau frontal** : rejeté, car sa vraisemblance + ne garantit pas la lisibilité déjà difficile à obtenir. +- **Conserver durablement les deux acquisitions** : rejeté, car deux + implémentations autoritaires doubleraient réglages, tests et risques de + régression. +- **Publier directement des nœuds, maillages ou dictionnaires depuis le monde** : + rejeté, car acquisition et présentation resteraient couplées. +- **Sauvegarder la carte sonar brute** : rejeté, car elle représente une vue + temporaire dérivée du monde et alourdirait le format sans bénéfice de + progression. diff --git a/docs/adr/0007-versioned-json-saves.md b/docs/adr/0007-versioned-json-saves.md new file mode 100644 index 0000000..70420ee --- /dev/null +++ b/docs/adr/0007-versioned-json-saves.md @@ -0,0 +1,192 @@ +# 0007 — Sauvegardes JSON versionnées et remplaçables sans perte + +- Statut : accepté +- Date : 2026-08-10 +- Remplace : aucun + +## Contexte + +La campagne contient un état métier durable : argent, dette, position du +navire, contrats, archives, épaves connues, cargaison et améliorations. Une +plongée contient en plus un état physique très fréquent et temporaire. Tenter +de sérialiser les nœuds, les collisions, le maillage sonar ou la position +arbitraire du sous-marin couplerait la sauvegarde aux scènes et rendrait les +migrations fragiles. + +Le joueur doit pouvoir conserver plusieurs étapes d'une même campagne, utiliser +des sauvegardes manuelles et reprendre après un crash sans perdre un +sous-marin à cause d'une écriture interrompue. Les réglages de plateforme et +d'accessibilité ne doivent pas être dupliqués dans chaque état de campagne. + +## Décision + +### Format et enveloppe + +Chaque sauvegarde est un document JSON explicite. Son enveloppe contient au +minimum : + +```text +schema_version entier positif +campaign_id identifiant stable de la partie +save_id identifiant stable de cette sauvegarde +created_at_utc date ISO 8601 utilisée comme métadonnée d'affichage +kind before_dive | after_dive | campaign | manual +display_name libellé facultatif d'une sauvegarde manuelle +payload état de campagne versionné +``` + +Les identifiants sont générés et validés par l'application. Un libellé saisi +par le joueur n'est jamais utilisé comme nom de fichier ou fragment de chemin. +Les horodatages viennent d'une horloge injectée ; ils n'influencent aucune +règle de gameplay. + +Le codec JSON appartient à l'infrastructure. Il convertit entre les +dictionnaires du format et des objets de transfert typés, puis entre ces objets +et le domaine. Aucun dictionnaire anonyme, chemin de scène, `Node`, `Resource` +arbitraire ou objet exécutable ne traverse cette frontière. + +Le payload contient au minimum : + +- difficulté, argent et dette ; +- `VesselLocation`, disponibilité du sous-marin et ressources non remplies ; +- contrats, objectifs connus et révélations ; +- zones d'écho, épaves localisées, identités et observations sécurisées ; +- états persistants d'ouverture et d'interaction ; +- panier, cale, stockage et objets narratifs ; +- archives, plans et améliorations ; +- dernier résultat de plongée. + +Les références de contenu utilisent des identifiants stables validés au +chargement. Une valeur dérivée est recalculée à partir de ses sources lorsqu'il +est possible de le faire sans perte d'information. + +### Organisation des fichiers + +Les campagnes sont rangées sous : + +```text +user://saves// + autosave_before_dive.json + autosave_after_dive.json + autosave_campaign.json + manual/.json + active_dive.marker.json +``` + +Les fichiers temporaires et de secours utilisent des suffixes techniques dans +le même dossier que leur cible. Ils ne sont pas affichés comme des sauvegardes. +Le `campaign_id` identifie la lignée créée par une nouvelle partie ; il ne +représente pas une seconde campagne narrative. + +Les réglages du joueur sont versionnés et stockés séparément sous +`user://settings.json`. Ils comprennent notamment commandes, accessibilité, +audio, affichage et qualité Forward+. Charger une autre sauvegarde de campagne +ne les remplace pas. + +### Politique des emplacements + +Trois emplacements automatiques ont une signification fixe : + +- `before_dive` est écrit après validation de la préparation et avant le + chargement de la scène sous-marine ; +- `after_dive` est écrit après application unique du résultat de plongée au + navire et à la campagne ; +- `campaign` est écrit après une action durable de progression, notamment + transfert, analyse, remise, vente, entretien ou achat. + +Chaque autosauvegarde conserve sa dernière version valide comme secours +invisible. Une sauvegarde manuelle remplacée conserve également sa précédente +version valide comme secours technique. + +Le joueur peut créer jusqu'à dix sauvegardes manuelles nommées, uniquement +lorsqu'aucune plongée n'est active. Atteindre la limite exige de choisir et de +confirmer un remplacement ou une suppression ; aucune sauvegarde visible n'est +effacée automatiquement. + +`Continuer` charge la sauvegarde principale valide la plus récente parmi tous +les emplacements visibles. Si elle est invalide, son secours est essayé, puis +la recherche continue avec les autres sauvegardes valides. `Charger` affiche +les sauvegardes visibles avec leur statut sans supprimer ni réécrire les +fichiers invalides. + +### Écriture récupérable + +Une écriture suit toujours cette procédure : + +1. sérialiser vers un fichier temporaire créé dans le dossier de la cible ; +2. fermer le fichier, puis le relire intégralement ; +3. décoder, migrer si nécessaire et valider le document relu ; +4. préserver la cible valide actuelle comme secours ; +5. remplacer la cible par renommage sur le même volume ; +6. restaurer le secours si le remplacement final échoue ; +7. signaler explicitement le succès ou l'erreur à l'application. + +La dernière sauvegarde valide n'est jamais supprimée avant qu'une nouvelle +copie complète ait été écrite et validée. Un échec d'I/O ne devient ni une +réussite silencieuse, ni un nouvel état de campagne. + +Au démarrage, l'infrastructure peut supprimer uniquement les temporaires +orphelins dont la cible ou le secours valide a été identifié. Elle ne supprime +jamais automatiquement une sauvegarde visible invalide. + +### Plongée interrompue + +Après réussite de l'autosauvegarde `before_dive`, le démarrage effectif de la +plongée crée `active_dive.marker.json`. Ce marqueur contient seulement le +`campaign_id`, le `save_id` de reprise et un identifiant de session. Il ne +contient aucune position, ressource, collision ou donnée sonar. + +Une fin de plongée traitée avec succès supprime le marqueur après écriture de +l'état `after_dive`. Si le jeu redémarre avec un marqueur valide, il propose ou +effectue la reprise depuis l'autosauvegarde `before_dive` référencée. Le crash +n'inflige ni perte automatique du véhicule, ni consommation intermédiaire. + +La position arbitraire du sous-marin, les vitesses, particules, collisions, +échantillons sonar, couleurs et maillages dynamiques ne sont jamais sauvegardés. + +### Versions et migrations + +`schema_version` existe dès la V1. Le chargement applique des migrations +explicites, successives et déterministes d'une version prise en charge vers la +version courante, puis valide l'ensemble des invariants et références de +contenu. + +Une fixture anonymisée de chaque version prise en charge est conservée sous +`tests/fixtures/`. Les tests vérifient migration, aller-retour, sauvegarde +tronquée, référence absente, secours et refus d'une version inconnue. + +Une version plus récente que celle du jeu est refusée proprement. Le fichier +original n'est ni modifié, ni rétrogradé. Une migration réussie en mémoire ne +réécrit pas non plus l'original avant une nouvelle sauvegarde explicitement +déclenchée. + +## Conséquences + +- Les sauvegardes survivent aux changements de scènes et peuvent être testées + sans lancer une plongée. +- Plusieurs étapes d'une partie coexistent sans transformer les libellés du + joueur en chemins dangereux. +- Une plongée ne peut pas être reprise à la seconde exacte de l'interruption ; + elle recommence depuis sa préparation validée. +- Les sauvegardes et leurs secours consomment davantage d'espace, mais elles ne + contiennent pas les données physiques ou sonar volumineuses. +- Chaque modification du format exige une migration et des fixtures de + contrat. Toute exception nécessite un nouvel ADR. +- Les réglages restent disponibles même lorsqu'aucune campagne valide n'existe. +- Les erreurs de disque ou de validation restent récupérables et visibles pour + le joueur. + +## Alternatives considérées + +- **Sérialiser l'arbre de scènes Godot** : rejeté, car les chemins, nœuds et + états physiques ne constituent pas un contrat de sauvegarde stable. +- **Sauvegarder pendant la plongée** : rejeté pour la V1, car restaurer une + simulation cohérente coûterait beaucoup plus que reprendre avant le départ. +- **Une seule sauvegarde écrasée en place** : rejeté, car une interruption + d'écriture pourrait détruire toute la progression. +- **Un format binaire opaque** : rejeté pour la V1, car il compliquerait + inspection, fixtures et migrations sans besoin de taille démontré. +- **Inclure les réglages dans chaque sauvegarde** : rejeté, car ils + appartiennent au joueur et à la plateforme, pas à une étape de campagne. +- **Supprimer automatiquement les sauvegardes invalides ou excédentaires** : + rejeté, car la récupération ou le choix doit rester sous contrôle du joueur. diff --git a/docs/adr/0008-composition-owned-service-lifetimes.md b/docs/adr/0008-composition-owned-service-lifetimes.md new file mode 100644 index 0000000..aaa2ce5 --- /dev/null +++ b/docs/adr/0008-composition-owned-service-lifetimes.md @@ -0,0 +1,128 @@ +# 0008 — Services possédés et injectés par la composition + +- Statut : accepté +- Date : 2026-08-10 +- Remplace : aucun + +## Contexte + +Les réglages, l'accès aux sauvegardes, l'audio et une campagne chargée doivent +survivre à plusieurs changements d'écran. Godot permet de résoudre ce besoin +avec des autoloads, des singletons statiques ou un bus de signaux global. Ces +solutions rendent toutefois les dépendances accessibles depuis n'importe quel +script, masquent leur durée de vie et compliquent l'isolation des tests. + +La verticale possède déjà une couche `composition`, seule autorisée à connaître +les implémentations concrètes de toutes les autres couches. Elle peut donc +maintenir les objets de longue durée sans les transformer en état global. + +## Décision + +### Aucun accès global de gameplay + +Aucun autoload personnalisé, singleton statique, registre global, service +locator ou bus de signaux global n'est introduit pour le gameplay de la +verticale. Une longue durée de vie ne suffit pas à justifier un accès global. + +Les singletons fournis par Godot restent des détails de plateforme. Seuls les +adaptateurs autorisés les utilisent ; le domaine et les cas d'usage ne lisent +pas directement `Input`, `SceneTree`, `AudioServer`, les fichiers ou l'horloge. + +### Racine persistante + +`vertical_slice_root.tscn` est la racine de composition persistante de la +verticale. Elle crée les implémentations concrètes, les relie à leurs ports et +possède un emplacement destiné à l'écran ou au monde actif. Une transition +remplace ce contenu enfant sans remplacer la racine et sans demander aux scènes +de rechercher des services dans l'arbre. + +La composition est responsable de : + +- construire les adaptateurs d'infrastructure et les cas d'usage ; +- charger ou créer l'état de campagne ; +- injecter les contrats requis avant d'activer une scène ; +- connecter les sorties d'une scène aux commandes applicatives appropriées ; +- déconnecter et libérer les dépendances de portée courte lors d'une + transition ; +- choisir l'écran suivant à partir d'un résultat applicatif explicite. + +Une scène ne parcourt pas ses parents, `/root`, un groupe global ou un chemin +absolu afin de découvrir une dépendance. Elle expose une méthode de +configuration, des propriétés typées ou un constructeur de composition +explicite. L'absence d'une dépendance requise échoue immédiatement en +développement et dans les tests de chargement. + +### Durées de vie + +Trois portées sont distinguées : + +1. **Application** : réglages, localisation, adaptateurs d'entrée et d'audio, + horloge de plateforme, accès au stockage et liste des sauvegardes. Ils vivent + tant que la racine existe. +2. **Campagne** : `CampaignState`, progression de mission et cas d'usage liés à + une partie. Ils sont créés lors d'une nouvelle partie ou d'un chargement et + détruits lors de sa fermeture ou de son remplacement. +3. **Plongée** : `ResolvedDiveRules`, `DiveSession`, acteurs, adaptateurs + physiques et service sonar courant. Ils sont créés au départ d'une plongée + et détruits après production de son résultat immuable. + +Un objet de portée longue peut fournir un port à une portée plus courte. Il ne +conserve pas en retour une référence implicite vers un acteur ou une scène déjà +détruite. Les connexions de signaux suivent la même propriété et sont retirées +avec leur portée. + +Les services d'infrastructure ne deviennent pas propriétaires des règles. Par +exemple, le dépôt de sauvegarde écrit un document demandé par un cas d'usage ; +il ne décide pas quand une mission progresse ou combien d'argent est gagné. + +### Navigation et communications + +La présentation traduit une interaction en commande ou appel de cas d'usage. +Elle consomme ensuite un résultat, un snapshot ou un événement typé. Elle ne +modifie jamais directement l'état de campagne ou un acteur du monde. + +Les événements locaux peuvent utiliser les signaux Godot entre objets déjà +reliés par la composition. Un signal décrit un fait passé et ne sert pas de +canal global anonyme. Une intention reste une commande nommée. + +Chaque écran et composition de plongée peut être instancié dans un test avec de +faux ports, sans charger une campagne réelle, le stockage de la machine ou tous +les autres écrans. + +### Évolution + +Tout nouvel autoload, accès statique global ou exception durable à cette +politique exige un ADR qui précise son propriétaire, sa durée de vie, son API, +sa stratégie de réinitialisation et son isolation en test. + +## Conséquences + +- Les dépendances et leur durée de vie sont visibles à l'endroit où les scènes + sont assemblées. +- Un changement d'écran ne détruit pas les services applicatifs nécessaires à + la session. +- Une nouvelle campagne peut remplacer proprement la précédente sans conserver + ses signaux, caches ou acteurs. +- Les écrans sont testables avec des doubles légers et ne dépendent pas d'un + ordre d'initialisation d'autoloads. +- La composition contient davantage de code d'assemblage explicite et doit + rester distincte des règles de gameplay. +- Les transitions doivent libérer leurs objets de portée courte et seront + couvertes par des tests d'intégration et de fuite de nœuds. +- Les fonctionnalités transversales passent par des ports nommés plutôt que par + une commodité globale. + +## Alternatives considérées + +- **Un autoload par service** : rejeté, car sauvegarde, campagne, audio et + navigation deviendraient accessibles et mutables depuis toutes les couches. +- **Un unique `GameManager` autoload** : rejeté, car il concentrerait + assemblage, navigation et règles dans un propriétaire générique. +- **Un bus d'événements global** : rejeté, car producteurs et consommateurs + seraient reliés par des noms implicites et des durées de vie difficiles à + vérifier. +- **Remplacer toute la scène courante à chaque écran** : rejeté comme + navigation principale, car les services persistants devraient alors être + globaux ou reconstruits avec un état dupliqué. +- **Rechercher les services dans les parents ou par groupe Godot** : rejeté, + car la dépendance resterait cachée et dépendrait de la forme de l'arbre. diff --git a/docs/adr/0009-player-knowledge-driven-progression.md b/docs/adr/0009-player-knowledge-driven-progression.md new file mode 100644 index 0000000..2b5a5c4 --- /dev/null +++ b/docs/adr/0009-player-knowledge-driven-progression.md @@ -0,0 +1,163 @@ +# 0009 — Progression pilotée par les connaissances et actions du joueur + +- Statut : accepté +- Date : 2026-08-10 +- Remplace : aucun + +## Contexte + +L'entreprise qui engage le joueur sait qu'elle poursuit un trésor, mais présente +la première mission comme la récupération du travail du précédent opérateur. +Le monde possède donc une vérité, le commanditaire possède des intentions et le +joueur ne dispose au départ que d'une version partielle ou mensongère. + +Une épave peut être localisée, identifiée, examinée, ouverte et revisitée sans +que son objectif principal ait été sécurisé. Débloquer la suite dès la +découverte du navire rendrait la récupération de l'objet ou de l'indice +facultative et contredirait la boucle de campagne. À l'inverse, utiliser les +secrets du contenu pour colorer une icône ou construire un objectif visible +révélerait indirectement une information que le joueur n'a pas encore obtenue. + +## Décision + +### Trois niveaux d'information + +Le modèle sépare explicitement : + +1. **La vérité du contenu** : identité réelle d'une épave, objets présents, + chronologie, motivations et liens narratifs. Elle réside dans les définitions + de contenu et n'est jamais envoyée telle quelle à la présentation. +2. **Les termes visibles du contrat** : `ContractTerms` contient uniquement le + commanditaire affiché, les informations communiquées au joueur, l'objectif + officiel, le paiement et les prérequis annoncés. +3. **Les connaissances du joueur** : `MissionProgress` contient uniquement les + faits observés, révélations acquises, objectifs connus, épaves localisées, + identités établies et activités découvertes. + +Les intentions cachées de l'entreprise et les véritables étapes de progression +sont décrites dans des `CampaignObjectiveDefinition` distinctes de +`ContractTerms`. Une définition peut rester invisible, devenir connue après une +révélation ou n'exister que comme règle interne reliant des faits. + +La sauvegarde persiste les identifiants et états nécessaires à la progression, +mais l'application expose à chaque écran une projection filtrée par les +connaissances du joueur. Le fait qu'une donnée existe dans le contenu ou le +format de sauvegarde ne l'autorise pas à traverser le contrat de présentation. + +### Faits, objectifs et révélations + +Un fait est un événement métier idempotent déjà établi, par exemple : + +- une zone d'écho a été obtenue ; +- une épave a été localisée ; +- une preuve a été observée ; +- une identité a été établie ; +- un accès a été ouvert ; +- un objet se trouve sur le site, dans le panier, dans la cale ou a été remis ; +- des données récupérées ont été analysées. + +Une révélation est une nouvelle connaissance produite par une commande validée +et ses prérequis, pas un texte déclenché par l'interface. Elle peut rendre +visible un objectif, une activité, une identité ou une nouvelle zone probable. + +Un `CampaignObjectiveDefinition` exprime des prérequis typés et un effet. La +progression les évalue après les commandes applicatives. Elle ne dépend ni de +la scène ouverte, ni d'un libellé, ni d'une comparaison effectuée dans le HUD. +Les mêmes faits ne produisent une révélation qu'une seule fois. + +Une hypothèse de joueur, telle que la cible présumée dans le dossier, reste +révisable et n'est pas un fait d'identification. Elle ne valide ni le contrat, +ni une étape cachée. + +### Localisation distincte de l'accomplissement + +Localiser ou identifier une épave ne satisfait pas automatiquement l'objectif +qui y conduit. Chaque étape requiert le dernier fait métier prévu par sa +définition : récupération, sécurisation dans la cale, analyse, lecture, remise +ou autre action explicitement nommée. + +Pour la première mission de la verticale : + +1. le contrat visible demande de retrouver le navire de prospection et son + module de cartographie ; +2. la localisation rend l'épave revisitable, sans révéler la piste suivante ; +3. l'identification confirme le navire, sans terminer la mission ; +4. les objets placés dans le panier restent provisoires ; +5. le treuillage puis `EmptyBasket` sécurisent le module et ses données dans la + cale ; +6. `AnalyzeRecoveredData` révèle la zone probable du U-Boot ; +7. la remise physique du module et le paiement restent des actions séparées. + +La copie analysable des données demeure acquise après son transfert, même si le +module physique est remis ensuite. Les intentions réelles de l'entreprise ne +deviennent visibles que par une révélation narrative prévue, pas par le simple +accomplissement technique d'un objectif caché. + +### Projection de la carte + +La carte globale reçoit une projection de `MissionProgress` et applique les +états suivants : + +- une vérité de contenu non localisée n'est pas affichée comme épave ; +- une zone d'écho connue reste une zone approximative ; +- une épave localisée sans identité établie est nommée + `Épave non identifiée` ; +- une épave identifiée utilise le nom du navire ; +- l'icône est colorée lorsqu'au moins une activité connue et réalisable reste à + accomplir ; +- l'icône devient monochrome lorsque toutes les activités connues sont + terminées et qu'aucun objectif connu n'y demande une action. + +Une activité cachée ne colore pas l'icône : cela divulguerait son existence. +Lorsqu'une révélation la rend connue, la projection peut redevenir colorée. +Cette couleur est une information de présentation dérivée d'un état métier +filtré ; elle n'est pas sauvegardée comme vérité indépendante. + +### Responsabilités + +Le domaine possède faits, prérequis, états d'objets et transitions de +progression. L'application exécute les commandes, publie les révélations et +construit les projections autorisées. Le monde produit uniquement des +observations et interactions. La présentation affiche les projections et émet +des commandes ; elle ne lit jamais directement les définitions cachées. + +Les tests de contenu vérifient au minimum : + +- que le contrat visible ne référence pas une révélation prématurée ; +- que chaque objectif de campagne possède une suite de prérequis atteignable ; +- que la campagne reste terminable sans le pack d'archives facultatif ; +- que localiser ou identifier seul la première épave ne révèle pas le U-Boot ; +- que l'analyse des données sécurisées le révèle une seule fois ; +- que chaque leurre possède des faits cohérents et une contradiction + découvrable ; +- qu'aucune projection ne contient un fait ou une activité encore caché. + +## Conséquences + +- L'entreprise peut mentir sans dupliquer une seconde mission ou risquer que + l'interface expose son véritable objectif. +- Une épave découverte reste facile à retrouver, mais sa découverte ne court- + circuite pas la récupération et l'analyse. +- La carte peut revenir sur une épave lorsqu'une nouvelle activité y est + révélée, sans modifier son historique d'exploration. +- Ajouter une révélation exige des prérequis typés et un test de contenu, ce qui + augmente le travail d'auteur mais réduit les blocages narratifs. +- Les écrans reçoivent des projections dédiées plutôt que les ressources de + contenu complètes. +- Les états de couleur et les libellés de carte restent dérivés et ne créent + pas une source de vérité concurrente. + +## Alternatives considérées + +- **Mettre objectifs visibles et cachés dans `ContractTerms`** : rejeté, car un + écran ou un export de contrat pourrait révéler les intentions de l'entreprise. +- **Utiliser des booléens globaux sans propriétaire** : rejeté, car leur origine, + leur ordre et leurs prérequis seraient implicites. +- **Débloquer la suite à l'identification de l'épave** : rejeté, car l'objet ou + l'indice demandé deviendrait facultatif. +- **Laisser l'UI déduire l'objectif suivant** : rejeté, car la présentation + calculerait une règle de campagne. +- **Colorer une épave tant qu'elle contient un secret quelconque** : rejeté, + car la carte divulguerait les activités inconnues du joueur. +- **Valider immédiatement la cible présumée** : rejeté, car l'hypothèse doit + rester révisable et la déduction ne doit pas devenir un quiz d'interface. diff --git a/docs/adr/0010-single-surface-bathymetry-for-v1.md b/docs/adr/0010-single-surface-bathymetry-for-v1.md new file mode 100644 index 0000000..cd45c3c --- /dev/null +++ b/docs/adr/0010-single-surface-bathymetry-for-v1.md @@ -0,0 +1,99 @@ +# 0010 — Bathymétrie à surface unique pour la V1 + +- Statut : accepté +- Date : 2026-08-10 +- Remplace : aucun + +## Contexte + +Le sonar retenu représente le fond par une grille bathymétrique continue. Pour +une ligne et une colonne données, cette structure ne peut stocker qu'une seule +élévation autoritaire. Elle convient aux pentes, crêtes, chenaux, dépressions et +reliefs ouverts, mais pas à un tunnel, une arche ou une grotte possédant +plusieurs surfaces superposées au même emplacement horizontal. + +Une représentation hybride pourrait combiner plusieurs couches, fragments de +maillage, points volumétriques ou voxels locaux. Aucun site de la verticale +n'exige encore cette complexité : les véritables grottes à plusieurs niveaux et +les surplombs complexes sont hors périmètre de la V1. + +## Décision + +Le terrain navigable et les informations critiques du sonar de la V1 utilisent +une bathymétrie à surface unique. Chaque cellule valide du scan possède au plus +une hauteur de terrain reconstruite. + +Les sites de la verticale sont conçus de sorte que : + +- les routes nécessaires puissent être comprises depuis une carte de hauteur ; +- aucun objectif n'exige de passer sous un surplomb naturel ou dans un tunnel ; +- aucune preuve indispensable ne soit cachée par une seconde surface au même + emplacement horizontal ; +- une absence de mesure reste inconnue et ne soit pas comblée artificiellement ; +- les limites de la zone jouable ne soient pas présentées comme un relief à + interpréter. + +Les épaves, le sous-marin, le navire et les accessoires restent de véritables +modèles 3D pour le rendu et les collisions. Une épave peut présenter des +ouvertures, des volumes, des éléments dressés et des zones interactives. Le +sonar de la V1 n'a toutefois pas à reconstruire précisément son intérieur, son +dessous ou plusieurs ponts superposés. + +Les surfaces anormales et les épaves peuvent être signalées par des +`SonarContact` distincts et par leur contribution visible à la surface +extérieure. Une interaction, une observation visuelle ou un indice du dossier +confirme ensuite leur nature. Le renderer sonar ne fabrique pas un volume +intérieur pour compenser les limites de la bathymétrie. + +Un surplomb purement décoratif reste autorisé s'il n'est ni une route, ni un +abri nécessaire, ni le support d'une information de mission et si son absence +ou sa simplification sur le sonar ne trompe pas le joueur. Cette exception est +vérifiée lors de la revue du site et des tests humains. + +Aucune interface générique pour plusieurs couches, aucun voxel local et aucun +champ de distance ne sont ajoutés préventivement. Le contrat du scan conserve +les valeurs réelles et les contacts nécessaires, sans prétendre résoudre un +besoin futur non validé. + +### Déclencheur d'une évolution + +Un prototype de représentation hybride devient nécessaire seulement lorsqu'un +site retenu exige au moins l'un des comportements suivants : + +- naviguer au-dessus et au-dessous de surfaces superposées ; +- lire au sonar l'entrée et la continuité d'une grotte ou d'un tunnel ; +- distinguer plusieurs ponts, plafonds ou niveaux comme information de jeu ; +- représenter une cible indispensable qu'une carte de hauteur rend ambiguë ou + invisible. + +Ce prototype comparera au minimum couches bathymétriques locales, fragments de +maillage et représentation volumétrique bornée. Son adoption, ses données et +son coût de persistance nécessiteront un nouvel ADR. Elle ne modifiera pas +silencieusement le contrat de la V1. + +## Conséquences + +- La reconstruction sonar reste continue, compacte et compatible avec le + langage visuel validé par le POC B. +- Le level design de la verticale doit éviter les configurations que le sonar + ne peut pas communiquer honnêtement. +- Les épaves peuvent conserver une géométrie visuelle riche sans imposer un + sonar volumétrique. +- Certaines formes 3D seront simplifiées ou absentes de la carte sonar ; elles + ne peuvent pas porter seules une information critique. +- Les budgets de la tranche sonar ne comprennent ni voxelisation, ni + reconstruction multi-couche, ni navigation dans une grotte. +- Une future évolution dispose de déclencheurs concrets et devra prouver sa + lisibilité avant d'élargir le format de scan. + +## Alternatives considérées + +- **Construire immédiatement un sonar hybride** : rejeté, car aucun objectif de + la verticale ne justifie encore son coût de données, de rendu et de test. +- **Utiliser des voxels pour tous les sites** : rejeté, car les essais du POC B + ont montré une lecture plus confuse que la surface bathymétrique continue. +- **Aplatir silencieusement une grotte complexe dans la carte** : rejeté, car le + sonar donnerait une information trompeuse au joueur. +- **Interdire toute géométrie 3D complexe aux épaves** : rejeté, car la limite + concerne la représentation sonar et le level design critique, pas les modèles + visuels ou les collisions locales. diff --git a/docs/adr/0011-godot-4-7-2-production-baseline.md b/docs/adr/0011-godot-4-7-2-production-baseline.md new file mode 100644 index 0000000..1da14b7 --- /dev/null +++ b/docs/adr/0011-godot-4-7-2-production-baseline.md @@ -0,0 +1,45 @@ +# 0011 — Godot 4.7.2 comme baseline de production + +- Statut : accepté +- Date : 2026-10-02 +- Remplace : aucun + +## Contexte + +La verticale de production a démarré sous Godot 4.7.1 et la barrière qualité +refusait toute autre version. L'environnement de développement disponible +fournit désormais Godot 4.7.2. Conserver l'exigence exacte 4.7.1 empêche +d'exécuter la barrière alors que cette mise à jour corrective reste dans la +même branche 4.7 et ne change ni le renderer Forward+ ni les contrats de jeu. + +Les POC A et B restent des références historiques figées sous Godot 4.7.1 et +GL Compatibility. Leur baseline n'est pas réécrite rétroactivement. + +## Décision + +Godot 4.7.2 stable devient la version exacte exigée pour le projet de +production et sa barrière qualité. Les documents de production et le commentaire +du projet sont alignés sur cette version. + +La barrière continue de comparer la version exacte au préfixe officiel attendu. +Une version antérieure, une version corrective ultérieure ou une préversion est +refusée jusqu'à une nouvelle décision explicite. + +## Conséquences + +- La barrière s'exécute avec l'installation Godot 4.7.2 disponible. +- Les résultats de la verticale sont reproductibles sur une version moteur + unique. +- La mise à jour doit être validée par le chargement de toutes les ressources, + les tests unitaires et les tests d'intégration existants. +- Les baselines des POC continuent de documenter la version 4.7.1 avec laquelle + elles ont été validées. + +## Alternatives considérées + +- **Rester sur Godot 4.7.1** : rejeté, car cette version n'est plus + l'installation disponible pour poursuivre la verticale. +- **Accepter toute version 4.7.x** : rejeté, car une plage implicite rendrait + les validations moins reproductibles. +- **Supprimer le contrôle de version** : rejeté, car il masquerait les mises à + jour du moteur au lieu de les valider explicitement. diff --git a/game/application/dive/dive_tick_runner.gd b/game/application/dive/dive_tick_runner.gd new file mode 100644 index 0000000..9084755 --- /dev/null +++ b/game/application/dive/dive_tick_runner.gd @@ -0,0 +1,75 @@ +class_name DiveTickRunner +extends RefCounted + +const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") +const DiveSession = preload("res://game/domain/dive/dive_session.gd") +const DiveTick = preload("res://game/domain/dive/dive_tick.gd") +const DiveTelemetrySample = preload("res://game/domain/dive/dive_telemetry_sample.gd") +const CollisionObservation = preload("res://game/domain/dive/collision_observation.gd") +const ToolPowerDemand = preload("res://game/domain/dive/tool_power_demand.gd") +const EjectionIntent = preload("res://game/domain/dive/ejection_intent.gd") +const ReturnRouteSample = preload("res://game/domain/dive/return_route_sample.gd") +const DiveStep = preload("res://game/domain/dive/dive_step.gd") +const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") + +var _session: DiveSession +var _next_sequence := 0 + + +func _init(session: DiveSession) -> void: + _session = session + + +func next_sequence() -> int: + return _next_sequence + + +func initial_update() -> DiveUpdate: + return DiveUpdate.initial(_session.current_snapshot()) + + +func advance( + telemetry: VehicleTelemetry, + elapsed_seconds: float, + collisions: Array[CollisionObservation] = [], + tool_power_demands: Array[ToolPowerDemand] = [], + ejection_intent: EjectionIntent = null, + return_route: ReturnRouteSample = null +) -> DiveUpdate: + if telemetry == null: + return DiveUpdate.rejected( + _next_sequence, + "vehicle telemetry is missing", + _session.current_snapshot() + ) + var telemetry_error: String = telemetry.validation_error( + _session.session_id, + _next_sequence + ) + if not telemetry_error.is_empty(): + return DiveUpdate.rejected( + _next_sequence, + telemetry_error, + _session.current_snapshot() + ) + + var tick := DiveTick.new(_session.session_id, _next_sequence, elapsed_seconds) + var sample := DiveTelemetrySample.new( + telemetry.depth_m, + telemetry.longitudinal_effort, + telemetry.steering_effort, + telemetry.vertical_effort, + telemetry.high_thrust_active + ) + var step := DiveStep.new( + tick, + sample, + collisions, + tool_power_demands, + ejection_intent, + return_route + ) + var update: DiveUpdate = _session.advance(step) + if update.accepted: + _next_sequence += 1 + return update diff --git a/game/application/dive/vehicle_telemetry.gd b/game/application/dive/vehicle_telemetry.gd new file mode 100644 index 0000000..deb9296 --- /dev/null +++ b/game/application/dive/vehicle_telemetry.gd @@ -0,0 +1,74 @@ +class_name VehicleTelemetry +extends RefCounted + +var session_id: StringName +var sequence: int +var world_position_m: Vector3 +var world_orientation: Quaternion +var world_linear_velocity_mps: Vector3 +var local_angular_velocity_rps: Vector3 +var depth_m: float +var longitudinal_effort: float +var steering_effort: float +var vertical_effort: float +var high_thrust_active: bool + + +func _init( + p_session_id: StringName, + p_sequence: int, + p_world_position_m: Vector3, + p_world_orientation: Quaternion, + p_world_linear_velocity_mps: Vector3, + p_local_angular_velocity_rps: Vector3, + p_depth_m: float, + p_longitudinal_effort: float, + p_steering_effort: float, + p_vertical_effort: float, + p_high_thrust_active: bool +) -> void: + session_id = p_session_id + sequence = p_sequence + world_position_m = p_world_position_m + world_orientation = p_world_orientation + world_linear_velocity_mps = p_world_linear_velocity_mps + local_angular_velocity_rps = p_local_angular_velocity_rps + depth_m = p_depth_m + longitudinal_effort = p_longitudinal_effort + steering_effort = p_steering_effort + vertical_effort = p_vertical_effort + high_thrust_active = p_high_thrust_active + + +func validation_error(expected_session_id: StringName, expected_sequence: int) -> String: + if session_id != expected_session_id: + return "telemetry session_id does not match" + if sequence != expected_sequence: + return "telemetry sequence does not match expected tick" + if not _vector_is_finite(world_position_m): + return "telemetry world position is not finite" + if not _quaternion_is_finite(world_orientation): + return "telemetry orientation is not finite" + if not _vector_is_finite(world_linear_velocity_mps): + return "telemetry linear velocity is not finite" + if not _vector_is_finite(local_angular_velocity_rps): + return "telemetry angular velocity is not finite" + if not is_finite(depth_m) or depth_m < 0.0: + return "telemetry depth must be finite and non-negative" + for effort: float in [longitudinal_effort, steering_effort, vertical_effort]: + if not is_finite(effort) or effort < -1.0 or effort > 1.0: + return "telemetry efforts must be between -1 and 1" + return "" + + +func _vector_is_finite(value: Vector3) -> bool: + return is_finite(value.x) and is_finite(value.y) and is_finite(value.z) + + +func _quaternion_is_finite(value: Quaternion) -> bool: + return ( + is_finite(value.x) + and is_finite(value.y) + and is_finite(value.z) + and is_finite(value.w) + ) diff --git a/game/application/input/input_binding_snapshot.gd b/game/application/input/input_binding_snapshot.gd new file mode 100644 index 0000000..67e1cc2 --- /dev/null +++ b/game/application/input/input_binding_snapshot.gd @@ -0,0 +1,12 @@ +class_name InputBindingSnapshot +extends RefCounted + +var action_id: StringName +var label: String +var binding_text: String + + +func _init(p_action_id: StringName, p_label: String, p_binding_text: String) -> void: + action_id = p_action_id + label = p_label + binding_text = p_binding_text diff --git a/game/application/input/rebind_result.gd b/game/application/input/rebind_result.gd new file mode 100644 index 0000000..c437a4c --- /dev/null +++ b/game/application/input/rebind_result.gd @@ -0,0 +1,22 @@ +class_name RebindResult +extends RefCounted + +var accepted: bool +var action_id: StringName +var binding_text: String +var conflicting_action_id: StringName +var message: String + + +func _init( + p_accepted: bool, + p_action_id: StringName, + p_binding_text: String = "", + p_conflicting_action_id: StringName = &"", + p_message: String = "" +) -> void: + accepted = p_accepted + action_id = p_action_id + binding_text = p_binding_text + conflicting_action_id = p_conflicting_action_id + message = p_message diff --git a/game/application/interfaces/vehicle_motion_port.gd b/game/application/interfaces/vehicle_motion_port.gd new file mode 100644 index 0000000..896898e --- /dev/null +++ b/game/application/interfaces/vehicle_motion_port.gd @@ -0,0 +1,8 @@ +class_name VehicleMotionPort +extends RefCounted + +const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd") + + +func apply_command(_command: VehicleCommand) -> void: + pass diff --git a/game/application/vehicle/camera_look_command.gd b/game/application/vehicle/camera_look_command.gd new file mode 100644 index 0000000..cabb01e --- /dev/null +++ b/game/application/vehicle/camera_look_command.gd @@ -0,0 +1,22 @@ +class_name CameraLookCommand +extends RefCounted + +var yaw_delta_radians: float +var pitch_delta_radians: float +var recenter_requested: bool + + +func _init( + p_yaw_delta_radians: float = 0.0, + p_pitch_delta_radians: float = 0.0, + p_recenter_requested: bool = false +) -> void: + yaw_delta_radians = p_yaw_delta_radians + pitch_delta_radians = p_pitch_delta_radians + recenter_requested = p_recenter_requested + + +func validation_error() -> String: + if not is_finite(yaw_delta_radians) or not is_finite(pitch_delta_radians): + return "camera look deltas must be finite" + return "" diff --git a/game/application/vehicle/pilot_use_case.gd b/game/application/vehicle/pilot_use_case.gd new file mode 100644 index 0000000..af7c0cf --- /dev/null +++ b/game/application/vehicle/pilot_use_case.gd @@ -0,0 +1,32 @@ +class_name PilotUseCase +extends RefCounted + +const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd") +const VehicleCommandResult = preload( + "res://game/application/vehicle/vehicle_command_result.gd" +) +const VehicleMotionPort = preload("res://game/application/interfaces/vehicle_motion_port.gd") + +var _motion_port: VehicleMotionPort +var _last_sequence := -1 + + +func _init(motion_port: VehicleMotionPort) -> void: + _motion_port = motion_port + + +func execute(command: VehicleCommand) -> VehicleCommandResult: + if command == null: + return VehicleCommandResult.new(false, -1, "vehicle command is missing") + var error: String = command.validation_error() + if not error.is_empty(): + return VehicleCommandResult.new(false, command.sequence, error) + if command.sequence != _last_sequence + 1: + return VehicleCommandResult.new( + false, + command.sequence, + "vehicle command sequence is not consecutive" + ) + _motion_port.apply_command(command) + _last_sequence = command.sequence + return VehicleCommandResult.new(true, command.sequence) diff --git a/game/application/vehicle/vehicle_collision_sample.gd b/game/application/vehicle/vehicle_collision_sample.gd new file mode 100644 index 0000000..fb87f89 --- /dev/null +++ b/game/application/vehicle/vehicle_collision_sample.gd @@ -0,0 +1,16 @@ +class_name VehicleCollisionSample +extends RefCounted + +var sample_id: StringName +var sequence: int +var normal_impact_speed_mps: float + + +func _init( + p_sample_id: StringName, + p_sequence: int, + p_normal_impact_speed_mps: float +) -> void: + sample_id = p_sample_id + sequence = p_sequence + normal_impact_speed_mps = p_normal_impact_speed_mps diff --git a/game/application/vehicle/vehicle_command.gd b/game/application/vehicle/vehicle_command.gd new file mode 100644 index 0000000..488049a --- /dev/null +++ b/game/application/vehicle/vehicle_command.gd @@ -0,0 +1,35 @@ +class_name VehicleCommand +extends RefCounted + +var sequence: int +var longitudinal: float +var steering: float +var vertical: float +var high_thrust_active: bool + + +func _init( + p_sequence: int, + p_longitudinal: float, + p_steering: float, + p_vertical: float, + p_high_thrust_active: bool +) -> void: + sequence = p_sequence + longitudinal = p_longitudinal + steering = p_steering + vertical = p_vertical + high_thrust_active = p_high_thrust_active + + +func validation_error() -> String: + if sequence < 0: + return "vehicle command sequence is negative" + for value: float in [longitudinal, steering, vertical]: + if not is_finite(value) or value < -1.0 or value > 1.0: + return "vehicle command axes must be finite and normalized" + return "" + + +static func neutral(p_sequence: int) -> VehicleCommand: + return new(p_sequence, 0.0, 0.0, 0.0, false) diff --git a/game/application/vehicle/vehicle_command_result.gd b/game/application/vehicle/vehicle_command_result.gd new file mode 100644 index 0000000..64a40b3 --- /dev/null +++ b/game/application/vehicle/vehicle_command_result.gd @@ -0,0 +1,12 @@ +class_name VehicleCommandResult +extends RefCounted + +var accepted: bool +var sequence: int +var rejection_reason: String + + +func _init(p_accepted: bool, p_sequence: int, p_rejection_reason: String = "") -> void: + accepted = p_accepted + sequence = p_sequence + rejection_reason = p_rejection_reason diff --git a/game/composition/vertical_slice/diagnostic_telemetry_source.gd b/game/composition/vertical_slice/diagnostic_telemetry_source.gd new file mode 100644 index 0000000..d7db651 --- /dev/null +++ b/game/composition/vertical_slice/diagnostic_telemetry_source.gd @@ -0,0 +1,20 @@ +class_name DiagnosticTelemetrySource +extends RefCounted + +const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") + + +func sample(session_id: StringName, sequence: int) -> VehicleTelemetry: + return VehicleTelemetry.new( + session_id, + sequence, + Vector3(0.0, -24.0, 0.0), + Quaternion.IDENTITY, + Vector3(0.0, 0.0, -1.5), + Vector3.ZERO, + 24.0, + 0.25, + 0.0, + 0.0, + false + ) diff --git a/game/composition/vertical_slice/vehicle_test_world.tscn b/game/composition/vertical_slice/vehicle_test_world.tscn new file mode 100644 index 0000000..74dbbbb --- /dev/null +++ b/game/composition/vertical_slice/vehicle_test_world.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="PackedScene" path="res://game/world/sites/vehicle_test_environment.tscn" id="1_environment"] +[ext_resource type="PackedScene" path="res://game/world/submarine/submarine_actor.tscn" id="2_submarine"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_camera_rig.tscn" id="3_camera"] +[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_interior.tscn" id="4_cockpit"] + +[node name="VehicleTestWorld" type="Node3D"] + +[node name="Environment" parent="." instance=ExtResource("1_environment")] + +[node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")] +unique_name_in_owner = true +position = Vector3(0, -12, 12) + +[node name="CockpitInterior" parent="SubmarineActor" instance=ExtResource("4_cockpit")] +unique_name_in_owner = true + +[node name="CockpitCameraRig" parent="SubmarineActor" instance=ExtResource("3_camera")] +unique_name_in_owner = true diff --git a/game/composition/vertical_slice/vertical_slice_root.gd b/game/composition/vertical_slice/vertical_slice_root.gd new file mode 100644 index 0000000..2d40084 --- /dev/null +++ b/game/composition/vertical_slice/vertical_slice_root.gd @@ -0,0 +1,426 @@ +class_name VerticalSliceRoot +extends Node + +const DiveDifficultyProfile = preload("res://game/domain/rules/dive_difficulty_profile.gd") +const DiveUpgradeEffects = preload("res://game/domain/rules/dive_upgrade_effects.gd") +const DiveAccessibilityAdjustments = preload( + "res://game/domain/rules/dive_accessibility_adjustments.gd" +) +const DiveRulesResolver = preload("res://game/domain/rules/dive_rules_resolver.gd") +const DiveSession = preload("res://game/domain/dive/dive_session.gd") +const DiveTickRunner = preload("res://game/application/dive/dive_tick_runner.gd") +const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") +const CollisionObservation = preload("res://game/domain/dive/collision_observation.gd") +const EjectionIntent = preload("res://game/domain/dive/ejection_intent.gd") +const ReturnRouteSample = preload("res://game/domain/dive/return_route_sample.gd") +const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd") +const VehicleCollisionSample = preload( + "res://game/application/vehicle/vehicle_collision_sample.gd" +) +const PilotUseCase = preload("res://game/application/vehicle/pilot_use_case.gd") +const SubmarineActor = preload("res://game/world/submarine/submarine_actor.gd") +const SubmarineMotionAdapter = preload( + "res://game/world/submarine/submarine_motion_adapter.gd" +) +const SubmarineTelemetryAdapter = preload( + "res://game/world/submarine/submarine_telemetry_adapter.gd" +) +const ProductionInputAdapter = preload( + "res://game/infrastructure/input/production_input_adapter.gd" +) +const CockpitCameraController = preload( + "res://game/presentation/cockpit/cockpit_camera_controller.gd" +) +const CockpitInstrumentPanel = preload( + "res://game/presentation/cockpit/cockpit_instrument_panel.gd" +) +const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd") +const MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd") +const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd") +const ControlsSettingsScreen = preload( + "res://game/presentation/settings/controls_settings_screen.gd" +) +const ConfirmationDialogScreen = preload( + "res://game/presentation/confirmation/confirmation_dialog.gd" +) +const RebindResult = preload("res://game/application/input/rebind_result.gd") + +const MAIN_MENU_SCENE = preload("res://game/presentation/main_menu/main_menu_screen.tscn") +const VEHICLE_TEST_WORLD_SCENE_PATH := ( + "res://game/composition/vertical_slice/vehicle_test_world.tscn" +) +const COCKPIT_HUD_SCENE = preload("res://game/presentation/cockpit/cockpit_hud.tscn") +const PAUSE_MENU_SCENE = preload("res://game/presentation/pause/pause_menu.tscn") +const CONTROLS_SETTINGS_SCENE = preload( + "res://game/presentation/settings/controls_settings_screen.tscn" +) +const CONFIRMATION_SCENE = preload( + "res://game/presentation/confirmation/confirmation_dialog.tscn" +) + +const DIVE_SESSION_ID := &"vertical_slice_vehicle_test" +const CONFIRM_ABANDON := &"abandon_dive" +const CONFIRM_MAIN_MENU := &"return_main_menu" +const CONFIRM_QUIT := &"quit_game" + +@onready var _world_content: Node3D = %WorldContent +@onready var _content: Control = %Content +@onready var _overlay_content: Control = %OverlayContent +@onready var _input_adapter: ProductionInputAdapter = %ProductionInputAdapter + +var _runner: DiveTickRunner +var _pilot_use_case: PilotUseCase +var _telemetry_adapter: SubmarineTelemetryAdapter +var _actor: SubmarineActor +var _camera_controller: CockpitCameraController +var _instrument_panel: CockpitInstrumentPanel +var _hud: CockpitHud +var _latest_update: DiveUpdate +var _active_world: Node3D +var _active_screen: Control +var _active_overlay: Control +var _vehicle_command_sequence := 0 +var _pending_collision_samples: Array[VehicleCollisionSample] = [] +var _dive_start_position := Vector3.ZERO +var _dive_start_depth_m := 0.0 +var _confirmed_ejection_pending := false +var _dive_terminal := false + + +func _ready() -> void: + _input_adapter.pause_requested.connect(_on_pause_requested) + _input_adapter.rebind_result_available.connect(_on_rebind_result_available) + _show_main_menu() + + +func _start_vehicle_test() -> void: + var rules = DiveRulesResolver.new().resolve( + DiveDifficultyProfile.standard(), + DiveUpgradeEffects.new(), + DiveAccessibilityAdjustments.new() + ) + if rules == null: + return + var session := DiveSession.new(DIVE_SESSION_ID, rules) + if not session.is_ready(): + return + + var world_scene := load(VEHICLE_TEST_WORLD_SCENE_PATH) as PackedScene + if world_scene == null: + return + var world := world_scene.instantiate() as Node3D + _replace_active_world(world) + _actor = world.get_node("%SubmarineActor") as SubmarineActor + _camera_controller = world.get_node("%CockpitCameraRig") as CockpitCameraController + _instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel + _hud = COCKPIT_HUD_SCENE.instantiate() as CockpitHud + _replace_active_screen(_hud) + + var motion_adapter := SubmarineMotionAdapter.new(_actor) + _pilot_use_case = PilotUseCase.new(motion_adapter) + _telemetry_adapter = SubmarineTelemetryAdapter.new(_actor) + _runner = DiveTickRunner.new(session) + _latest_update = _runner.initial_update() + _vehicle_command_sequence = 0 + _pending_collision_samples.clear() + _confirmed_ejection_pending = false + _dive_terminal = false + _dive_start_position = _actor.global_position + _dive_start_depth_m = _actor.current_depth_m() + _actor.collision_sampled.connect(_on_collision_sampled) + + var initial_telemetry = _telemetry_adapter.sample( + DIVE_SESSION_ID, + _runner.next_sequence() + ) + _hud.present(_latest_update, initial_telemetry) + _instrument_panel.present(_latest_update, initial_telemetry) + _camera_controller.present_telemetry(initial_telemetry) + _input_adapter.set_gameplay_enabled(true) + set_physics_process(true) + + +func _physics_process(delta: float) -> void: + if _runner == null or _actor == null or _dive_terminal: + return + + var command: VehicleCommand = _input_adapter.sample_vehicle_command( + _vehicle_command_sequence + ) + if _latest_update.snapshot.battery.remaining_kwh <= 0.0: + command = VehicleCommand.neutral(_vehicle_command_sequence) + var pilot_result = _pilot_use_case.execute(command) + if pilot_result.accepted: + _vehicle_command_sequence += 1 + + _camera_controller.apply_look(_input_adapter.take_camera_look_command()) + var telemetry = _telemetry_adapter.sample(DIVE_SESSION_ID, _runner.next_sequence()) + _camera_controller.present_telemetry(telemetry) + var collisions := _consume_collision_observations(_runner.next_sequence()) + var ejection := EjectionIntent.new( + _input_adapter.ejection_active(), + _confirmed_ejection_pending + ) + _confirmed_ejection_pending = false + var return_route := ReturnRouteSample.new( + true, + _actor.global_position.distance_to(_dive_start_position), + maxf(_actor.current_depth_m() - _dive_start_depth_m, 0.0) + ) + _latest_update = _runner.advance( + telemetry, + delta, + collisions, + [], + ejection, + return_route + ) + _hud.present(_latest_update, telemetry) + _instrument_panel.present(_latest_update, telemetry) + if _latest_update.snapshot.is_terminal(): + _finish_dive_motion() + + +func latest_update() -> DiveUpdate: + return _latest_update + + +func active_screen() -> Control: + return _active_screen + + +func active_world() -> Node3D: + return _active_world + + +func submarine_actor() -> SubmarineActor: + return _actor + + +func camera_controller() -> CockpitCameraController: + return _camera_controller + + +func cockpit_instrument_panel() -> CockpitInstrumentPanel: + return _instrument_panel + + +func input_adapter() -> ProductionInputAdapter: + return _input_adapter + + +func active_overlay() -> Control: + return _active_overlay + + +func _finish_dive_motion() -> void: + _dive_terminal = true + _input_adapter.set_gameplay_enabled(false) + var neutral: VehicleCommand = VehicleCommand.neutral(_vehicle_command_sequence) + if _pilot_use_case.execute(neutral).accepted: + _vehicle_command_sequence += 1 + + +func _consume_collision_observations( + tick_sequence: int +) -> Array[CollisionObservation]: + var observations: Array[CollisionObservation] = [] + for sample: VehicleCollisionSample in _pending_collision_samples: + observations.append( + CollisionObservation.new( + sample.sample_id, + sample.sequence, + tick_sequence, + sample.normal_impact_speed_mps + ) + ) + _pending_collision_samples.clear() + return observations + + +func _on_collision_sampled(sample: VehicleCollisionSample) -> void: + _pending_collision_samples.append(sample) + + +func _on_pause_requested() -> void: + if _runner == null: + return + if not get_tree().paused: + _open_pause_menu() + elif _active_overlay is ConfirmationDialogScreen: + _show_pause_menu_overlay() + elif _active_overlay is ControlsSettingsScreen: + _show_pause_menu_overlay() + else: + _resume_dive() + + +func _open_pause_menu() -> void: + get_tree().paused = true + _input_adapter.set_gameplay_enabled(false) + _show_pause_menu_overlay() + + +func _show_pause_menu_overlay() -> void: + var menu := PAUSE_MENU_SCENE.instantiate() as PauseMenu + _replace_overlay(menu) + menu.resume_requested.connect(_resume_dive) + menu.controls_requested.connect(_show_controls_overlay) + menu.abandon_requested.connect(_confirm_abandon) + menu.main_menu_requested.connect(_confirm_main_menu) + menu.quit_requested.connect(_confirm_quit) + + +func _resume_dive() -> void: + _replace_overlay(null) + get_tree().paused = false + _input_adapter.set_gameplay_enabled(not _dive_terminal) + + +func _show_controls_overlay() -> void: + var screen := CONTROLS_SETTINGS_SCENE.instantiate() as ControlsSettingsScreen + _replace_overlay(screen) + screen.present_bindings(_input_adapter.binding_snapshots()) + screen.rebind_requested.connect(_on_rebind_requested) + screen.restore_defaults_requested.connect(_on_restore_defaults_requested) + screen.conflict_resolution_requested.connect(_on_conflict_resolution_requested) + screen.back_requested.connect(_show_pause_menu_overlay) + + +func _on_rebind_requested(action_id: StringName) -> void: + var result: RebindResult = _input_adapter.begin_rebind(action_id) + var screen := _active_overlay as ControlsSettingsScreen + if screen != null: + screen.present_rebind_result(result) + screen.show_waiting_for_key() + + +func _on_rebind_result_available(result: RebindResult) -> void: + var screen := _active_overlay as ControlsSettingsScreen + if screen == null: + return + screen.present_rebind_result(result) + if result.accepted: + screen.present_bindings(_input_adapter.binding_snapshots()) + + +func _on_conflict_resolution_requested(swap_bindings: bool) -> void: + var result: RebindResult = _input_adapter.resolve_pending_conflict(swap_bindings) + var screen := _active_overlay as ControlsSettingsScreen + if screen != null: + screen.present_rebind_result(result) + screen.present_bindings(_input_adapter.binding_snapshots()) + + +func _on_restore_defaults_requested() -> void: + _input_adapter.restore_default_bindings() + var screen := _active_overlay as ControlsSettingsScreen + if screen != null: + screen.present_bindings(_input_adapter.binding_snapshots()) + + +func _confirm_abandon() -> void: + _show_confirmation( + CONFIRM_ABANDON, + "Abandonner la plongée ?", + "Vous serez éjecté puis évacué. Le sous-marin et sa cargaison non sécurisée resteront au fond. Une franchise sera appliquée.", + "S'ÉJECTER ET ABANDONNER" + ) + + +func _confirm_main_menu() -> void: + _show_confirmation( + CONFIRM_MAIN_MENU, + "Retourner au menu principal ?", + "La plongée actuelle sera interrompue et aucune progression sous-marine ne sera sauvegardée.", + "RETOURNER AU MENU" + ) + + +func _confirm_quit() -> void: + _show_confirmation( + CONFIRM_QUIT, + "Quitter le jeu ?", + "La plongée actuelle sera interrompue.", + "QUITTER LE JEU" + ) + + +func _show_confirmation( + action_id: StringName, + title: String, + message: String, + confirm_label: String +) -> void: + var dialog := CONFIRMATION_SCENE.instantiate() as ConfirmationDialogScreen + _replace_overlay(dialog) + dialog.configure(action_id, title, message, confirm_label) + dialog.cancelled.connect(_show_pause_menu_overlay) + dialog.confirmed.connect(_on_confirmation_accepted) + + +func _on_confirmation_accepted(action_id: StringName) -> void: + match action_id: + CONFIRM_ABANDON: + _confirmed_ejection_pending = true + _replace_overlay(null) + get_tree().paused = false + _input_adapter.set_gameplay_enabled(true) + CONFIRM_MAIN_MENU: + _show_main_menu() + CONFIRM_QUIT: + _quit_game() + + +func _show_main_menu() -> void: + get_tree().paused = false + _input_adapter.set_gameplay_enabled(false) + _runner = null + _pilot_use_case = null + _telemetry_adapter = null + _actor = null + _camera_controller = null + _instrument_panel = null + _hud = null + _latest_update = null + _pending_collision_samples.clear() + _dive_terminal = false + set_physics_process(false) + _replace_overlay(null) + _replace_active_world(null) + var screen := MAIN_MENU_SCENE.instantiate() as MainMenuScreen + _replace_active_screen(screen) + screen.new_game_requested.connect(_start_vehicle_test) + screen.quit_requested.connect(_quit_game) + + +func _replace_active_world(next_world: Node3D) -> void: + if _active_world != null: + _world_content.remove_child(_active_world) + _active_world.queue_free() + _active_world = next_world + if _active_world != null: + _world_content.add_child(_active_world) + + +func _replace_active_screen(next_screen: Control) -> void: + if _active_screen != null: + _content.remove_child(_active_screen) + _active_screen.queue_free() + _active_screen = next_screen + if _active_screen != null: + _content.add_child(_active_screen) + + +func _replace_overlay(next_overlay: Control) -> void: + if _active_overlay != null: + _overlay_content.remove_child(_active_overlay) + _active_overlay.queue_free() + _active_overlay = next_overlay + if _active_overlay != null: + _overlay_content.add_child(_active_overlay) + + +func _quit_game() -> void: + get_tree().paused = false + get_tree().quit() diff --git a/game/composition/vertical_slice/vertical_slice_root.tscn b/game/composition/vertical_slice/vertical_slice_root.tscn new file mode 100644 index 0000000..433c1a8 --- /dev/null +++ b/game/composition/vertical_slice/vertical_slice_root.tscn @@ -0,0 +1,35 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://game/composition/vertical_slice/vertical_slice_root.gd" id="1_root"] +[ext_resource type="Script" path="res://game/infrastructure/input/production_input_adapter.gd" id="2_input"] + +[node name="VerticalSliceRoot" type="Node"] +script = ExtResource("1_root") + +[node name="WorldContent" type="Node3D" parent="."] +unique_name_in_owner = true + +[node name="Content" type="Control" parent="."] +unique_name_in_owner = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="OverlayContent" type="Control" parent="."] +unique_name_in_owner = true +process_mode = 3 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="ProductionInputAdapter" type="Node" parent="."] +unique_name_in_owner = true +process_mode = 3 +script = ExtResource("2_input") diff --git a/game/domain/dive/battery_snapshot.gd b/game/domain/dive/battery_snapshot.gd new file mode 100644 index 0000000..b79411f --- /dev/null +++ b/game/domain/dive/battery_snapshot.gd @@ -0,0 +1,22 @@ +class_name BatterySnapshot +extends RefCounted + +var remaining_kwh: float +var maximum_kwh: float +var ratio: float +var current_power_kw: float +var endurance_minutes: float + + +func _init( + p_remaining_kwh: float, + p_maximum_kwh: float, + p_current_power_kw: float +) -> void: + remaining_kwh = p_remaining_kwh + maximum_kwh = p_maximum_kwh + ratio = remaining_kwh / maximum_kwh if maximum_kwh > 0.0 else 0.0 + current_power_kw = p_current_power_kw + endurance_minutes = ( + remaining_kwh / current_power_kw * 60.0 if current_power_kw > 0.0 else INF + ) diff --git a/game/domain/dive/collision_observation.gd b/game/domain/dive/collision_observation.gd new file mode 100644 index 0000000..7f74f79 --- /dev/null +++ b/game/domain/dive/collision_observation.gd @@ -0,0 +1,31 @@ +class_name CollisionObservation +extends RefCounted + +var observation_id: StringName +var sequence: int +var tick_sequence: int +var normal_impact_speed_mps: float + + +func _init( + p_observation_id: StringName, + p_sequence: int, + p_tick_sequence: int, + p_normal_impact_speed_mps: float +) -> void: + observation_id = p_observation_id + sequence = p_sequence + tick_sequence = p_tick_sequence + normal_impact_speed_mps = p_normal_impact_speed_mps + + +func validation_error(expected_tick_sequence: int) -> String: + if observation_id.is_empty(): + return "collision observation_id is empty" + if sequence < 0: + return "collision sequence is negative" + if tick_sequence != expected_tick_sequence: + return "collision tick sequence does not match DiveTick" + if not is_finite(normal_impact_speed_mps) or normal_impact_speed_mps < 0.0: + return "collision speed must be finite and non-negative" + return "" diff --git a/game/domain/dive/depth_snapshot.gd b/game/domain/dive/depth_snapshot.gd new file mode 100644 index 0000000..eb9cfd2 --- /dev/null +++ b/game/domain/dive/depth_snapshot.gd @@ -0,0 +1,19 @@ +class_name DepthSnapshot +extends RefCounted + +var current_m: float +var certified_m: float +var critical_m: float +var crush_m: float + + +func _init( + p_current_m: float, + p_certified_m: float, + p_critical_m: float, + p_crush_m: float +) -> void: + current_m = p_current_m + certified_m = p_certified_m + critical_m = p_critical_m + crush_m = p_crush_m diff --git a/game/domain/dive/dive_alert_level.gd b/game/domain/dive/dive_alert_level.gd new file mode 100644 index 0000000..a91ac05 --- /dev/null +++ b/game/domain/dive/dive_alert_level.gd @@ -0,0 +1,18 @@ +class_name DiveAlertLevel +extends RefCounted + +enum Value { + NORMAL, + ATTENTION, + RETURN, + CRITICAL, + FAILURE, +} + + +static func highest(levels: Array[Value]) -> Value: + var result := Value.NORMAL + for level: Value in levels: + if level > result: + result = level + return result diff --git a/game/domain/dive/dive_alert_state.gd b/game/domain/dive/dive_alert_state.gd new file mode 100644 index 0000000..67a8133 --- /dev/null +++ b/game/domain/dive/dive_alert_state.gd @@ -0,0 +1,23 @@ +class_name DiveAlertState +extends RefCounted + +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") + +var oxygen: DiveAlertLevel.Value +var battery: DiveAlertLevel.Value +var hull: DiveAlertLevel.Value +var depth: DiveAlertLevel.Value +var maximum: DiveAlertLevel.Value + + +func _init( + p_oxygen: DiveAlertLevel.Value, + p_battery: DiveAlertLevel.Value, + p_hull: DiveAlertLevel.Value, + p_depth: DiveAlertLevel.Value +) -> void: + oxygen = p_oxygen + battery = p_battery + hull = p_hull + depth = p_depth + maximum = DiveAlertLevel.highest([oxygen, battery, hull, depth]) diff --git a/game/domain/dive/dive_event.gd b/game/domain/dive/dive_event.gd new file mode 100644 index 0000000..606b520 --- /dev/null +++ b/game/domain/dive/dive_event.gd @@ -0,0 +1,101 @@ +class_name DiveEvent +extends RefCounted + +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const DiveTerminalReason = preload("res://game/domain/dive/dive_terminal_reason.gd") + +enum Kind { + ALERT_LEVEL_CHANGED, + RESOURCE_DEPLETED, + HULL_DAMAGED, + DIVE_TERMINATED, +} + +var event_id: StringName +var kind: Kind +var tick_sequence: int +var subject: StringName +var amount: float +var previous_level: DiveAlertLevel.Value +var current_level: DiveAlertLevel.Value +var terminal_reason: DiveTerminalReason.Value + + +func _init( + p_event_id: StringName, + p_kind: Kind, + p_tick_sequence: int, + p_subject: StringName = &"", + p_amount: float = 0.0, + p_previous_level: DiveAlertLevel.Value = DiveAlertLevel.Value.NORMAL, + p_current_level: DiveAlertLevel.Value = DiveAlertLevel.Value.NORMAL, + p_terminal_reason: DiveTerminalReason.Value = DiveTerminalReason.Value.NONE +) -> void: + event_id = p_event_id + kind = p_kind + tick_sequence = p_tick_sequence + subject = p_subject + amount = p_amount + previous_level = p_previous_level + current_level = p_current_level + terminal_reason = p_terminal_reason + + +static func alert_changed( + tick_sequence: int, + subject: StringName, + previous_level: DiveAlertLevel.Value, + current_level: DiveAlertLevel.Value +): + return new( + _event_id(tick_sequence, &"alert", subject), + Kind.ALERT_LEVEL_CHANGED, + tick_sequence, + subject, + 0.0, + previous_level, + current_level + ) + + +static func resource_depleted(tick_sequence: int, subject: StringName): + return new( + _event_id(tick_sequence, &"depleted", subject), + Kind.RESOURCE_DEPLETED, + tick_sequence, + subject + ) + + +static func hull_damaged(tick_sequence: int, amount: float): + return new( + _event_id(tick_sequence, &"damage", &"hull"), + Kind.HULL_DAMAGED, + tick_sequence, + &"hull", + amount + ) + + +static func dive_terminated( + tick_sequence: int, + reason: DiveTerminalReason.Value +): + return new( + _event_id(tick_sequence, &"terminal", &"dive"), + Kind.DIVE_TERMINATED, + tick_sequence, + &"dive", + 0.0, + DiveAlertLevel.Value.NORMAL, + DiveAlertLevel.Value.NORMAL, + reason + ) + + +static func _event_id( + tick_sequence: int, + event_name: StringName, + subject_name: StringName +) -> StringName: + return StringName("%d:%s:%s" % [tick_sequence, event_name, subject_name]) diff --git a/game/domain/dive/dive_session.gd b/game/domain/dive/dive_session.gd new file mode 100644 index 0000000..434f526 --- /dev/null +++ b/game/domain/dive/dive_session.gd @@ -0,0 +1,370 @@ +class_name DiveSession +extends RefCounted + +const ResolvedDiveRules = preload("res://game/domain/rules/resolved_dive_rules.gd") +const DiveStep = preload("res://game/domain/dive/dive_step.gd") +const CollisionObservation = preload("res://game/domain/dive/collision_observation.gd") +const BatterySnapshot = preload("res://game/domain/dive/battery_snapshot.gd") +const OxygenSnapshot = preload("res://game/domain/dive/oxygen_snapshot.gd") +const HullSnapshot = preload("res://game/domain/dive/hull_snapshot.gd") +const DepthSnapshot = preload("res://game/domain/dive/depth_snapshot.gd") +const ReturnEstimate = preload("res://game/domain/dive/return_estimate.gd") +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const DiveAlertState = preload("res://game/domain/dive/dive_alert_state.gd") +const EjectionSnapshot = preload("res://game/domain/dive/ejection_snapshot.gd") +const DiveTerminalReason = preload("res://game/domain/dive/dive_terminal_reason.gd") +const DiveSnapshot = preload("res://game/domain/dive/dive_snapshot.gd") +const DiveEvent = preload("res://game/domain/dive/dive_event.gd") +const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") + +var session_id: StringName +var rules: ResolvedDiveRules +var initialization_error: String = "" + +var _battery_kwh: float +var _oxygen_l: float +var _hull_integrity: float +var _depth_m := 0.0 +var _current_power_kw := 0.0 +var _ejection_held_seconds := 0.0 +var _crush_exposure_seconds := 0.0 +var _collision_cooldown_seconds := 0.0 +var _last_tick_sequence := -1 +var _terminal_reason := DiveTerminalReason.Value.NONE +var _return_estimate: ReturnEstimate = ReturnEstimate.unknown() +var _processed_collision_ids: Dictionary[StringName, bool] = {} +var _alerts: DiveAlertState +var _snapshot: DiveSnapshot + + +func _init( + p_session_id: StringName, + p_rules: ResolvedDiveRules, + initial_battery_kwh: float = -1.0, + initial_oxygen_l: float = -1.0, + initial_hull_integrity: float = -1.0 +) -> void: + session_id = p_session_id + rules = p_rules + initialization_error = _validate_initialization( + initial_battery_kwh, + initial_oxygen_l, + initial_hull_integrity + ) + if not initialization_error.is_empty(): + return + _battery_kwh = ( + rules.battery_capacity_kwh if initial_battery_kwh < 0.0 else initial_battery_kwh + ) + _oxygen_l = rules.oxygen_capacity_l if initial_oxygen_l < 0.0 else initial_oxygen_l + _hull_integrity = ( + rules.hull_max_integrity if initial_hull_integrity < 0.0 else initial_hull_integrity + ) + _alerts = _build_alert_state() + _snapshot = _build_snapshot() + + +func is_ready() -> bool: + return initialization_error.is_empty() + + +func current_snapshot() -> DiveSnapshot: + return _snapshot + + +func advance(step: DiveStep) -> DiveUpdate: + var requested_sequence := step.tick.sequence if step != null and step.tick != null else -1 + if not is_ready(): + return DiveUpdate.rejected(requested_sequence, initialization_error, _snapshot) + if _terminal_reason != DiveTerminalReason.Value.NONE: + return DiveUpdate.rejected(requested_sequence, "dive session is terminal", _snapshot) + if step == null: + return DiveUpdate.rejected(requested_sequence, "DiveStep is null", _snapshot) + var validation_error: String = step.validation_error() + if not validation_error.is_empty(): + return DiveUpdate.rejected(requested_sequence, validation_error, _snapshot) + if step.tick.session_id != session_id: + return DiveUpdate.rejected(requested_sequence, "tick session_id does not match", _snapshot) + if step.tick.sequence != _last_tick_sequence + 1: + return DiveUpdate.rejected(requested_sequence, "tick sequence is not consecutive", _snapshot) + + var previous_battery := _battery_kwh + var previous_oxygen := _oxygen_l + var previous_hull := _hull_integrity + var previous_alerts := _alerts + var delta_seconds := step.tick.elapsed_seconds + var events: Array[DiveEvent] = [] + + _last_tick_sequence = step.tick.sequence + _depth_m = step.telemetry.depth_m + _current_power_kw = _calculate_power_kw(step) + _battery_kwh = maxf( + 0.0, + _battery_kwh - _current_power_kw * delta_seconds / 3600.0 + ) + _oxygen_l = maxf( + 0.0, + _oxygen_l - rules.oxygen_consumption_l_per_min * delta_seconds / 60.0 + ) + + var hull_damage := _apply_pressure_damage(delta_seconds) + hull_damage += _apply_collision_damage(step, delta_seconds) + if hull_damage > 0.0: + events.append(DiveEvent.hull_damaged(_last_tick_sequence, hull_damage)) + + if step.ejection_intent.confirmed: + _ejection_held_seconds = rules.ejection_hold_seconds + elif step.ejection_intent.active: + _ejection_held_seconds = minf( + rules.ejection_hold_seconds, + _ejection_held_seconds + delta_seconds + ) + else: + _ejection_held_seconds = 0.0 + + _return_estimate = _calculate_return_estimate(step) + _alerts = _build_alert_state() + _append_depletion_events(events, previous_battery, previous_oxygen, previous_hull) + _append_alert_events(events, previous_alerts) + _update_terminal_reason() + if _terminal_reason != DiveTerminalReason.Value.NONE: + events.append(DiveEvent.dive_terminated(_last_tick_sequence, _terminal_reason)) + + _snapshot = _build_snapshot() + return DiveUpdate.new(true, _last_tick_sequence, "", _snapshot, events) + + +func _validate_initialization( + initial_battery_kwh: float, + initial_oxygen_l: float, + initial_hull_integrity: float +) -> String: + if session_id.is_empty(): + return "session_id is empty" + if rules == null: + return "resolved dive rules are missing" + var rules_error: String = rules.validation_error() + if not rules_error.is_empty(): + return rules_error + if not _valid_initial_value(initial_battery_kwh, rules.battery_capacity_kwh): + return "initial battery is outside resolved capacity" + if not _valid_initial_value(initial_oxygen_l, rules.oxygen_capacity_l): + return "initial oxygen is outside resolved capacity" + if not _valid_initial_value(initial_hull_integrity, rules.hull_max_integrity): + return "initial hull is outside resolved capacity" + return "" + + +func _valid_initial_value(value: float, maximum: float) -> bool: + return is_finite(value) and (is_equal_approx(value, -1.0) or (value >= 0.0 and value <= maximum)) + + +func _calculate_power_kw(step: DiveStep) -> float: + var power := ( + absf(step.telemetry.longitudinal_effort) * rules.longitudinal_power_kw + + absf(step.telemetry.steering_effort) * rules.steering_power_kw + + absf(step.telemetry.vertical_effort) * rules.vertical_power_kw + ) + if step.telemetry.high_thrust_active: + power += rules.high_thrust_power_kw + for demand in step.tool_power_demands: + power += demand.effective_power_kw + return power + + +func _apply_pressure_damage(delta_seconds: float) -> float: + var damage_rate := 0.0 + if _depth_m <= rules.certified_depth_m: + _crush_exposure_seconds = 0.0 + elif _depth_m <= rules.critical_depth_m: + _crush_exposure_seconds = 0.0 + var range_size := rules.critical_depth_m - rules.certified_depth_m + var ratio := (_depth_m - rules.certified_depth_m) / range_size + damage_rate = lerpf(0.15, 5.0, ratio * ratio) + elif _depth_m <= rules.crush_depth_m: + _crush_exposure_seconds = 0.0 + var range_size := rules.crush_depth_m - rules.critical_depth_m + var ratio := (_depth_m - rules.critical_depth_m) / range_size + damage_rate = lerpf(5.0, 18.0, ratio) + else: + _crush_exposure_seconds += delta_seconds + if _crush_exposure_seconds >= rules.crush_grace_seconds: + damage_rate = 100.0 + + var damage := damage_rate * rules.pressure_damage_multiplier * delta_seconds + return _damage_hull(damage) + + +func _apply_collision_damage(step: DiveStep, delta_seconds: float) -> float: + _collision_cooldown_seconds = maxf(0.0, _collision_cooldown_seconds - delta_seconds) + var ordered_collisions: Array[CollisionObservation] = step.collisions.duplicate() + ordered_collisions.sort_custom(_collision_comes_first) + var total_damage := 0.0 + for collision: CollisionObservation in ordered_collisions: + if _processed_collision_ids.has(collision.observation_id): + continue + _processed_collision_ids[collision.observation_id] = true + if collision.normal_impact_speed_mps <= rules.collision_speed_threshold_mps: + continue + if _collision_cooldown_seconds > 0.0: + continue + var excess_speed := ( + collision.normal_impact_speed_mps - rules.collision_speed_threshold_mps + ) + var requested_damage := ( + excess_speed + * rules.collision_damage_per_excess_mps + * rules.collision_damage_multiplier + ) + total_damage += _damage_hull(requested_damage) + _collision_cooldown_seconds = rules.collision_repeat_delay_seconds + return total_damage + + +func _collision_comes_first(left: CollisionObservation, right: CollisionObservation) -> bool: + return left.sequence < right.sequence + + +func _damage_hull(requested_damage: float) -> float: + if requested_damage <= 0.0: + return 0.0 + var applied_damage := minf(requested_damage, _hull_integrity) + _hull_integrity -= applied_damage + return applied_damage + + +func _calculate_return_estimate(step: DiveStep) -> ReturnEstimate: + if not step.return_route.is_known: + return ReturnEstimate.unknown() + var required := ( + step.return_route.distance_m * rules.return_energy_kwh_per_m + + step.return_route.ascent_m * rules.ascent_energy_kwh_per_m + ) + var margin := _battery_kwh - required + var margin_ratio := margin / rules.battery_capacity_kwh + var status := ReturnEstimate.Status.SAFE + if margin < 0.0: + status = ReturnEstimate.Status.INSUFFICIENT + elif margin_ratio <= rules.return_ratio: + status = ReturnEstimate.Status.TIGHT + return ReturnEstimate.new(required, margin, margin_ratio, status) + + +func _append_depletion_events( + events: Array[DiveEvent], + previous_battery: float, + previous_oxygen: float, + previous_hull: float +) -> void: + if previous_battery > 0.0 and _battery_kwh <= 0.0: + events.append(DiveEvent.resource_depleted(_last_tick_sequence, &"battery")) + if previous_oxygen > 0.0 and _oxygen_l <= 0.0: + events.append(DiveEvent.resource_depleted(_last_tick_sequence, &"oxygen")) + if previous_hull > 0.0 and _hull_integrity <= 0.0: + events.append(DiveEvent.resource_depleted(_last_tick_sequence, &"hull")) + + +func _append_alert_events( + events: Array[DiveEvent], + previous_alerts: DiveAlertState +) -> void: + _append_alert_event(events, &"oxygen", previous_alerts.oxygen, _alerts.oxygen) + _append_alert_event(events, &"battery", previous_alerts.battery, _alerts.battery) + _append_alert_event(events, &"hull", previous_alerts.hull, _alerts.hull) + _append_alert_event(events, &"depth", previous_alerts.depth, _alerts.depth) + + +func _append_alert_event( + events: Array[DiveEvent], + subject: StringName, + previous_level: DiveAlertLevel.Value, + current_level: DiveAlertLevel.Value +) -> void: + if previous_level == current_level: + return + events.append( + DiveEvent.alert_changed( + _last_tick_sequence, + subject, + previous_level, + current_level + ) + ) + + +func _update_terminal_reason() -> void: + if _hull_integrity <= 0.0: + _terminal_reason = DiveTerminalReason.Value.HULL_DESTROYED + elif _oxygen_l <= 0.0: + _terminal_reason = DiveTerminalReason.Value.OXYGEN_DEPLETED + elif _ejection_held_seconds >= rules.ejection_hold_seconds: + _terminal_reason = DiveTerminalReason.Value.VOLUNTARY_EJECTION + + +func _build_alert_state() -> DiveAlertState: + return DiveAlertState.new( + _resource_alert(_ratio(_oxygen_l, rules.oxygen_capacity_l)), + _resource_alert(_ratio(_battery_kwh, rules.battery_capacity_kwh)), + _resource_alert(_ratio(_hull_integrity, rules.hull_max_integrity)), + _depth_alert(_depth_m) + ) + + +func _resource_alert(ratio: float) -> DiveAlertLevel.Value: + if ratio <= 0.0: + return DiveAlertLevel.Value.FAILURE + if ratio <= rules.critical_ratio: + return DiveAlertLevel.Value.CRITICAL + if ratio <= rules.return_ratio: + return DiveAlertLevel.Value.RETURN + if ratio <= rules.attention_ratio: + return DiveAlertLevel.Value.ATTENTION + return DiveAlertLevel.Value.NORMAL + + +func _depth_alert(depth_m: float) -> DiveAlertLevel.Value: + if depth_m >= rules.crush_depth_m: + return DiveAlertLevel.Value.FAILURE + if depth_m >= rules.critical_depth_m: + return DiveAlertLevel.Value.CRITICAL + if depth_m > rules.certified_depth_m: + return DiveAlertLevel.Value.RETURN + if depth_m >= rules.certified_depth_m * 0.85: + return DiveAlertLevel.Value.ATTENTION + return DiveAlertLevel.Value.NORMAL + + +func _build_snapshot() -> DiveSnapshot: + var exterior_pressure := 1.0 + _depth_m / 10.0 + return DiveSnapshot.new( + session_id, + _last_tick_sequence, + BatterySnapshot.new(_battery_kwh, rules.battery_capacity_kwh, _current_power_kw), + OxygenSnapshot.new( + _oxygen_l, + rules.oxygen_capacity_l, + rules.oxygen_consumption_l_per_min + ), + HullSnapshot.new( + _hull_integrity, + rules.hull_max_integrity, + exterior_pressure, + rules.certified_pressure_bar, + rules.critical_pressure_bar, + rules.crush_pressure_bar + ), + DepthSnapshot.new( + _depth_m, + rules.certified_depth_m, + rules.critical_depth_m, + rules.crush_depth_m + ), + _return_estimate, + _alerts, + EjectionSnapshot.new(_ejection_held_seconds, rules.ejection_hold_seconds), + _terminal_reason + ) + + +func _ratio(current: float, maximum: float) -> float: + return current / maximum if maximum > 0.0 else 0.0 diff --git a/game/domain/dive/dive_snapshot.gd b/game/domain/dive/dive_snapshot.gd new file mode 100644 index 0000000..a16905a --- /dev/null +++ b/game/domain/dive/dive_snapshot.gd @@ -0,0 +1,50 @@ +class_name DiveSnapshot +extends RefCounted + +const BatterySnapshot = preload("res://game/domain/dive/battery_snapshot.gd") +const OxygenSnapshot = preload("res://game/domain/dive/oxygen_snapshot.gd") +const HullSnapshot = preload("res://game/domain/dive/hull_snapshot.gd") +const DepthSnapshot = preload("res://game/domain/dive/depth_snapshot.gd") +const ReturnEstimate = preload("res://game/domain/dive/return_estimate.gd") +const DiveAlertState = preload("res://game/domain/dive/dive_alert_state.gd") +const EjectionSnapshot = preload("res://game/domain/dive/ejection_snapshot.gd") +const DiveTerminalReason = preload("res://game/domain/dive/dive_terminal_reason.gd") + +var session_id: StringName +var tick_sequence: int +var battery: BatterySnapshot +var oxygen: OxygenSnapshot +var hull: HullSnapshot +var depth: DepthSnapshot +var return_estimate: ReturnEstimate +var alerts: DiveAlertState +var ejection: EjectionSnapshot +var terminal_reason: DiveTerminalReason.Value + + +func _init( + p_session_id: StringName, + p_tick_sequence: int, + p_battery: BatterySnapshot, + p_oxygen: OxygenSnapshot, + p_hull: HullSnapshot, + p_depth: DepthSnapshot, + p_return_estimate: ReturnEstimate, + p_alerts: DiveAlertState, + p_ejection: EjectionSnapshot, + p_terminal_reason: DiveTerminalReason.Value +) -> void: + session_id = p_session_id + tick_sequence = p_tick_sequence + battery = p_battery + oxygen = p_oxygen + hull = p_hull + depth = p_depth + return_estimate = p_return_estimate + alerts = p_alerts + ejection = p_ejection + terminal_reason = p_terminal_reason + + +func is_terminal() -> bool: + return terminal_reason != DiveTerminalReason.Value.NONE diff --git a/game/domain/dive/dive_step.gd b/game/domain/dive/dive_step.gd new file mode 100644 index 0000000..a1efba9 --- /dev/null +++ b/game/domain/dive/dive_step.gd @@ -0,0 +1,70 @@ +class_name DiveStep +extends RefCounted + +const DiveTick = preload("res://game/domain/dive/dive_tick.gd") +const DiveTelemetrySample = preload("res://game/domain/dive/dive_telemetry_sample.gd") +const CollisionObservation = preload("res://game/domain/dive/collision_observation.gd") +const ToolPowerDemand = preload("res://game/domain/dive/tool_power_demand.gd") +const EjectionIntent = preload("res://game/domain/dive/ejection_intent.gd") +const ReturnRouteSample = preload("res://game/domain/dive/return_route_sample.gd") + +var tick: DiveTick +var telemetry: DiveTelemetrySample +var collisions: Array[CollisionObservation] +var tool_power_demands: Array[ToolPowerDemand] +var ejection_intent: EjectionIntent +var return_route: ReturnRouteSample + + +func _init( + p_tick: DiveTick, + p_telemetry: DiveTelemetrySample, + p_collisions: Array[CollisionObservation] = [], + p_tool_power_demands: Array[ToolPowerDemand] = [], + p_ejection_intent: EjectionIntent = null, + p_return_route: ReturnRouteSample = null +) -> void: + tick = p_tick + telemetry = p_telemetry + collisions = p_collisions.duplicate() + tool_power_demands = p_tool_power_demands.duplicate() + ejection_intent = p_ejection_intent if p_ejection_intent != null else EjectionIntent.new() + return_route = p_return_route if p_return_route != null else ReturnRouteSample.new() + + +func validation_error() -> String: + if tick == null: + return "DiveStep has no tick" + var error: String = tick.validation_error() + if not error.is_empty(): + return error + if telemetry == null: + return "DiveStep has no telemetry" + error = telemetry.validation_error() + if not error.is_empty(): + return error + if ejection_intent == null: + return "DiveStep has no ejection intent" + if return_route == null: + return "DiveStep has no return route sample" + error = return_route.validation_error() + if not error.is_empty(): + return error + for collision: CollisionObservation in collisions: + if collision == null: + return "DiveStep contains a null collision" + error = collision.validation_error(tick.sequence) + if not error.is_empty(): + return error + for demand: ToolPowerDemand in tool_power_demands: + if demand == null: + return "DiveStep contains a null tool power demand" + error = demand.validation_error() + if not error.is_empty(): + return error + var tool_ids: Dictionary[StringName, bool] = {} + for demand: ToolPowerDemand in tool_power_demands: + if tool_ids.has(demand.tool_id): + return "DiveStep contains duplicate tool power demands" + tool_ids[demand.tool_id] = true + return "" diff --git a/game/domain/dive/dive_telemetry_sample.gd b/game/domain/dive/dive_telemetry_sample.gd new file mode 100644 index 0000000..690e65b --- /dev/null +++ b/game/domain/dive/dive_telemetry_sample.gd @@ -0,0 +1,38 @@ +class_name DiveTelemetrySample +extends RefCounted + +var depth_m: float +var longitudinal_effort: float +var steering_effort: float +var vertical_effort: float +var high_thrust_active: bool + + +func _init( + p_depth_m: float = 0.0, + p_longitudinal_effort: float = 0.0, + p_steering_effort: float = 0.0, + p_vertical_effort: float = 0.0, + p_high_thrust_active: bool = false +) -> void: + depth_m = p_depth_m + longitudinal_effort = p_longitudinal_effort + steering_effort = p_steering_effort + vertical_effort = p_vertical_effort + high_thrust_active = p_high_thrust_active + + +func validation_error() -> String: + if not is_finite(depth_m) or depth_m < 0.0: + return "telemetry depth must be finite and non-negative" + if not _is_normalized(longitudinal_effort): + return "longitudinal effort must be between -1 and 1" + if not _is_normalized(steering_effort): + return "steering effort must be between -1 and 1" + if not _is_normalized(vertical_effort): + return "vertical effort must be between -1 and 1" + return "" + + +func _is_normalized(value: float) -> bool: + return is_finite(value) and value >= -1.0 and value <= 1.0 diff --git a/game/domain/dive/dive_terminal_reason.gd b/game/domain/dive/dive_terminal_reason.gd new file mode 100644 index 0000000..52a1f21 --- /dev/null +++ b/game/domain/dive/dive_terminal_reason.gd @@ -0,0 +1,10 @@ +class_name DiveTerminalReason +extends RefCounted + +enum Value { + NONE, + RETURNED, + VOLUNTARY_EJECTION, + OXYGEN_DEPLETED, + HULL_DESTROYED, +} diff --git a/game/domain/dive/dive_tick.gd b/game/domain/dive/dive_tick.gd new file mode 100644 index 0000000..66a751a --- /dev/null +++ b/game/domain/dive/dive_tick.gd @@ -0,0 +1,28 @@ +class_name DiveTick +extends RefCounted + +var session_id: StringName +var sequence: int +var elapsed_seconds: float + + +func _init( + p_session_id: StringName, + p_sequence: int, + p_elapsed_seconds: float +) -> void: + session_id = p_session_id + sequence = p_sequence + elapsed_seconds = p_elapsed_seconds + + +func validation_error() -> String: + if session_id.is_empty(): + return "tick session_id is empty" + if sequence < 0: + return "tick sequence is negative" + if not is_finite(elapsed_seconds) or elapsed_seconds <= 0.0: + return "tick duration must be finite and positive" + if elapsed_seconds > 0.1: + return "tick duration exceeds 0.1 seconds" + return "" diff --git a/game/domain/dive/dive_update.gd b/game/domain/dive/dive_update.gd new file mode 100644 index 0000000..b1b38b8 --- /dev/null +++ b/game/domain/dive/dive_update.gd @@ -0,0 +1,37 @@ +class_name DiveUpdate +extends RefCounted + +const DiveSnapshot = preload("res://game/domain/dive/dive_snapshot.gd") +const DiveEvent = preload("res://game/domain/dive/dive_event.gd") + +var accepted: bool +var requested_tick_sequence: int +var rejection_reason: String +var snapshot: DiveSnapshot +var events: Array[DiveEvent] + + +func _init( + p_accepted: bool, + p_requested_tick_sequence: int, + p_rejection_reason: String, + p_snapshot: DiveSnapshot, + p_events: Array[DiveEvent] = [] +) -> void: + accepted = p_accepted + requested_tick_sequence = p_requested_tick_sequence + rejection_reason = p_rejection_reason + snapshot = p_snapshot + events = p_events.duplicate() + + +static func initial(snapshot: DiveSnapshot): + return new(true, snapshot.tick_sequence, "", snapshot) + + +static func rejected( + requested_tick_sequence: int, + reason: String, + snapshot: DiveSnapshot +): + return new(false, requested_tick_sequence, reason, snapshot) diff --git a/game/domain/dive/ejection_intent.gd b/game/domain/dive/ejection_intent.gd new file mode 100644 index 0000000..4d761e2 --- /dev/null +++ b/game/domain/dive/ejection_intent.gd @@ -0,0 +1,10 @@ +class_name EjectionIntent +extends RefCounted + +var active: bool +var confirmed: bool + + +func _init(p_active: bool = false, p_confirmed: bool = false) -> void: + active = p_active + confirmed = p_confirmed diff --git a/game/domain/dive/ejection_snapshot.gd b/game/domain/dive/ejection_snapshot.gd new file mode 100644 index 0000000..2d1a189 --- /dev/null +++ b/game/domain/dive/ejection_snapshot.gd @@ -0,0 +1,12 @@ +class_name EjectionSnapshot +extends RefCounted + +var held_seconds: float +var required_seconds: float +var ratio: float + + +func _init(p_held_seconds: float, p_required_seconds: float) -> void: + held_seconds = p_held_seconds + required_seconds = p_required_seconds + ratio = clampf(held_seconds / required_seconds, 0.0, 1.0) if required_seconds > 0.0 else 0.0 diff --git a/game/domain/dive/hull_snapshot.gd b/game/domain/dive/hull_snapshot.gd new file mode 100644 index 0000000..9d4c780 --- /dev/null +++ b/game/domain/dive/hull_snapshot.gd @@ -0,0 +1,27 @@ +class_name HullSnapshot +extends RefCounted + +var current_integrity: float +var maximum_integrity: float +var ratio: float +var exterior_pressure_bar: float +var certified_pressure_bar: float +var critical_pressure_bar: float +var crush_pressure_bar: float + + +func _init( + p_current_integrity: float, + p_maximum_integrity: float, + p_exterior_pressure_bar: float, + p_certified_pressure_bar: float, + p_critical_pressure_bar: float, + p_crush_pressure_bar: float +) -> void: + current_integrity = p_current_integrity + maximum_integrity = p_maximum_integrity + ratio = current_integrity / maximum_integrity if maximum_integrity > 0.0 else 0.0 + exterior_pressure_bar = p_exterior_pressure_bar + certified_pressure_bar = p_certified_pressure_bar + critical_pressure_bar = p_critical_pressure_bar + crush_pressure_bar = p_crush_pressure_bar diff --git a/game/domain/dive/oxygen_snapshot.gd b/game/domain/dive/oxygen_snapshot.gd new file mode 100644 index 0000000..d45f854 --- /dev/null +++ b/game/domain/dive/oxygen_snapshot.gd @@ -0,0 +1,22 @@ +class_name OxygenSnapshot +extends RefCounted + +var remaining_l: float +var maximum_l: float +var ratio: float +var consumption_l_per_min: float +var endurance_minutes: float + + +func _init( + p_remaining_l: float, + p_maximum_l: float, + p_consumption_l_per_min: float +) -> void: + remaining_l = p_remaining_l + maximum_l = p_maximum_l + ratio = remaining_l / maximum_l if maximum_l > 0.0 else 0.0 + consumption_l_per_min = p_consumption_l_per_min + endurance_minutes = ( + remaining_l / consumption_l_per_min if consumption_l_per_min > 0.0 else INF + ) diff --git a/game/domain/dive/return_estimate.gd b/game/domain/dive/return_estimate.gd new file mode 100644 index 0000000..7eafbac --- /dev/null +++ b/game/domain/dive/return_estimate.gd @@ -0,0 +1,30 @@ +class_name ReturnEstimate +extends RefCounted + +enum Status { + UNKNOWN, + SAFE, + TIGHT, + INSUFFICIENT, +} + +var required_kwh: float +var margin_kwh: float +var margin_ratio: float +var status: Status + + +func _init( + p_required_kwh: float, + p_margin_kwh: float, + p_margin_ratio: float, + p_status: Status +) -> void: + required_kwh = p_required_kwh + margin_kwh = p_margin_kwh + margin_ratio = p_margin_ratio + status = p_status + + +static func unknown(): + return new(0.0, 0.0, 0.0, Status.UNKNOWN) diff --git a/game/domain/dive/return_route_sample.gd b/game/domain/dive/return_route_sample.gd new file mode 100644 index 0000000..df0c8ea --- /dev/null +++ b/game/domain/dive/return_route_sample.gd @@ -0,0 +1,24 @@ +class_name ReturnRouteSample +extends RefCounted + +var is_known: bool +var distance_m: float +var ascent_m: float + + +func _init( + p_is_known: bool = false, + p_distance_m: float = 0.0, + p_ascent_m: float = 0.0 +) -> void: + is_known = p_is_known + distance_m = p_distance_m + ascent_m = p_ascent_m + + +func validation_error() -> String: + if not is_finite(distance_m) or distance_m < 0.0: + return "return distance must be finite and non-negative" + if not is_finite(ascent_m) or ascent_m < 0.0: + return "return ascent must be finite and non-negative" + return "" diff --git a/game/domain/dive/tool_power_demand.gd b/game/domain/dive/tool_power_demand.gd new file mode 100644 index 0000000..cef829b --- /dev/null +++ b/game/domain/dive/tool_power_demand.gd @@ -0,0 +1,18 @@ +class_name ToolPowerDemand +extends RefCounted + +var tool_id: StringName +var effective_power_kw: float + + +func _init(p_tool_id: StringName, p_effective_power_kw: float) -> void: + tool_id = p_tool_id + effective_power_kw = p_effective_power_kw + + +func validation_error() -> String: + if tool_id.is_empty(): + return "tool_id is empty" + if not is_finite(effective_power_kw) or effective_power_kw < 0.0: + return "tool power must be finite and non-negative" + return "" diff --git a/game/domain/rules/dive_accessibility_adjustments.gd b/game/domain/rules/dive_accessibility_adjustments.gd new file mode 100644 index 0000000..d94b6b6 --- /dev/null +++ b/game/domain/rules/dive_accessibility_adjustments.gd @@ -0,0 +1,27 @@ +class_name DiveAccessibilityAdjustments +extends RefCounted + +var oxygen_consumption_multiplier: float +var battery_consumption_multiplier: float +var pressure_damage_multiplier: float + + +func _init( + p_oxygen_consumption_multiplier: float = 1.0, + p_battery_consumption_multiplier: float = 1.0, + p_pressure_damage_multiplier: float = 1.0 +) -> void: + oxygen_consumption_multiplier = p_oxygen_consumption_multiplier + battery_consumption_multiplier = p_battery_consumption_multiplier + pressure_damage_multiplier = p_pressure_damage_multiplier + + +func validation_error() -> String: + for multiplier: float in [ + oxygen_consumption_multiplier, + battery_consumption_multiplier, + pressure_damage_multiplier, + ]: + if not is_finite(multiplier) or multiplier <= 0.0: + return "accessibility multipliers must be finite and positive" + return "" diff --git a/game/domain/rules/dive_difficulty_profile.gd b/game/domain/rules/dive_difficulty_profile.gd new file mode 100644 index 0000000..bbe4f37 --- /dev/null +++ b/game/domain/rules/dive_difficulty_profile.gd @@ -0,0 +1,52 @@ +class_name DiveDifficultyProfile +extends RefCounted + +var profile_id: StringName +var oxygen_consumption_multiplier: float +var battery_consumption_multiplier: float +var collision_damage_multiplier: float +var pressure_damage_multiplier: float +var crush_grace_multiplier: float + + +func _init( + p_profile_id: StringName, + p_oxygen_consumption_multiplier: float, + p_battery_consumption_multiplier: float, + p_collision_damage_multiplier: float, + p_pressure_damage_multiplier: float, + p_crush_grace_multiplier: float +) -> void: + profile_id = p_profile_id + oxygen_consumption_multiplier = p_oxygen_consumption_multiplier + battery_consumption_multiplier = p_battery_consumption_multiplier + collision_damage_multiplier = p_collision_damage_multiplier + pressure_damage_multiplier = p_pressure_damage_multiplier + crush_grace_multiplier = p_crush_grace_multiplier + + +static func exploration(): + return new(&"exploration", 0.75, 0.80, 0.65, 0.50, 1.50) + + +static func standard(): + return new(&"standard", 1.0, 1.0, 1.0, 1.0, 1.0) + + +static func expert(): + return new(&"expert", 1.20, 1.15, 1.25, 1.35, 0.75) + + +func validation_error() -> String: + if profile_id.is_empty(): + return "difficulty profile_id is empty" + for value: float in [ + oxygen_consumption_multiplier, + battery_consumption_multiplier, + collision_damage_multiplier, + pressure_damage_multiplier, + crush_grace_multiplier, + ]: + if not is_finite(value) or value <= 0.0: + return "difficulty multipliers must be finite and positive" + return "" diff --git a/game/domain/rules/dive_rules_resolver.gd b/game/domain/rules/dive_rules_resolver.gd new file mode 100644 index 0000000..dc35df7 --- /dev/null +++ b/game/domain/rules/dive_rules_resolver.gd @@ -0,0 +1,99 @@ +class_name DiveRulesResolver +extends RefCounted + +const DiveDifficultyProfile = preload("res://game/domain/rules/dive_difficulty_profile.gd") +const DiveUpgradeEffects = preload("res://game/domain/rules/dive_upgrade_effects.gd") +const DiveAccessibilityAdjustments = preload( + "res://game/domain/rules/dive_accessibility_adjustments.gd" +) +const ResolvedDiveRules = preload("res://game/domain/rules/resolved_dive_rules.gd") + +const BASE_BATTERY_CAPACITY_KWH := 100.0 +const BASE_OXYGEN_CAPACITY_L := 480.0 +const BASE_OXYGEN_CONSUMPTION_L_PER_MIN := 30.0 +const BASE_HULL_INTEGRITY := 100.0 +const BASE_CERTIFIED_DEPTH_M := 60.0 + +const BASE_LONGITUDINAL_POWER_KW := 1800.0 +const BASE_STEERING_POWER_KW := 180.0 +const BASE_VERTICAL_POWER_KW := 360.0 +const BASE_HIGH_THRUST_POWER_KW := 900.0 +const BASE_RETURN_ENERGY_KWH_PER_M := 100.0 / 1200.0 +const BASE_ASCENT_ENERGY_KWH_PER_M := 0.035 + + +func resolve( + difficulty: DiveDifficultyProfile, + upgrades: DiveUpgradeEffects = null, + accessibility: DiveAccessibilityAdjustments = null +) -> ResolvedDiveRules: + var resolved_upgrades: DiveUpgradeEffects = ( + upgrades if upgrades != null else DiveUpgradeEffects.new() + ) + var resolved_accessibility: DiveAccessibilityAdjustments = ( + accessibility if accessibility != null else DiveAccessibilityAdjustments.new() + ) + if difficulty == null: + return null + if not difficulty.validation_error().is_empty(): + return null + if not resolved_upgrades.validation_error().is_empty(): + return null + if not resolved_accessibility.validation_error().is_empty(): + return null + + # Base values, then submarine upgrades, difficulty and gameplay accessibility. + var battery_capacity: float = ( + BASE_BATTERY_CAPACITY_KWH + resolved_upgrades.battery_capacity_bonus_kwh + ) + var oxygen_capacity: float = ( + BASE_OXYGEN_CAPACITY_L + resolved_upgrades.oxygen_capacity_bonus_l + ) + var hull_integrity: float = BASE_HULL_INTEGRITY + resolved_upgrades.hull_integrity_bonus + var certified_depth: float = ( + BASE_CERTIFIED_DEPTH_M + resolved_upgrades.certified_depth_bonus_m + ) + + var battery_multiplier: float = ( + resolved_upgrades.battery_consumption_multiplier + * difficulty.battery_consumption_multiplier + * resolved_accessibility.battery_consumption_multiplier + ) + var oxygen_multiplier: float = ( + difficulty.oxygen_consumption_multiplier + * resolved_accessibility.oxygen_consumption_multiplier + ) + var pressure_multiplier: float = ( + resolved_upgrades.pressure_damage_multiplier + * difficulty.pressure_damage_multiplier + * resolved_accessibility.pressure_damage_multiplier + ) + var collision_multiplier: float = ( + resolved_upgrades.collision_damage_multiplier + * difficulty.collision_damage_multiplier + ) + + var result: ResolvedDiveRules = ResolvedDiveRules.new( + battery_capacity, + oxygen_capacity, + BASE_OXYGEN_CONSUMPTION_L_PER_MIN * oxygen_multiplier, + hull_integrity, + certified_depth, + BASE_LONGITUDINAL_POWER_KW * battery_multiplier, + BASE_STEERING_POWER_KW * battery_multiplier, + BASE_VERTICAL_POWER_KW * battery_multiplier, + BASE_HIGH_THRUST_POWER_KW * battery_multiplier, + BASE_RETURN_ENERGY_KWH_PER_M * battery_multiplier, + BASE_ASCENT_ENERGY_KWH_PER_M * battery_multiplier, + pressure_multiplier, + collision_multiplier, + 3.5, + 4.0, + 0.45, + 1.5 * difficulty.crush_grace_multiplier, + 0.30, + 0.15, + 0.05, + 3.0 + ) + return result if result.validation_error().is_empty() else null diff --git a/game/domain/rules/dive_upgrade_effects.gd b/game/domain/rules/dive_upgrade_effects.gd new file mode 100644 index 0000000..eaae207 --- /dev/null +++ b/game/domain/rules/dive_upgrade_effects.gd @@ -0,0 +1,47 @@ +class_name DiveUpgradeEffects +extends RefCounted + +var battery_capacity_bonus_kwh: float +var oxygen_capacity_bonus_l: float +var hull_integrity_bonus: float +var certified_depth_bonus_m: float +var battery_consumption_multiplier: float +var collision_damage_multiplier: float +var pressure_damage_multiplier: float + + +func _init( + p_battery_capacity_bonus_kwh: float = 0.0, + p_oxygen_capacity_bonus_l: float = 0.0, + p_hull_integrity_bonus: float = 0.0, + p_certified_depth_bonus_m: float = 0.0, + p_battery_consumption_multiplier: float = 1.0, + p_collision_damage_multiplier: float = 1.0, + p_pressure_damage_multiplier: float = 1.0 +) -> void: + battery_capacity_bonus_kwh = p_battery_capacity_bonus_kwh + oxygen_capacity_bonus_l = p_oxygen_capacity_bonus_l + hull_integrity_bonus = p_hull_integrity_bonus + certified_depth_bonus_m = p_certified_depth_bonus_m + battery_consumption_multiplier = p_battery_consumption_multiplier + collision_damage_multiplier = p_collision_damage_multiplier + pressure_damage_multiplier = p_pressure_damage_multiplier + + +func validation_error() -> String: + for bonus: float in [ + battery_capacity_bonus_kwh, + oxygen_capacity_bonus_l, + hull_integrity_bonus, + certified_depth_bonus_m, + ]: + if not is_finite(bonus) or bonus < 0.0: + return "upgrade bonuses must be finite and non-negative" + for multiplier: float in [ + battery_consumption_multiplier, + collision_damage_multiplier, + pressure_damage_multiplier, + ]: + if not is_finite(multiplier) or multiplier <= 0.0: + return "upgrade multipliers must be finite and positive" + return "" diff --git a/game/domain/rules/resolved_dive_rules.gd b/game/domain/rules/resolved_dive_rules.gd new file mode 100644 index 0000000..f658bcf --- /dev/null +++ b/game/domain/rules/resolved_dive_rules.gd @@ -0,0 +1,125 @@ +class_name ResolvedDiveRules +extends RefCounted + +var battery_capacity_kwh: float +var oxygen_capacity_l: float +var oxygen_consumption_l_per_min: float +var hull_max_integrity: float + +var certified_depth_m: float +var critical_depth_m: float +var crush_depth_m: float +var certified_pressure_bar: float +var critical_pressure_bar: float +var crush_pressure_bar: float + +var longitudinal_power_kw: float +var steering_power_kw: float +var vertical_power_kw: float +var high_thrust_power_kw: float +var return_energy_kwh_per_m: float +var ascent_energy_kwh_per_m: float + +var pressure_damage_multiplier: float +var collision_damage_multiplier: float +var collision_speed_threshold_mps: float +var collision_damage_per_excess_mps: float +var collision_repeat_delay_seconds: float +var crush_grace_seconds: float + +var attention_ratio: float +var return_ratio: float +var critical_ratio: float +var ejection_hold_seconds: float + + +func _init( + p_battery_capacity_kwh: float, + p_oxygen_capacity_l: float, + p_oxygen_consumption_l_per_min: float, + p_hull_max_integrity: float, + p_certified_depth_m: float, + p_longitudinal_power_kw: float, + p_steering_power_kw: float, + p_vertical_power_kw: float, + p_high_thrust_power_kw: float, + p_return_energy_kwh_per_m: float, + p_ascent_energy_kwh_per_m: float, + p_pressure_damage_multiplier: float, + p_collision_damage_multiplier: float, + p_collision_speed_threshold_mps: float, + p_collision_damage_per_excess_mps: float, + p_collision_repeat_delay_seconds: float, + p_crush_grace_seconds: float, + p_attention_ratio: float, + p_return_ratio: float, + p_critical_ratio: float, + p_ejection_hold_seconds: float +) -> void: + battery_capacity_kwh = p_battery_capacity_kwh + oxygen_capacity_l = p_oxygen_capacity_l + oxygen_consumption_l_per_min = p_oxygen_consumption_l_per_min + hull_max_integrity = p_hull_max_integrity + certified_depth_m = p_certified_depth_m + critical_depth_m = certified_depth_m * 1.15 + crush_depth_m = certified_depth_m * 1.25 + certified_pressure_bar = _pressure_at_depth(certified_depth_m) + critical_pressure_bar = _pressure_at_depth(critical_depth_m) + crush_pressure_bar = _pressure_at_depth(crush_depth_m) + longitudinal_power_kw = p_longitudinal_power_kw + steering_power_kw = p_steering_power_kw + vertical_power_kw = p_vertical_power_kw + high_thrust_power_kw = p_high_thrust_power_kw + return_energy_kwh_per_m = p_return_energy_kwh_per_m + ascent_energy_kwh_per_m = p_ascent_energy_kwh_per_m + pressure_damage_multiplier = p_pressure_damage_multiplier + collision_damage_multiplier = p_collision_damage_multiplier + collision_speed_threshold_mps = p_collision_speed_threshold_mps + collision_damage_per_excess_mps = p_collision_damage_per_excess_mps + collision_repeat_delay_seconds = p_collision_repeat_delay_seconds + crush_grace_seconds = p_crush_grace_seconds + attention_ratio = p_attention_ratio + return_ratio = p_return_ratio + critical_ratio = p_critical_ratio + ejection_hold_seconds = p_ejection_hold_seconds + + +func validation_error() -> String: + for positive_value: float in [ + battery_capacity_kwh, + oxygen_capacity_l, + oxygen_consumption_l_per_min, + hull_max_integrity, + certified_depth_m, + longitudinal_power_kw, + return_energy_kwh_per_m, + collision_speed_threshold_mps, + collision_damage_per_excess_mps, + collision_repeat_delay_seconds, + crush_grace_seconds, + ejection_hold_seconds, + ]: + if not is_finite(positive_value) or positive_value <= 0.0: + return "resolved positive values must be finite and greater than zero" + for non_negative_value: float in [ + steering_power_kw, + vertical_power_kw, + high_thrust_power_kw, + ascent_energy_kwh_per_m, + ]: + if not is_finite(non_negative_value) or non_negative_value < 0.0: + return "resolved non-negative values must be finite" + if not ( + critical_ratio > 0.0 + and critical_ratio < return_ratio + and return_ratio < attention_ratio + and attention_ratio < 1.0 + ): + return "alert ratios must be ordered between zero and one" + if pressure_damage_multiplier <= 0.0 or collision_damage_multiplier <= 0.0: + return "damage multipliers must be positive" + return "" + + +static func _pressure_at_depth(depth_m: float) -> float: + return 1.0 + depth_m / 10.0 diff --git a/game/infrastructure/input/production_input_adapter.gd b/game/infrastructure/input/production_input_adapter.gd new file mode 100644 index 0000000..a4563ea --- /dev/null +++ b/game/infrastructure/input/production_input_adapter.gd @@ -0,0 +1,279 @@ +class_name ProductionInputAdapter +extends Node + +signal pause_requested +signal rebind_result_available(result: RebindResult) + +const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd") +const CameraLookCommand = preload("res://game/application/vehicle/camera_look_command.gd") +const InputBindingSnapshot = preload( + "res://game/application/input/input_binding_snapshot.gd" +) +const RebindResult = preload("res://game/application/input/rebind_result.gd") + +const ACTION_FORWARD := &"vehicle_forward" +const ACTION_BACKWARD := &"vehicle_backward" +const ACTION_LEFT := &"vehicle_left" +const ACTION_RIGHT := &"vehicle_right" +const ACTION_ASCEND := &"vehicle_ascend" +const ACTION_DESCEND := &"vehicle_descend" +const ACTION_HIGH_THRUST := &"vehicle_high_thrust" +const ACTION_EJECT := &"dive_eject" +const ACTION_RECENTER_CAMERA := &"camera_recenter" +const ACTION_PAUSE := &"pause_game" + +const BINDABLE_ACTIONS: Array[StringName] = [ + ACTION_FORWARD, + ACTION_BACKWARD, + ACTION_LEFT, + ACTION_RIGHT, + ACTION_ASCEND, + ACTION_DESCEND, + ACTION_HIGH_THRUST, + ACTION_EJECT, + ACTION_RECENTER_CAMERA, +] + +const ACTION_LABELS := { + ACTION_FORWARD: "Avancer", + ACTION_BACKWARD: "Reculer", + ACTION_LEFT: "Gouverner à gauche", + ACTION_RIGHT: "Gouverner à droite", + ACTION_ASCEND: "Monter", + ACTION_DESCEND: "Descendre", + ACTION_HIGH_THRUST: "Forte poussée", + ACTION_EJECT: "Éjection maintenue", + ACTION_RECENTER_CAMERA: "Recentrer le regard", +} + +const DEFAULT_KEYS := { + ACTION_FORWARD: KEY_W, + ACTION_BACKWARD: KEY_S, + ACTION_LEFT: KEY_A, + ACTION_RIGHT: KEY_D, + ACTION_ASCEND: KEY_SPACE, + ACTION_DESCEND: KEY_CTRL, + ACTION_HIGH_THRUST: KEY_SHIFT, + ACTION_EJECT: KEY_X, + ACTION_RECENTER_CAMERA: KEY_R, + ACTION_PAUSE: KEY_ESCAPE, +} + +@export var mouse_sensitivity_radians_per_pixel := 0.0025 + +var _gameplay_enabled := false +var _look_delta := Vector2.ZERO +var _rebind_action_id: StringName = &"" +var _pending_key_event: InputEventKey +var _pending_conflicting_action_id: StringName = &"" + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + _ensure_default_actions() + + +func set_gameplay_enabled(enabled: bool) -> void: + _gameplay_enabled = enabled + _look_delta = Vector2.ZERO + if enabled: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + else: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + + +func sample_vehicle_command(sequence: int) -> VehicleCommand: + if not _gameplay_enabled or get_tree().paused: + return VehicleCommand.neutral(sequence) + return VehicleCommand.new( + sequence, + Input.get_axis(ACTION_BACKWARD, ACTION_FORWARD), + Input.get_axis(ACTION_LEFT, ACTION_RIGHT), + Input.get_axis(ACTION_DESCEND, ACTION_ASCEND), + Input.is_action_pressed(ACTION_HIGH_THRUST) + ) + + +func take_camera_look_command() -> CameraLookCommand: + var command := CameraLookCommand.new( + -_look_delta.x * mouse_sensitivity_radians_per_pixel, + -_look_delta.y * mouse_sensitivity_radians_per_pixel, + _gameplay_enabled and Input.is_action_just_pressed(ACTION_RECENTER_CAMERA) + ) + _look_delta = Vector2.ZERO + return command + + +func ejection_active() -> bool: + return ( + _gameplay_enabled + and not get_tree().paused + and Input.is_action_pressed(ACTION_EJECT) + ) + + +func binding_snapshots() -> Array[InputBindingSnapshot]: + var snapshots: Array[InputBindingSnapshot] = [] + for action_id: StringName in BINDABLE_ACTIONS: + snapshots.append( + InputBindingSnapshot.new( + action_id, + String(ACTION_LABELS[action_id]), + _binding_text(action_id) + ) + ) + return snapshots + + +func begin_rebind(action_id: StringName) -> RebindResult: + if action_id not in BINDABLE_ACTIONS: + return RebindResult.new(false, action_id, "", &"", "Action inconnue") + _rebind_action_id = action_id + _pending_key_event = null + _pending_conflicting_action_id = &"" + return RebindResult.new(false, action_id, "", &"", "Appuyez sur une touche") + + +func resolve_pending_conflict(swap_bindings: bool) -> RebindResult: + if _pending_key_event == null or _rebind_action_id.is_empty(): + return RebindResult.new(false, &"", "", &"", "Aucun conflit en attente") + if not swap_bindings: + var cancelled_action := _rebind_action_id + _clear_pending_rebind() + return RebindResult.new(false, cancelled_action, "", &"", "Remappage annulé") + + var target_action := _rebind_action_id + var conflicting_action := _pending_conflicting_action_id + var previous_target_event := _first_key_event(target_action) + _set_single_key_binding(target_action, _pending_key_event) + if not conflicting_action.is_empty(): + if previous_target_event != null: + _set_single_key_binding(conflicting_action, previous_target_event) + else: + InputMap.action_erase_events(conflicting_action) + var result := RebindResult.new( + true, + target_action, + _binding_text(target_action), + conflicting_action, + "Touches permutées" + ) + _clear_pending_rebind() + return result + + +func restore_default_bindings() -> void: + for action_id: StringName in BINDABLE_ACTIONS: + _set_single_key_binding(action_id, _key_event(int(DEFAULT_KEYS[action_id]))) + + +func _unhandled_input(event: InputEvent) -> void: + if not _rebind_action_id.is_empty(): + _handle_rebind_input(event) + return + if event.is_action_pressed(ACTION_PAUSE): + pause_requested.emit() + get_viewport().set_input_as_handled() + return + if ( + _gameplay_enabled + and not get_tree().paused + and event is InputEventMouseMotion + ): + _look_delta += (event as InputEventMouseMotion).relative + + +func _handle_rebind_input(event: InputEvent) -> void: + if not (event is InputEventKey): + return + var key_event := event as InputEventKey + if not key_event.pressed or key_event.echo: + return + get_viewport().set_input_as_handled() + if key_event.physical_keycode == KEY_ESCAPE: + var cancelled_action := _rebind_action_id + _clear_pending_rebind() + rebind_result_available.emit( + RebindResult.new(false, cancelled_action, "", &"", "Remappage annulé") + ) + return + + var normalized_event := _key_event(key_event.physical_keycode) + var conflicting_action := _find_conflicting_action( + _rebind_action_id, + normalized_event.physical_keycode + ) + if not conflicting_action.is_empty(): + _pending_key_event = normalized_event + _pending_conflicting_action_id = conflicting_action + rebind_result_available.emit( + RebindResult.new( + false, + _rebind_action_id, + normalized_event.as_text_physical_keycode(), + conflicting_action, + "Cette touche est déjà utilisée" + ) + ) + return + + _set_single_key_binding(_rebind_action_id, normalized_event) + var result := RebindResult.new( + true, + _rebind_action_id, + _binding_text(_rebind_action_id), + &"", + "Commande mise à jour" + ) + _clear_pending_rebind() + rebind_result_available.emit(result) + + +func _ensure_default_actions() -> void: + for action_id: StringName in DEFAULT_KEYS: + if not InputMap.has_action(action_id): + InputMap.add_action(action_id) + if InputMap.action_get_events(action_id).is_empty(): + InputMap.action_add_event(action_id, _key_event(int(DEFAULT_KEYS[action_id]))) + + +func _find_conflicting_action( + target_action: StringName, + physical_keycode: Key +) -> StringName: + for action_id: StringName in BINDABLE_ACTIONS: + if action_id == target_action: + continue + var event := _first_key_event(action_id) + if event != null and event.physical_keycode == physical_keycode: + return action_id + return &"" + + +func _binding_text(action_id: StringName) -> String: + var event := _first_key_event(action_id) + return event.as_text_physical_keycode() if event != null else "Non assignée" + + +func _first_key_event(action_id: StringName) -> InputEventKey: + for event: InputEvent in InputMap.action_get_events(action_id): + if event is InputEventKey: + return (event as InputEventKey).duplicate() + return null + + +func _set_single_key_binding(action_id: StringName, event: InputEventKey) -> void: + InputMap.action_erase_events(action_id) + InputMap.action_add_event(action_id, event) + + +func _key_event(physical_keycode: Key) -> InputEventKey: + var event := InputEventKey.new() + event.physical_keycode = physical_keycode + return event + + +func _clear_pending_rebind() -> void: + _rebind_action_id = &"" + _pending_key_event = null + _pending_conflicting_action_id = &"" diff --git a/game/presentation/cockpit/cockpit_camera_controller.gd b/game/presentation/cockpit/cockpit_camera_controller.gd new file mode 100644 index 0000000..cdc54f0 --- /dev/null +++ b/game/presentation/cockpit/cockpit_camera_controller.gd @@ -0,0 +1,82 @@ +class_name CockpitCameraController +extends Node3D + +const CameraLookCommand = preload( + "res://game/application/vehicle/camera_look_command.gd" +) +const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") + +@export var minimum_pitch_radians := -0.30 +@export var maximum_pitch_radians := 0.20 +@export var maximum_yaw_radians := deg_to_rad(30.0) +@export var feedback_response := 4.5 +@export var maximum_feedback_pitch_radians := deg_to_rad(18.0) +@export var maximum_feedback_roll_radians := deg_to_rad(16.0) * 0.55 + +var _look_yaw := 0.0 +var _look_pitch := -0.04 +var _feedback_pitch := 0.0 +var _feedback_roll := 0.0 +var _target_feedback_pitch := 0.0 +var _target_feedback_roll := 0.0 + + +func apply_look(command: CameraLookCommand) -> void: + if command == null or not command.validation_error().is_empty(): + return + if command.recenter_requested: + _look_yaw = 0.0 + _look_pitch = -0.04 + else: + _look_yaw = clampf( + _look_yaw + command.yaw_delta_radians, + -maximum_yaw_radians, + maximum_yaw_radians + ) + _look_pitch = clampf( + _look_pitch + command.pitch_delta_radians, + minimum_pitch_radians, + maximum_pitch_radians + ) + _apply_rotation() + + +func present_telemetry(telemetry: VehicleTelemetry) -> void: + if telemetry == null: + return + _target_feedback_pitch = ( + -telemetry.vertical_effort * maximum_feedback_pitch_radians + ) + _target_feedback_roll = ( + -telemetry.steering_effort * maximum_feedback_roll_radians + ) + + +func look_yaw_radians() -> float: + return _look_yaw + + +func look_pitch_radians() -> float: + return _look_pitch + + +func _process(delta: float) -> void: + _feedback_pitch = lerpf( + _feedback_pitch, + _target_feedback_pitch, + 1.0 - exp(-feedback_response * delta) + ) + _feedback_roll = lerpf( + _feedback_roll, + _target_feedback_roll, + 1.0 - exp(-feedback_response * delta) + ) + _apply_rotation() + + +func _apply_rotation() -> void: + rotation = Vector3( + _look_pitch + _feedback_pitch, + _look_yaw, + _feedback_roll + ) diff --git a/game/presentation/cockpit/cockpit_camera_rig.tscn b/game/presentation/cockpit/cockpit_camera_rig.tscn new file mode 100644 index 0000000..361a501 --- /dev/null +++ b/game/presentation/cockpit/cockpit_camera_rig.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_camera_controller.gd" id="1_camera"] + +[node name="CockpitCameraRig" type="Node3D"] +position = Vector3(0, 0.52, -0.48) +script = ExtResource("1_camera") + +[node name="Camera" type="Camera3D" parent="."] +current = true +fov = 82.0 +near = 0.05 diff --git a/game/presentation/cockpit/cockpit_hud.gd b/game/presentation/cockpit/cockpit_hud.gd new file mode 100644 index 0000000..f79e3c0 --- /dev/null +++ b/game/presentation/cockpit/cockpit_hud.gd @@ -0,0 +1,61 @@ +class_name CockpitHud +extends Control + +const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const DiveTerminalReason = preload("res://game/domain/dive/dive_terminal_reason.gd") +const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") + +@onready var _alert_panel: PanelContainer = %AlertPanel +@onready var _alert_label: Label = %AlertLabel +@onready var _ejection_panel: PanelContainer = %EjectionPanel +@onready var _ejection_label: Label = %EjectionLabel +@onready var _terminal_panel: PanelContainer = %TerminalPanel +@onready var _terminal_label: Label = %TerminalLabel + + +func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void: + if update == null or update.snapshot == null or telemetry == null: + return + var snapshot = update.snapshot + _alert_panel.visible = snapshot.alerts.maximum != DiveAlertLevel.Value.NORMAL + _alert_label.text = "ALERTE · %s" % _alert_text(snapshot.alerts.maximum) + _ejection_panel.visible = snapshot.ejection.held_seconds > 0.0 + _ejection_label.text = "ÉJECTION %.0f %%" % (snapshot.ejection.ratio * 100.0) + _terminal_panel.visible = snapshot.is_terminal() + if snapshot.is_terminal(): + _terminal_label.text = "%s\nÉchap pour ouvrir le menu de pause." % [ + _terminal_text(snapshot.terminal_reason) + ] + + +func alert_overlay_visible() -> bool: + return _alert_panel.visible + + +func _alert_text(level: DiveAlertLevel.Value) -> String: + match level: + DiveAlertLevel.Value.NORMAL: + return tr("NORMAL") + DiveAlertLevel.Value.ATTENTION: + return tr("ATTENTION") + DiveAlertLevel.Value.RETURN: + return tr("RETOUR") + DiveAlertLevel.Value.CRITICAL: + return tr("CRITIQUE") + DiveAlertLevel.Value.FAILURE: + return tr("DÉFAILLANCE") + return tr("INCONNU") + + +func _terminal_text(reason: DiveTerminalReason.Value) -> String: + match reason: + DiveTerminalReason.Value.VOLUNTARY_EJECTION: + return tr("ÉJECTION DÉCLENCHÉE") + DiveTerminalReason.Value.OXYGEN_DEPLETED: + return tr("OXYGÈNE ÉPUISÉ") + DiveTerminalReason.Value.HULL_DESTROYED: + return tr("COQUE DÉTRUITE") + DiveTerminalReason.Value.RETURNED: + return tr("RETOUR TERMINÉ") + return tr("PLONGÉE TERMINÉE") diff --git a/game/presentation/cockpit/cockpit_hud.tscn b/game/presentation/cockpit/cockpit_hud.tscn new file mode 100644 index 0000000..ddc071b --- /dev/null +++ b/game/presentation/cockpit/cockpit_hud.tscn @@ -0,0 +1,109 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_hud.gd" id="1_hud"] + +[sub_resource type="StyleBoxFlat" id="AlertStyle"] +bg_color = Color(0.14, 0.075, 0.02, 0.92) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(1, 0.75, 0.2, 1) +content_margin_left = 22.0 +content_margin_top = 10.0 +content_margin_right = 22.0 +content_margin_bottom = 10.0 + +[sub_resource type="StyleBoxFlat" id="DangerStyle"] +bg_color = Color(0.16, 0.025, 0.018, 0.94) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(1, 0.42, 0.31, 1) +content_margin_left = 24.0 +content_margin_top = 12.0 +content_margin_right = 24.0 +content_margin_bottom = 12.0 + +[node name="CockpitHud" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_hud") + +[node name="AlertPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_left = 590.0 +offset_top = 26.0 +offset_right = -590.0 +offset_bottom = 78.0 +grow_horizontal = 2 +theme_override_styles/panel = SubResource("AlertStyle") + +[node name="AlertLabel" type="Label" parent="AlertPanel"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.82, 0.42, 1) +theme_override_font_sizes/font_size = 20 +text = "ALERTE · ATTENTION" +horizontal_alignment = 1 + +[node name="EjectionPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -190.0 +offset_top = -94.0 +offset_right = 190.0 +offset_bottom = -36.0 +grow_horizontal = 2 +grow_vertical = 0 +theme_override_styles/panel = SubResource("DangerStyle") + +[node name="EjectionLabel" type="Label" parent="EjectionPanel"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.48, 0.38, 1) +theme_override_font_sizes/font_size = 20 +text = "ÉJECTION —" +horizontal_alignment = 1 + +[node name="TerminalPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -280.0 +offset_top = -82.0 +offset_right = 280.0 +offset_bottom = 82.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/panel = SubResource("DangerStyle") + +[node name="TerminalLabel" type="Label" parent="TerminalPanel"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.74, 0.68, 1) +theme_override_font_sizes/font_size = 24 +text = "PLONGÉE TERMINÉE" +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/game/presentation/cockpit/cockpit_instrument_panel.gd b/game/presentation/cockpit/cockpit_instrument_panel.gd new file mode 100644 index 0000000..a36c6b8 --- /dev/null +++ b/game/presentation/cockpit/cockpit_instrument_panel.gd @@ -0,0 +1,118 @@ +class_name CockpitInstrumentPanel +extends Node3D + +const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const ReturnEstimate = preload("res://game/domain/dive/return_estimate.gd") +const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") + +const COLOR_NORMAL := Color(0.45, 0.89, 0.90, 1.0) +const COLOR_ATTENTION := Color(1.0, 0.75, 0.20, 1.0) +const COLOR_CRITICAL := Color(1.0, 0.42, 0.31, 1.0) + +@onready var _resources_label: Label3D = %ResourcesLabel +@onready var _navigation_label: Label3D = %NavigationLabel +@onready var _integrity_label: Label3D = %IntegrityLabel +@onready var _return_label: Label3D = %ReturnLabel +@onready var _status_label: Label3D = %StatusLabel + + +func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void: + if update == null or update.snapshot == null or telemetry == null: + return + var snapshot = update.snapshot + _resources_label.text = ( + "BATTERIE\n%.1f / %.1f kWh · %.0f %%\n%.0f kW\n\nOXYGÈNE\n%.1f / %.1f L · %.0f %%\n%.1f L/min" + % [ + snapshot.battery.remaining_kwh, + snapshot.battery.maximum_kwh, + snapshot.battery.ratio * 100.0, + snapshot.battery.current_power_kw, + snapshot.oxygen.remaining_l, + snapshot.oxygen.maximum_l, + snapshot.oxygen.ratio * 100.0, + snapshot.oxygen.consumption_l_per_min, + ] + ) + _navigation_label.text = ( + "PROFONDEUR\n%.1f m\nMAX. CONSEILLÉ %.1f m\n\nVITESSE\n%.2f m/s" + % [ + snapshot.depth.current_m, + snapshot.depth.certified_m, + telemetry.world_linear_velocity_mps.length(), + ] + ) + _integrity_label.text = ( + "COQUE\n%.1f / %.1f · %.0f %%\n\nPRESSION\n%.2f / %.2f bar" + % [ + snapshot.hull.current_integrity, + snapshot.hull.maximum_integrity, + snapshot.hull.ratio * 100.0, + snapshot.hull.exterior_pressure_bar, + snapshot.hull.certified_pressure_bar, + ] + ) + _return_label.text = _return_text(snapshot.return_estimate) + _status_label.text = "SYSTÈMES · %s" % _alert_text(snapshot.alerts.maximum) + _resources_label.modulate = _alert_color( + maxi(snapshot.alerts.battery, snapshot.alerts.oxygen) + ) + _integrity_label.modulate = _alert_color( + maxi(snapshot.alerts.hull, snapshot.alerts.depth) + ) + _status_label.modulate = _alert_color(snapshot.alerts.maximum) + + +func resources_text() -> String: + return _resources_label.text + + +func navigation_text() -> String: + return _navigation_label.text + + +func integrity_text() -> String: + return _integrity_label.text + + +func return_text() -> String: + return _return_label.text + + +func _return_text(estimate: ReturnEstimate) -> String: + match estimate.status: + ReturnEstimate.Status.SAFE: + return "RETOUR %.1f kWh · MARGE %.1f kWh" % [ + estimate.required_kwh, + estimate.margin_kwh, + ] + ReturnEstimate.Status.TIGHT: + return "RETOUR SERRÉ · MARGE %.1f kWh" % estimate.margin_kwh + ReturnEstimate.Status.INSUFFICIENT: + return "RETOUR INSUFFISANT · MANQUE %.1f kWh" % absf( + estimate.margin_kwh + ) + return "RETOUR · INCONNU" + + +func _alert_text(level: DiveAlertLevel.Value) -> String: + match level: + DiveAlertLevel.Value.NORMAL: + return "NORMAL" + DiveAlertLevel.Value.ATTENTION: + return "ATTENTION" + DiveAlertLevel.Value.RETURN: + return "RETOUR" + DiveAlertLevel.Value.CRITICAL: + return "CRITIQUE" + DiveAlertLevel.Value.FAILURE: + return "DÉFAILLANCE" + return "INCONNU" + + +func _alert_color(level: DiveAlertLevel.Value) -> Color: + if level >= DiveAlertLevel.Value.CRITICAL: + return COLOR_CRITICAL + if level >= DiveAlertLevel.Value.ATTENTION: + return COLOR_ATTENTION + return COLOR_NORMAL diff --git a/game/presentation/cockpit/cockpit_interior.tscn b/game/presentation/cockpit/cockpit_interior.tscn new file mode 100644 index 0000000..3ff3e8e --- /dev/null +++ b/game/presentation/cockpit/cockpit_interior.tscn @@ -0,0 +1,450 @@ +[gd_scene load_steps=19 format=3] + +[ext_resource type="Script" path="res://game/presentation/cockpit/cockpit_instrument_panel.gd" id="1_instruments"] + +[sub_resource type="StandardMaterial3D" id="SteelMaterial"] +albedo_color = Color(0.095, 0.12, 0.12, 1) +metallic = 0.82 +roughness = 0.34 + +[sub_resource type="StandardMaterial3D" id="PaintedMaterial"] +albedo_color = Color(0.085, 0.185, 0.19, 1) +metallic = 0.58 +roughness = 0.48 + +[sub_resource type="StandardMaterial3D" id="BrassMaterial"] +albedo_color = Color(0.36, 0.24, 0.075, 1) +metallic = 0.78 +roughness = 0.31 + +[sub_resource type="StandardMaterial3D" id="RubberMaterial"] +albedo_color = Color(0.018, 0.024, 0.025, 1) +roughness = 0.88 + +[sub_resource type="StandardMaterial3D" id="GlassMaterial"] +transparency = 1 +shading_mode = 0 +albedo_color = Color(0.12, 0.42, 0.47, 0.09) +metallic = 0.12 +roughness = 0.08 +cull_mode = 2 + +[sub_resource type="StandardMaterial3D" id="ScreenMaterial"] +albedo_color = Color(0.006, 0.025, 0.028, 1) +metallic = 0.15 +roughness = 0.22 +emission_enabled = true +emission = Color(0.008, 0.07, 0.075, 1) +emission_energy_multiplier = 1.7 + +[sub_resource type="StandardMaterial3D" id="CyanMaterial"] +shading_mode = 0 +albedo_color = Color(0.2, 0.78, 0.82, 1) +emission_enabled = true +emission = Color(0.12, 0.72, 0.76, 1) +emission_energy_multiplier = 3.2 + +[sub_resource type="StandardMaterial3D" id="AmberMaterial"] +shading_mode = 0 +albedo_color = Color(0.95, 0.52, 0.08, 1) +emission_enabled = true +emission = Color(0.95, 0.33, 0.02, 1) +emission_energy_multiplier = 2.6 + +[sub_resource type="StandardMaterial3D" id="RedMaterial"] +shading_mode = 0 +albedo_color = Color(0.82, 0.08, 0.045, 1) +emission_enabled = true +emission = Color(0.92, 0.055, 0.025, 1) +emission_energy_multiplier = 2.8 + +[sub_resource type="BoxMesh" id="UnitBoxMesh"] +size = Vector3(1, 1, 1) + +[sub_resource type="TorusMesh" id="PortholeTorusMesh"] +inner_radius = 0.56 +outer_radius = 0.71 +rings = 48 +ring_segments = 16 + +[sub_resource type="CylinderMesh" id="GlassCylinderMesh"] +top_radius = 0.56 +bottom_radius = 0.56 +height = 0.012 +radial_segments = 64 + +[sub_resource type="CylinderMesh" id="BoltMesh"] +top_radius = 0.026 +bottom_radius = 0.026 +height = 0.045 +radial_segments = 12 + +[sub_resource type="QuadMesh" id="ScreenQuadMesh"] +size = Vector2(1, 1) + +[sub_resource type="CylinderMesh" id="LeverMesh"] +top_radius = 0.018 +bottom_radius = 0.023 +height = 0.24 +radial_segments = 12 + +[sub_resource type="SphereMesh" id="KnobMesh"] +radius = 0.046 +height = 0.092 +radial_segments = 16 +rings = 8 + +[sub_resource type="CylinderMesh" id="DialMesh"] +top_radius = 0.105 +bottom_radius = 0.105 +height = 0.035 +radial_segments = 32 + +[node name="CockpitInterior" type="Node3D"] +script = ExtResource("1_instruments") + +[node name="CabinShell" type="Node3D" parent="."] + +[node name="LeftWall" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(-1.03, 0.24, -0.15) +scale = Vector3(0.16, 1.82, 2.7) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("PaintedMaterial") + +[node name="RightWall" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(1.03, 0.24, -0.15) +scale = Vector3(0.16, 1.82, 2.7) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("PaintedMaterial") + +[node name="Ceiling" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(0, 1.22, -0.2) +scale = Vector3(2.15, 0.16, 2.6) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("SteelMaterial") + +[node name="Floor" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(0, -0.7, -0.1) +scale = Vector3(2.05, 0.18, 2.5) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("RubberMaterial") + +[node name="RearBulkhead" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(0, 0.22, 1.08) +scale = Vector3(2.08, 1.75, 0.16) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("SteelMaterial") + +[node name="LeftRib" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(-0.88, 0.26, -0.45) +scale = Vector3(0.11, 1.75, 0.18) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("BrassMaterial") + +[node name="RightRib" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(0.88, 0.26, -0.45) +scale = Vector3(0.11, 1.75, 0.18) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("BrassMaterial") + +[node name="CeilingRib" type="MeshInstance3D" parent="CabinShell"] +position = Vector3(0, 1.05, -0.45) +scale = Vector3(1.82, 0.1, 0.18) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("BrassMaterial") + +[node name="PortholeAssembly" type="Node3D" parent="."] +position = Vector3(0, 0.47, -1.32) + +[node name="LeftBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(-0.94, 0, 0) +scale = Vector3(0.6, 1.82, 0.2) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("SteelMaterial") + +[node name="RightBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0.94, 0, 0) +scale = Vector3(0.6, 1.82, 0.2) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("SteelMaterial") + +[node name="TopBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0, 0.91, 0) +scale = Vector3(1.55, 0.52, 0.2) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("SteelMaterial") + +[node name="BottomBulkhead" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0, -0.91, 0) +scale = Vector3(1.55, 0.52, 0.2) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("SteelMaterial") + +[node name="WindowFrame" type="MeshInstance3D" parent="PortholeAssembly"] +unique_name_in_owner = true +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("PortholeTorusMesh") +material_override = SubResource("BrassMaterial") + +[node name="RubberSeal" type="MeshInstance3D" parent="PortholeAssembly"] +scale = Vector3(0.91, 0.91, 0.91) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("PortholeTorusMesh") +material_override = SubResource("RubberMaterial") + +[node name="WindowGlass" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0, 0, -0.01) +rotation_degrees = Vector3(90, 0, 0) +cast_shadow = 0 +mesh = SubResource("GlassCylinderMesh") +material_override = SubResource("GlassMaterial") + +[node name="BoltTop" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0, 0.665, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="BoltBottom" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0, -0.665, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="BoltLeft" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(-0.665, 0, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="BoltRight" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0.665, 0, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="BoltUpperLeft" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(-0.47, 0.47, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="BoltUpperRight" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0.47, 0.47, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="BoltLowerLeft" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(-0.47, -0.47, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="BoltLowerRight" type="MeshInstance3D" parent="PortholeAssembly"] +position = Vector3(0.47, -0.47, 0.1) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("BoltMesh") +material_override = SubResource("SteelMaterial") + +[node name="Dashboard" type="Node3D" parent="."] +unique_name_in_owner = true +position = Vector3(0, 0.05, -1.22) +rotation_degrees = Vector3(-12, 0, 0) + +[node name="MainPanel" type="MeshInstance3D" parent="Dashboard"] +scale = Vector3(1.56, 0.44, 0.14) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("PaintedMaterial") + +[node name="ResourcesFrame" type="MeshInstance3D" parent="Dashboard"] +position = Vector3(-0.5, 0.03, 0.088) +scale = Vector3(0.45, 0.31, 0.025) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("BrassMaterial") + +[node name="ResourcesScreen" type="MeshInstance3D" parent="Dashboard"] +position = Vector3(-0.5, 0.03, 0.102) +scale = Vector3(0.41, 0.27, 1) +mesh = SubResource("ScreenQuadMesh") +material_override = SubResource("ScreenMaterial") + +[node name="ResourcesLabel" type="Label3D" parent="Dashboard"] +unique_name_in_owner = true +position = Vector3(-0.5, 0.03, 0.108) +pixel_size = 0.00115 +font_size = 20 +outline_size = 2 +text = "BATTERIE\n— / — kWh\n— kW\n\nOXYGÈNE\n— / — L\n— L/min" +modulate = Color(0.45, 0.89, 0.9, 1) + +[node name="NavigationFrame" type="MeshInstance3D" parent="Dashboard"] +position = Vector3(0, 0.03, 0.088) +scale = Vector3(0.4, 0.31, 0.025) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("BrassMaterial") + +[node name="NavigationScreen" type="MeshInstance3D" parent="Dashboard"] +position = Vector3(0, 0.03, 0.102) +scale = Vector3(0.36, 0.27, 1) +mesh = SubResource("ScreenQuadMesh") +material_override = SubResource("ScreenMaterial") + +[node name="NavigationLabel" type="Label3D" parent="Dashboard"] +unique_name_in_owner = true +position = Vector3(0, 0.03, 0.108) +pixel_size = 0.00115 +font_size = 20 +outline_size = 2 +text = "PROFONDEUR\n— m\nMAX. — m\n\nVITESSE\n— m/s" +modulate = Color(0.45, 0.89, 0.9, 1) + +[node name="IntegrityFrame" type="MeshInstance3D" parent="Dashboard"] +position = Vector3(0.5, 0.03, 0.088) +scale = Vector3(0.45, 0.31, 0.025) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("BrassMaterial") + +[node name="IntegrityScreen" type="MeshInstance3D" parent="Dashboard"] +position = Vector3(0.5, 0.03, 0.102) +scale = Vector3(0.41, 0.27, 1) +mesh = SubResource("ScreenQuadMesh") +material_override = SubResource("ScreenMaterial") + +[node name="IntegrityLabel" type="Label3D" parent="Dashboard"] +unique_name_in_owner = true +position = Vector3(0.5, 0.03, 0.108) +pixel_size = 0.00115 +font_size = 20 +outline_size = 2 +text = "COQUE\n— / —\n\nPRESSION\n— / — bar" +modulate = Color(0.45, 0.89, 0.9, 1) + +[node name="ReturnLabel" type="Label3D" parent="Dashboard"] +unique_name_in_owner = true +position = Vector3(0.32, 0.205, 0.108) +pixel_size = 0.00082 +font_size = 15 +outline_size = 2 +text = "RETOUR · INCONNU" +modulate = Color(1, 0.75, 0.2, 1) + +[node name="StatusLabel" type="Label3D" parent="Dashboard"] +unique_name_in_owner = true +position = Vector3(-0.34, 0.205, 0.108) +pixel_size = 0.00082 +font_size = 15 +outline_size = 2 +text = "SYSTÈMES · NORMAL" +modulate = Color(0.45, 0.89, 0.9, 1) + +[node name="LeftConsole" type="MeshInstance3D" parent="."] +position = Vector3(-0.82, -0.23, -1.19) +rotation_degrees = Vector3(-8, 18, 4) +scale = Vector3(0.32, 0.16, 0.56) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("PaintedMaterial") + +[node name="RightConsole" type="MeshInstance3D" parent="."] +position = Vector3(0.82, -0.23, -1.19) +rotation_degrees = Vector3(-8, -18, -4) +scale = Vector3(0.32, 0.16, 0.56) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("PaintedMaterial") + +[node name="ThrottleLever" type="MeshInstance3D" parent="."] +position = Vector3(-0.79, -0.06, -1.13) +rotation_degrees = Vector3(18, 0, -10) +scale = Vector3(0.72, 0.72, 0.72) +mesh = SubResource("LeverMesh") +material_override = SubResource("SteelMaterial") + +[node name="ThrottleKnob" type="MeshInstance3D" parent="."] +position = Vector3(-0.81, 0.025, -1.1) +scale = Vector3(0.72, 0.72, 0.72) +mesh = SubResource("KnobMesh") +material_override = SubResource("BrassMaterial") + +[node name="VerticalLever" type="MeshInstance3D" parent="."] +position = Vector3(0.79, -0.06, -1.13) +rotation_degrees = Vector3(18, 0, 10) +scale = Vector3(0.72, 0.72, 0.72) +mesh = SubResource("LeverMesh") +material_override = SubResource("SteelMaterial") + +[node name="VerticalKnob" type="MeshInstance3D" parent="."] +position = Vector3(0.81, 0.025, -1.1) +scale = Vector3(0.72, 0.72, 0.72) +mesh = SubResource("KnobMesh") +material_override = SubResource("BrassMaterial") + +[node name="SonarMonitor" type="Node3D" parent="."] +position = Vector3(0.72, 0.4, -1.24) +rotation_degrees = Vector3(-5, -22, -2) + +[node name="Housing" type="MeshInstance3D" parent="SonarMonitor"] +scale = Vector3(0.32, 0.24, 0.08) +mesh = SubResource("UnitBoxMesh") +material_override = SubResource("RubberMaterial") + +[node name="SonarScreen" type="MeshInstance3D" parent="SonarMonitor"] +unique_name_in_owner = true +position = Vector3(0, 0.025, 0.06) +scale = Vector3(0.265, 0.165, 1) +mesh = SubResource("ScreenQuadMesh") +material_override = SubResource("ScreenMaterial") + +[node name="SonarPlaceholder" type="Label3D" parent="SonarMonitor"] +position = Vector3(0, 0.025, 0.068) +pixel_size = 0.0009 +font_size = 16 +outline_size = 2 +text = "SONAR\nMODULE EN ATTENTE" +modulate = Color(0.34, 0.6, 0.6, 1) + +[node name="EmergencyDial" type="MeshInstance3D" parent="."] +position = Vector3(-0.6, 0.39, -1.15) +scale = Vector3(0.38, 0.38, 0.38) +rotation_degrees = Vector3(90, 0, 0) +mesh = SubResource("DialMesh") +material_override = SubResource("RedMaterial") + +[node name="EmergencyLabel" type="Label3D" parent="."] +position = Vector3(-0.6, 0.3, -1.125) +pixel_size = 0.0009 +font_size = 15 +outline_size = 2 +text = "ÉJECTION · X" +modulate = Color(1, 0.48, 0.38, 1) + +[node name="CyanIndicator" type="MeshInstance3D" parent="."] +position = Vector3(-0.15, 0.305, -1.11) +scale = Vector3(0.25, 0.25, 0.25) +mesh = SubResource("KnobMesh") +material_override = SubResource("CyanMaterial") + +[node name="AmberIndicator" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.305, -1.11) +scale = Vector3(0.25, 0.25, 0.25) +mesh = SubResource("KnobMesh") +material_override = SubResource("AmberMaterial") + +[node name="RedIndicator" type="MeshInstance3D" parent="."] +position = Vector3(0.15, 0.305, -1.11) +scale = Vector3(0.25, 0.25, 0.25) +mesh = SubResource("KnobMesh") +material_override = SubResource("RedMaterial") + +[node name="CabinLight" type="OmniLight3D" parent="."] +position = Vector3(0, 0.92, -0.28) +light_color = Color(0.42, 0.75, 0.74, 1) +light_energy = 2.2 +shadow_enabled = true +omni_range = 3.3 + +[node name="PanelLight" type="OmniLight3D" parent="."] +position = Vector3(0, 0.18, -0.63) +light_color = Color(0.23, 0.72, 0.76, 1) +light_energy = 0.8 +shadow_enabled = false +omni_range = 1.35 diff --git a/game/presentation/confirmation/confirmation_dialog.gd b/game/presentation/confirmation/confirmation_dialog.gd new file mode 100644 index 0000000..40e97db --- /dev/null +++ b/game/presentation/confirmation/confirmation_dialog.gd @@ -0,0 +1,26 @@ +class_name ConfirmationDialogScreen +extends Control + +signal cancelled +signal confirmed(action_id: StringName) + +var _action_id: StringName + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + %CancelButton.pressed.connect(func() -> void: cancelled.emit()) + %ConfirmButton.pressed.connect(func() -> void: confirmed.emit(_action_id)) + %CancelButton.grab_focus() + + +func configure( + action_id: StringName, + title: String, + message: String, + confirm_label: String +) -> void: + _action_id = action_id + %Title.text = title + %Message.text = message + %ConfirmButton.text = confirm_label diff --git a/game/presentation/confirmation/confirmation_dialog.tscn b/game/presentation/confirmation/confirmation_dialog.tscn new file mode 100644 index 0000000..2270ffc --- /dev/null +++ b/game/presentation/confirmation/confirmation_dialog.tscn @@ -0,0 +1,74 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://game/presentation/confirmation/confirmation_dialog.gd" id="1_confirm"] + +[node name="ConfirmationDialog" type="Control"] +process_mode = 3 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_confirm") + +[node name="Dim" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0.72) + +[node name="Center" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Panel" type="PanelContainer" parent="Center"] +custom_minimum_size = Vector2(650, 280) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="Center/Panel"] +layout_mode = 2 +theme_override_constants/margin_left = 34 +theme_override_constants/margin_top = 28 +theme_override_constants/margin_right = 34 +theme_override_constants/margin_bottom = 28 + +[node name="Layout" type="VBoxContainer" parent="Center/Panel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 18 + +[node name="Title" type="Label" parent="Center/Panel/Margin/Layout"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "CONFIRMER" + +[node name="Message" type="Label" parent="Center/Panel/Margin/Layout"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +text = "Cette action doit être confirmée." +autowrap_mode = 2 + +[node name="Actions" type="HBoxContainer" parent="Center/Panel/Margin/Layout"] +layout_mode = 2 +alignment = 2 + +[node name="CancelButton" type="Button" parent="Center/Panel/Margin/Layout/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(190, 46) +layout_mode = 2 +text = "RESTER EN PLONGÉE" + +[node name="ConfirmButton" type="Button" parent="Center/Panel/Margin/Layout/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(210, 46) +layout_mode = 2 +text = "CONFIRMER" diff --git a/game/presentation/diagnostic/diagnostic_dive_screen.gd b/game/presentation/diagnostic/diagnostic_dive_screen.gd new file mode 100644 index 0000000..22191cb --- /dev/null +++ b/game/presentation/diagnostic/diagnostic_dive_screen.gd @@ -0,0 +1,141 @@ +class_name DiagnosticDiveScreen +extends Control + +signal menu_requested + +const DiveUpdate = preload("res://game/domain/dive/dive_update.gd") +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const DiveTerminalReason = preload("res://game/domain/dive/dive_terminal_reason.gd") +const ReturnEstimate = preload("res://game/domain/dive/return_estimate.gd") + +@onready var _status_label: Label = %StatusLabel +@onready var _tick_label: Label = %TickLabel +@onready var _battery_label: Label = %BatteryLabel +@onready var _oxygen_label: Label = %OxygenLabel +@onready var _hull_label: Label = %HullLabel +@onready var _depth_label: Label = %DepthLabel +@onready var _return_label: Label = %ReturnLabel +@onready var _alert_label: Label = %AlertLabel +@onready var _event_label: Label = %EventLabel +@onready var _menu_button: Button = %MenuButton + + +func _ready() -> void: + _menu_button.pressed.connect(_on_menu_pressed) + + +func present(update: DiveUpdate) -> void: + if update == null or update.snapshot == null: + present_setup_error("Mise à jour de plongée absente") + return + if not update.accepted: + _status_label.text = "ÉTAT · PAS REFUSÉ · %s" % update.rejection_reason + else: + _status_label.text = "ÉTAT · SIMULATION DÉTERMINISTE · 60 HZ" + + var snapshot = update.snapshot + _tick_label.text = "SESSION %s · TICK %d" % [snapshot.session_id, snapshot.tick_sequence] + _battery_label.text = ( + "BATTERIE %.2f / %.2f kWh · %.0f %% · %.1f kW" + % [ + snapshot.battery.remaining_kwh, + snapshot.battery.maximum_kwh, + snapshot.battery.ratio * 100.0, + snapshot.battery.current_power_kw, + ] + ) + _oxygen_label.text = ( + "OXYGÈNE %.1f / %.1f L · %.0f %% · %.1f L/min · %.1f min" + % [ + snapshot.oxygen.remaining_l, + snapshot.oxygen.maximum_l, + snapshot.oxygen.ratio * 100.0, + snapshot.oxygen.consumption_l_per_min, + snapshot.oxygen.endurance_minutes, + ] + ) + _hull_label.text = ( + "COQUE %.1f / %.1f · %.0f %% · %.2f / %.2f bar recommandés" + % [ + snapshot.hull.current_integrity, + snapshot.hull.maximum_integrity, + snapshot.hull.ratio * 100.0, + snapshot.hull.exterior_pressure_bar, + snapshot.hull.certified_pressure_bar, + ] + ) + _depth_label.text = ( + "PROFONDEUR %.1f m · CERTIFIÉE %.1f m · CRITIQUE %.1f m" + % [ + snapshot.depth.current_m, + snapshot.depth.certified_m, + snapshot.depth.critical_m, + ] + ) + _return_label.text = _format_return_estimate(snapshot.return_estimate) + _alert_label.text = "ALERTE MAXIMALE · %s" % _alert_level_text(snapshot.alerts.maximum) + _event_label.text = ( + "TERMINAISON · %s · ÉVÉNEMENTS DU PAS · %d" + % [_terminal_reason_text(snapshot.terminal_reason), update.events.size()] + ) + + +func present_setup_error(message: String) -> void: + _status_label.text = "ÉTAT · INITIALISATION IMPOSSIBLE" + _tick_label.text = message + + +func _on_menu_pressed() -> void: + menu_requested.emit() + + +func _format_return_estimate(estimate: ReturnEstimate) -> String: + match estimate.status: + ReturnEstimate.Status.UNKNOWN: + return "RETOUR · ESTIMATION INCONNUE" + ReturnEstimate.Status.SAFE: + return "RETOUR %.2f kWh requis · MARGE %.2f kWh · SÛR" % [ + estimate.required_kwh, + estimate.margin_kwh, + ] + ReturnEstimate.Status.TIGHT: + return "RETOUR %.2f kWh requis · MARGE %.2f kWh · SERRÉ" % [ + estimate.required_kwh, + estimate.margin_kwh, + ] + ReturnEstimate.Status.INSUFFICIENT: + return "RETOUR %.2f kWh requis · MANQUE %.2f kWh" % [ + estimate.required_kwh, + absf(estimate.margin_kwh), + ] + return "RETOUR · ÉTAT INCONNU" + + +func _alert_level_text(level: DiveAlertLevel.Value) -> String: + match level: + DiveAlertLevel.Value.NORMAL: + return tr("NORMAL") + DiveAlertLevel.Value.ATTENTION: + return tr("ATTENTION") + DiveAlertLevel.Value.RETURN: + return tr("RETOUR") + DiveAlertLevel.Value.CRITICAL: + return tr("CRITIQUE") + DiveAlertLevel.Value.FAILURE: + return tr("DÉFAILLANCE") + return tr("INCONNU") + + +func _terminal_reason_text(reason: DiveTerminalReason.Value) -> String: + match reason: + DiveTerminalReason.Value.NONE: + return tr("EN COURS") + DiveTerminalReason.Value.RETURNED: + return tr("RETOUR") + DiveTerminalReason.Value.VOLUNTARY_EJECTION: + return tr("ÉJECTION VOLONTAIRE") + DiveTerminalReason.Value.OXYGEN_DEPLETED: + return tr("OXYGÈNE ÉPUISÉ") + DiveTerminalReason.Value.HULL_DESTROYED: + return tr("COQUE DÉTRUITE") + return tr("INCONNU") diff --git a/game/presentation/diagnostic/diagnostic_dive_screen.tscn b/game/presentation/diagnostic/diagnostic_dive_screen.tscn new file mode 100644 index 0000000..7156a7d --- /dev/null +++ b/game/presentation/diagnostic/diagnostic_dive_screen.tscn @@ -0,0 +1,119 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://game/presentation/diagnostic/diagnostic_dive_screen.gd" id="1_screen"] + +[node name="DiagnosticDiveScreen" type="Control"] +unique_name_in_owner = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_screen") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.0118, 0.0549, 0.0745, 1) +mouse_filter = 2 + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 72.0 +offset_top = 56.0 +offset_right = -72.0 +offset_bottom = -56.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Rows" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 18 + +[node name="Title" type="Label" parent="Margin/Rows"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.4549, 0.8902, 0.898, 1) +theme_override_font_sizes/font_size = 30 +text = "RANSOM'S WAKE · DIAGNOSTIC DE PLONGÉE" + +[node name="StatusLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.7647, 0.8157, 0.8275, 1) +theme_override_font_sizes/font_size = 16 +text = "ÉTAT · INITIALISATION" + +[node name="Separator" type="HSeparator" parent="Margin/Rows"] +layout_mode = 2 + +[node name="TickLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "SESSION — · TICK —" + +[node name="BatteryLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "BATTERIE —" + +[node name="OxygenLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "OXYGÈNE —" + +[node name="HullLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "COQUE —" + +[node name="DepthLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "PROFONDEUR —" + +[node name="ReturnLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.9098, 0.7686, 0.4039, 1) +theme_override_font_sizes/font_size = 20 +text = "RETOUR —" + +[node name="AlertLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "ALERTE MAXIMALE —" + +[node name="EventLabel" type="Label" parent="Margin/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.7647, 0.8157, 0.8275, 1) +theme_override_font_sizes/font_size = 16 +text = "TERMINAISON — · ÉVÉNEMENTS —" + +[node name="MenuButton" type="Button" parent="Margin/Rows"] +unique_name_in_owner = true +custom_minimum_size = Vector2(260, 44) +layout_mode = 2 +size_flags_horizontal = 0 +text = "RETOUR AU MENU" + +[node name="Note" type="Label" parent="Margin/Rows"] +layout_mode = 2 +size_flags_vertical = 10 +theme_override_colors/font_color = Color(0.4824, 0.5804, 0.6078, 1) +text = "Écran technique temporaire · aucune règle de gameplay n'est calculée ici." +vertical_alignment = 2 diff --git a/game/presentation/main_menu/main_menu_screen.gd b/game/presentation/main_menu/main_menu_screen.gd new file mode 100644 index 0000000..27e4578 --- /dev/null +++ b/game/presentation/main_menu/main_menu_screen.gd @@ -0,0 +1,24 @@ +class_name MainMenuScreen +extends Control + +signal new_game_requested +signal quit_requested + +@onready var _continue_button: Button = %ContinueButton +@onready var _new_game_button: Button = %NewGameButton +@onready var _quit_button: Button = %QuitButton + + +func _ready() -> void: + _continue_button.disabled = true + _continue_button.tooltip_text = "Aucune sauvegarde disponible" + _new_game_button.pressed.connect(_on_new_game_pressed) + _quit_button.pressed.connect(_on_quit_pressed) + + +func _on_new_game_pressed() -> void: + new_game_requested.emit() + + +func _on_quit_pressed() -> void: + quit_requested.emit() diff --git a/game/presentation/main_menu/main_menu_screen.tscn b/game/presentation/main_menu/main_menu_screen.tscn new file mode 100644 index 0000000..aa49788 --- /dev/null +++ b/game/presentation/main_menu/main_menu_screen.tscn @@ -0,0 +1,85 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://game/presentation/main_menu/main_menu_screen.gd" id="1_menu"] + +[node name="MainMenuScreen" type="Control"] +unique_name_in_owner = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_menu") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.0078, 0.0353, 0.051, 1) +mouse_filter = 2 + +[node name="Center" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Menu" type="VBoxContainer" parent="Center"] +custom_minimum_size = Vector2(460, 0) +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="Title" type="Label" parent="Center/Menu"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.4549, 0.8902, 0.898, 1) +theme_override_font_sizes/font_size = 46 +text = "RANSOM'S WAKE" +horizontal_alignment = 1 + +[node name="Subtitle" type="Label" parent="Center/Menu"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.6588, 0.7333, 0.749, 1) +theme_override_font_sizes/font_size = 16 +text = "FONDATION DE LA VERTICALE" +horizontal_alignment = 1 + +[node name="Spacer" type="Control" parent="Center/Menu"] +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 + +[node name="ContinueButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +text = "CONTINUER · AUCUNE SAUVEGARDE" + +[node name="NewGameButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +text = "NOUVELLE PARTIE" + +[node name="SettingsButton" type="Button" parent="Center/Menu"] +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +disabled = true +tooltip_text = "Écran de réglages prévu dans une tranche ultérieure" +text = "RÉGLAGES · À VENIR" + +[node name="QuitButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +text = "QUITTER" + +[node name="TechnicalNote" type="Label" parent="Center/Menu"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.3882, 0.4824, 0.5059, 1) +text = "Build technique · Forward+ · simulation 60 Hz" +horizontal_alignment = 1 diff --git a/game/presentation/pause/pause_menu.gd b/game/presentation/pause/pause_menu.gd new file mode 100644 index 0000000..b69f288 --- /dev/null +++ b/game/presentation/pause/pause_menu.gd @@ -0,0 +1,18 @@ +class_name PauseMenu +extends Control + +signal resume_requested +signal controls_requested +signal abandon_requested +signal main_menu_requested +signal quit_requested + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + %ResumeButton.pressed.connect(func() -> void: resume_requested.emit()) + %ControlsButton.pressed.connect(func() -> void: controls_requested.emit()) + %AbandonButton.pressed.connect(func() -> void: abandon_requested.emit()) + %MainMenuButton.pressed.connect(func() -> void: main_menu_requested.emit()) + %QuitButton.pressed.connect(func() -> void: quit_requested.emit()) + %ResumeButton.grab_focus() diff --git a/game/presentation/pause/pause_menu.tscn b/game/presentation/pause/pause_menu.tscn new file mode 100644 index 0000000..23dca1d --- /dev/null +++ b/game/presentation/pause/pause_menu.tscn @@ -0,0 +1,82 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://game/presentation/pause/pause_menu.gd" id="1_pause"] + +[node name="PauseMenu" type="Control"] +process_mode = 3 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_pause") + +[node name="Dim" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.01, 0.025, 0.04, 0.88) + +[node name="Center" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Menu" type="VBoxContainer" parent="Center"] +custom_minimum_size = Vector2(430, 0) +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="Center/Menu"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.45, 0.89, 0.9, 1) +theme_override_font_sizes/font_size = 34 +text = "PLONGÉE EN PAUSE" +horizontal_alignment = 1 + +[node name="State" type="Label" parent="Center/Menu"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.65, 0.73, 0.75, 1) +text = "SIMULATION ET CONSOMMATIONS SUSPENDUES" +horizontal_alignment = 1 + +[node name="Spacer" type="Control" parent="Center/Menu"] +custom_minimum_size = Vector2(0, 18) +layout_mode = 2 + +[node name="ResumeButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "REPRENDRE" + +[node name="ControlsButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "RÉGLAGES · COMMANDES" + +[node name="AbandonButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "ABANDONNER LA PLONGÉE" + +[node name="MainMenuButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "RETOUR AU MENU PRINCIPAL" + +[node name="QuitButton" type="Button" parent="Center/Menu"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +text = "QUITTER LE JEU" diff --git a/game/presentation/settings/controls_settings_screen.gd b/game/presentation/settings/controls_settings_screen.gd new file mode 100644 index 0000000..cdaef83 --- /dev/null +++ b/game/presentation/settings/controls_settings_screen.gd @@ -0,0 +1,67 @@ +class_name ControlsSettingsScreen +extends Control + +signal rebind_requested(action_id: StringName) +signal restore_defaults_requested +signal conflict_resolution_requested(swap_bindings: bool) +signal back_requested + +const InputBindingSnapshot = preload( + "res://game/application/input/input_binding_snapshot.gd" +) +const RebindResult = preload("res://game/application/input/rebind_result.gd") + +@onready var _rows: VBoxContainer = %Rows +@onready var _status_label: Label = %StatusLabel +@onready var _conflict_panel: PanelContainer = %ConflictPanel +@onready var _conflict_label: Label = %ConflictLabel + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + %RestoreButton.pressed.connect(func() -> void: restore_defaults_requested.emit()) + %BackButton.pressed.connect(func() -> void: back_requested.emit()) + %SwapButton.pressed.connect(func() -> void: conflict_resolution_requested.emit(true)) + %CancelConflictButton.pressed.connect( + func() -> void: conflict_resolution_requested.emit(false) + ) + _conflict_panel.visible = false + + +func present_bindings(bindings: Array[InputBindingSnapshot]) -> void: + for child: Node in _rows.get_children(): + child.queue_free() + for binding: InputBindingSnapshot in bindings: + var row := HBoxContainer.new() + row.custom_minimum_size.y = 42.0 + var label := Label.new() + label.text = binding.label + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var button := Button.new() + button.custom_minimum_size.x = 180.0 + button.text = binding.binding_text + button.pressed.connect(_on_binding_button_pressed.bind(binding.action_id)) + row.add_child(label) + row.add_child(button) + _rows.add_child(row) + + +func _on_binding_button_pressed(action_id: StringName) -> void: + rebind_requested.emit(action_id) + + +func present_rebind_result(result: RebindResult) -> void: + _status_label.text = result.message + if result.conflicting_action_id.is_empty(): + _conflict_panel.visible = false + return + _conflict_label.text = ( + "La touche %s est déjà utilisée. Permuter les deux commandes ?" + % result.binding_text + ) + _conflict_panel.visible = true + + +func show_waiting_for_key() -> void: + _status_label.text = "Appuyez sur une touche · Échap pour annuler" + _conflict_panel.visible = false diff --git a/game/presentation/settings/controls_settings_screen.tscn b/game/presentation/settings/controls_settings_screen.tscn new file mode 100644 index 0000000..4fc7384 --- /dev/null +++ b/game/presentation/settings/controls_settings_screen.tscn @@ -0,0 +1,108 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://game/presentation/settings/controls_settings_screen.gd" id="1_controls"] + +[node name="ControlsSettingsScreen" type="Control"] +process_mode = 3 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_controls") + +[node name="Dim" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.01, 0.025, 0.04, 0.96) + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 190.0 +offset_top = 42.0 +offset_right = -190.0 +offset_bottom = -42.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Layout" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="Margin/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.45, 0.89, 0.9, 1) +theme_override_font_sizes/font_size = 32 +text = "RÉGLAGES · COMMANDES" + +[node name="Description" type="Label" parent="Margin/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.65, 0.73, 0.75, 1) +text = "Le clavier oriente le sous-marin. La souris déplace uniquement le regard." + +[node name="Rows" type="VBoxContainer" parent="Margin/Layout"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 5 + +[node name="StatusLabel" type="Label" parent="Margin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 30) +layout_mode = 2 +theme_override_colors/font_color = Color(0.91, 0.77, 0.4, 1) +text = "Sélectionnez une commande à remapper." + +[node name="ConflictPanel" type="PanelContainer" parent="Margin/Layout"] +unique_name_in_owner = true +visible = false +layout_mode = 2 + +[node name="ConflictLayout" type="VBoxContainer" parent="Margin/Layout/ConflictPanel"] +layout_mode = 2 + +[node name="ConflictLabel" type="Label" parent="Margin/Layout/ConflictPanel/ConflictLayout"] +unique_name_in_owner = true +layout_mode = 2 +text = "Cette touche est déjà utilisée." +horizontal_alignment = 1 + +[node name="Actions" type="HBoxContainer" parent="Margin/Layout/ConflictPanel/ConflictLayout"] +layout_mode = 2 +alignment = 1 + +[node name="CancelConflictButton" type="Button" parent="Margin/Layout/ConflictPanel/ConflictLayout/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(150, 42) +layout_mode = 2 +text = "ANNULER" + +[node name="SwapButton" type="Button" parent="Margin/Layout/ConflictPanel/ConflictLayout/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(150, 42) +layout_mode = 2 +text = "PERMUTER" + +[node name="Footer" type="HBoxContainer" parent="Margin/Layout"] +layout_mode = 2 +alignment = 2 + +[node name="RestoreButton" type="Button" parent="Margin/Layout/Footer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(220, 44) +layout_mode = 2 +text = "VALEURS PAR DÉFAUT" + +[node name="BackButton" type="Button" parent="Margin/Layout/Footer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(160, 44) +layout_mode = 2 +text = "RETOUR" diff --git a/game/world/sites/vehicle_test_environment.gd b/game/world/sites/vehicle_test_environment.gd new file mode 100644 index 0000000..11021da --- /dev/null +++ b/game/world/sites/vehicle_test_environment.gd @@ -0,0 +1,33 @@ +class_name VehicleTestEnvironment +extends Node3D + + +func _ready() -> void: + var particles := GPUParticles3D.new() + particles.name = "SuspendedParticles" + particles.position = Vector3(0.0, -34.0, -42.0) + particles.amount = 360 + particles.lifetime = 14.0 + particles.preprocess = 14.0 + particles.visibility_aabb = AABB( + Vector3(-50.0, -35.0, -78.0), + Vector3(100.0, 70.0, 156.0) + ) + + var motion := ParticleProcessMaterial.new() + motion.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + motion.emission_box_extents = Vector3(45.0, 28.0, 70.0) + motion.direction = Vector3(0.1, 0.04, 0.0) + motion.spread = 180.0 + motion.initial_velocity_min = 0.02 + motion.initial_velocity_max = 0.09 + motion.gravity = Vector3.ZERO + motion.scale_min = 0.025 + motion.scale_max = 0.08 + particles.process_material = motion + + var particle_mesh := SphereMesh.new() + particle_mesh.radius = 0.035 + particle_mesh.height = 0.07 + particles.draw_pass_1 = particle_mesh + add_child(particles) diff --git a/game/world/sites/vehicle_test_environment.tscn b/game/world/sites/vehicle_test_environment.tscn new file mode 100644 index 0000000..6cdace5 --- /dev/null +++ b/game/world/sites/vehicle_test_environment.tscn @@ -0,0 +1,132 @@ +[gd_scene load_steps=12 format=3] + +[ext_resource type="Script" path="res://game/world/sites/vehicle_test_environment.gd" id="1_environment"] + +[sub_resource type="Environment" id="EnvironmentUnderwater"] +background_mode = 1 +background_color = Color(0.005, 0.045, 0.065, 1) +ambient_light_source = 3 +ambient_light_color = Color(0.12, 0.32, 0.37, 1) +ambient_light_energy = 0.42 +fog_enabled = true +fog_light_color = Color(0.07, 0.25, 0.3, 1) +fog_light_energy = 0.48 +fog_density = 0.014 +fog_height = -4.0 +fog_height_density = 0.025 +volumetric_fog_enabled = true +volumetric_fog_density = 0.025 +volumetric_fog_length = 72.0 + +[sub_resource type="StandardMaterial3D" id="RockMaterial"] +albedo_color = Color(0.075, 0.19, 0.19, 1) +roughness = 0.92 + +[sub_resource type="StandardMaterial3D" id="RockMaterialLight"] +albedo_color = Color(0.105, 0.25, 0.24, 1) +roughness = 0.88 + +[sub_resource type="BoxMesh" id="SeafloorMesh"] +size = Vector3(220, 8, 250) + +[sub_resource type="BoxShape3D" id="SeafloorShape"] +size = Vector3(220, 8, 250) + +[sub_resource type="BoxMesh" id="RockMeshA"] +size = Vector3(12, 30, 14) + +[sub_resource type="BoxShape3D" id="RockShapeA"] +size = Vector3(12, 30, 14) + +[sub_resource type="BoxMesh" id="RockMeshB"] +size = Vector3(18, 42, 16) + +[sub_resource type="BoxShape3D" id="RockShapeB"] +size = Vector3(18, 42, 16) + +[sub_resource type="SphereMesh" id="BeaconMesh"] +radius = 0.55 +height = 1.1 + +[sub_resource type="StandardMaterial3D" id="BeaconMaterial"] +shading_mode = 0 +albedo_color = Color(0.2, 0.78, 0.9, 1) +emission_enabled = true +emission = Color(0.2, 0.78, 0.9, 1) +emission_energy_multiplier = 5.0 + +[node name="VehicleTestEnvironment" type="Node3D"] +script = ExtResource("1_environment") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("EnvironmentUnderwater") + +[node name="AmbientDirection" type="DirectionalLight3D" parent="."] +rotation_degrees = Vector3(-52, -24, 0) +light_color = Color(0.39, 0.65, 0.7, 1) +light_energy = 0.38 +shadow_enabled = true + +[node name="Seafloor" type="StaticBody3D" parent="."] +position = Vector3(0, -84, -58) + +[node name="Mesh" type="MeshInstance3D" parent="Seafloor"] +mesh = SubResource("SeafloorMesh") +material_override = SubResource("RockMaterial") + +[node name="Collision" type="CollisionShape3D" parent="Seafloor"] +shape = SubResource("SeafloorShape") + +[node name="RockNearLeft" type="StaticBody3D" parent="."] +position = Vector3(-10, -18, -18) +rotation_degrees = Vector3(0, -14, 8) + +[node name="Mesh" type="MeshInstance3D" parent="RockNearLeft"] +mesh = SubResource("RockMeshA") +material_override = SubResource("RockMaterialLight") + +[node name="Collision" type="CollisionShape3D" parent="RockNearLeft"] +shape = SubResource("RockShapeA") + +[node name="RockNearRight" type="StaticBody3D" parent="."] +position = Vector3(12, -22, -32) +rotation_degrees = Vector3(0, 18, -5) + +[node name="Mesh" type="MeshInstance3D" parent="RockNearRight"] +mesh = SubResource("RockMeshB") +material_override = SubResource("RockMaterial") + +[node name="Collision" type="CollisionShape3D" parent="RockNearRight"] +shape = SubResource("RockShapeB") + +[node name="RockFarLeft" type="StaticBody3D" parent="."] +position = Vector3(-18, -48, -68) +rotation_degrees = Vector3(0, 9, 4) + +[node name="Mesh" type="MeshInstance3D" parent="RockFarLeft"] +mesh = SubResource("RockMeshA") +material_override = SubResource("RockMaterialLight") + +[node name="Collision" type="CollisionShape3D" parent="RockFarLeft"] +shape = SubResource("RockShapeA") + +[node name="RockFarRight" type="StaticBody3D" parent="."] +position = Vector3(20, -58, -94) +rotation_degrees = Vector3(0, -11, -7) + +[node name="Mesh" type="MeshInstance3D" parent="RockFarRight"] +mesh = SubResource("RockMeshB") +material_override = SubResource("RockMaterial") + +[node name="Collision" type="CollisionShape3D" parent="RockFarRight"] +shape = SubResource("RockShapeB") + +[node name="NavigationBeacon" type="MeshInstance3D" parent="."] +position = Vector3(0, -15, -22) +mesh = SubResource("BeaconMesh") +material_override = SubResource("BeaconMaterial") + +[node name="BeaconLight" type="OmniLight3D" parent="NavigationBeacon"] +omni_range = 12.0 +light_color = Color(0.2, 0.78, 0.9, 1) +light_energy = 4.0 diff --git a/game/world/submarine/submarine_actor.gd b/game/world/submarine/submarine_actor.gd new file mode 100644 index 0000000..b738565 --- /dev/null +++ b/game/world/submarine/submarine_actor.gd @@ -0,0 +1,139 @@ +class_name SubmarineActor +extends CharacterBody3D + +signal collision_sampled(sample: VehicleCollisionSample) + +const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd") +const VehicleCollisionSample = preload( + "res://game/application/vehicle/vehicle_collision_sample.gd" +) + +@export var cruise_speed_mps := 6.0 +@export var high_thrust_speed_mps := 9.5 +@export var acceleration_mps2 := 2.0 +@export var braking_mps2 := 1.35 +@export var vertical_speed_mps := 3.5 +@export var vertical_acceleration_mps2 := 1.8 +@export var lateral_grip_mps2 := 2.2 +@export var maximum_yaw_speed_degrees := 45.0 +@export var yaw_acceleration_degrees := 30.0 +@export var yaw_braking_degrees := 24.0 +@export var maximum_depth_m := 500.0 + +var _command: VehicleCommand = VehicleCommand.neutral(0) +var _yaw_velocity_rps := 0.0 +var _collision_sequence := 0 +var _last_longitudinal_effort := 0.0 +var _last_steering_effort := 0.0 +var _last_vertical_effort := 0.0 +var _last_high_thrust_active := false + + +func apply_vehicle_command(command: VehicleCommand) -> void: + _command = command + + +func current_depth_m() -> float: + return maxf(-global_position.y, 0.0) + + +func current_yaw_velocity_rps() -> float: + return _yaw_velocity_rps + + +func actual_longitudinal_effort() -> float: + return _last_longitudinal_effort + + +func actual_steering_effort() -> float: + return _last_steering_effort + + +func actual_vertical_effort() -> float: + return _last_vertical_effort + + +func high_thrust_active() -> bool: + return _last_high_thrust_active + + +func _physics_process(delta: float) -> void: + _update_yaw(delta) + _update_velocity(delta) + var velocity_before_move := velocity + move_and_slide() + _keep_inside_vertical_bounds() + _publish_collisions(velocity_before_move) + + +func _update_yaw(delta: float) -> void: + var maximum_yaw_speed := deg_to_rad(maximum_yaw_speed_degrees) + var target_yaw_velocity := -_command.steering * maximum_yaw_speed + var yaw_change_degrees := ( + yaw_acceleration_degrees + if not is_zero_approx(_command.steering) + else yaw_braking_degrees + ) + _yaw_velocity_rps = move_toward( + _yaw_velocity_rps, + target_yaw_velocity, + deg_to_rad(yaw_change_degrees) * delta + ) + rotation.y = wrapf(rotation.y + _yaw_velocity_rps * delta, -PI, PI) + _last_steering_effort = _command.steering + + +func _update_velocity(delta: float) -> void: + var local_velocity := global_basis.inverse() * velocity + var requested_speed := ( + high_thrust_speed_mps if _command.high_thrust_active else cruise_speed_mps + ) + var target_forward_speed := _command.longitudinal * requested_speed + var current_forward_speed := -local_velocity.z + var forward_change_rate := ( + acceleration_mps2 + if not is_zero_approx(target_forward_speed) + else braking_mps2 + ) + current_forward_speed = move_toward( + current_forward_speed, + target_forward_speed, + forward_change_rate * delta + ) + local_velocity.z = -current_forward_speed + + var target_vertical_speed := _command.vertical * vertical_speed_mps + local_velocity.y = move_toward( + local_velocity.y, + target_vertical_speed, + vertical_acceleration_mps2 * delta + ) + local_velocity.x = move_toward(local_velocity.x, 0.0, lateral_grip_mps2 * delta) + velocity = global_basis * local_velocity + _last_longitudinal_effort = _command.longitudinal + _last_vertical_effort = _command.vertical + _last_high_thrust_active = _command.high_thrust_active + + +func _keep_inside_vertical_bounds() -> void: + if global_position.y > -2.0: + global_position.y = -2.0 + velocity.y = minf(velocity.y, 0.0) + if global_position.y < -maximum_depth_m: + global_position.y = -maximum_depth_m + velocity.y = maxf(velocity.y, 0.0) + + +func _publish_collisions(previous_velocity: Vector3) -> void: + for collision_index in get_slide_collision_count(): + var collision := get_slide_collision(collision_index) + var impact_speed := absf(previous_velocity.dot(collision.get_normal())) + if impact_speed <= 0.01: + continue + var sample_id := StringName( + "collision-%d-%d" % [_collision_sequence, collision_index] + ) + collision_sampled.emit( + VehicleCollisionSample.new(sample_id, _collision_sequence, impact_speed) + ) + _collision_sequence += 1 diff --git a/game/world/submarine/submarine_actor.tscn b/game/world/submarine/submarine_actor.tscn new file mode 100644 index 0000000..50a0808 --- /dev/null +++ b/game/world/submarine/submarine_actor.tscn @@ -0,0 +1,75 @@ +[gd_scene load_steps=8 format=3] + +[ext_resource type="Script" path="res://game/world/submarine/submarine_actor.gd" id="1_actor"] + +[sub_resource type="CapsuleShape3D" id="CapsuleCollision"] +radius = 0.72 +height = 3.8 + +[sub_resource type="CapsuleMesh" id="CapsuleHull"] +radius = 0.72 +height = 3.8 + +[sub_resource type="StandardMaterial3D" id="HullMaterial"] +albedo_color = Color(0.64, 0.48, 0.17, 1) +metallic = 0.72 +roughness = 0.34 + +[sub_resource type="SphereMesh" id="CanopyMesh"] +radius = 0.58 +height = 1.05 + +[sub_resource type="StandardMaterial3D" id="CanopyMaterial"] +transparency = 1 +albedo_color = Color(0.04, 0.18, 0.25, 0.72) +metallic = 0.18 +roughness = 0.16 + +[sub_resource type="BoxMesh" id="FinMesh"] +size = Vector3(1.8, 0.12, 1.2) + +[node name="SubmarineActor" type="CharacterBody3D"] +script = ExtResource("1_actor") + +[node name="CollisionShape" type="CollisionShape3D" parent="."] +rotation = Vector3(1.5708, 0, 0) +shape = SubResource("CapsuleCollision") + +[node name="Hull" type="MeshInstance3D" parent="."] +rotation = Vector3(1.5708, 0, 0) +mesh = SubResource("CapsuleHull") +material_override = SubResource("HullMaterial") + +[node name="Canopy" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.36, -0.45) +scale = Vector3(0.85, 0.55, 1) +mesh = SubResource("CanopyMesh") +material_override = SubResource("CanopyMaterial") + +[node name="LeftFin" type="MeshInstance3D" parent="."] +position = Vector3(-0.92, 0, 0.85) +mesh = SubResource("FinMesh") +material_override = SubResource("HullMaterial") + +[node name="RightFin" type="MeshInstance3D" parent="."] +position = Vector3(0.92, 0, 0.85) +mesh = SubResource("FinMesh") +material_override = SubResource("HullMaterial") + +[node name="LeftProjector" type="SpotLight3D" parent="."] +position = Vector3(-0.42, 0.05, -2.02) +rotation_degrees = Vector3(-3, 6, 0) +light_color = Color(0.72, 0.91, 1, 1) +light_energy = 22.0 +shadow_enabled = true +spot_range = 58.0 +spot_angle = 24.0 + +[node name="RightProjector" type="SpotLight3D" parent="."] +position = Vector3(0.42, 0.05, -2.02) +rotation_degrees = Vector3(-3, -6, 0) +light_color = Color(0.72, 0.91, 1, 1) +light_energy = 22.0 +shadow_enabled = true +spot_range = 58.0 +spot_angle = 24.0 diff --git a/game/world/submarine/submarine_motion_adapter.gd b/game/world/submarine/submarine_motion_adapter.gd new file mode 100644 index 0000000..ca916d3 --- /dev/null +++ b/game/world/submarine/submarine_motion_adapter.gd @@ -0,0 +1,14 @@ +class_name SubmarineMotionAdapter +extends "res://game/application/interfaces/vehicle_motion_port.gd" + +const SubmarineActor = preload("res://game/world/submarine/submarine_actor.gd") + +var _actor: SubmarineActor + + +func _init(actor: SubmarineActor) -> void: + _actor = actor + + +func apply_command(command: VehicleCommand) -> void: + _actor.apply_vehicle_command(command) diff --git a/game/world/submarine/submarine_telemetry_adapter.gd b/game/world/submarine/submarine_telemetry_adapter.gd new file mode 100644 index 0000000..69a4faf --- /dev/null +++ b/game/world/submarine/submarine_telemetry_adapter.gd @@ -0,0 +1,27 @@ +class_name SubmarineTelemetryAdapter +extends RefCounted + +const VehicleTelemetry = preload("res://game/application/dive/vehicle_telemetry.gd") +const SubmarineActor = preload("res://game/world/submarine/submarine_actor.gd") + +var _actor: SubmarineActor + + +func _init(actor: SubmarineActor) -> void: + _actor = actor + + +func sample(session_id: StringName, sequence: int) -> VehicleTelemetry: + return VehicleTelemetry.new( + session_id, + sequence, + _actor.global_position, + _actor.global_transform.basis.get_rotation_quaternion(), + _actor.velocity, + Vector3(0.0, _actor.current_yaw_velocity_rps(), 0.0), + _actor.current_depth_m(), + _actor.actual_longitudinal_effort(), + _actor.actual_steering_effort(), + _actor.actual_vertical_effort(), + _actor.high_thrust_active() + ) diff --git a/project.godot b/project.godot index 92b69a3..9c631e7 100644 --- a/project.godot +++ b/project.godot @@ -1,13 +1,17 @@ -; Ransom's Wake - POC B -; Generated for Godot 4.7.1. +; Ransom's Wake - production vertical slice +; Generated for Godot 4.7.2. 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") +config/name="Ransom's Wake" +run/main_scene="res://game/composition/vertical_slice/vertical_slice_root.tscn" +config/features=PackedStringArray("4.7", "Forward Plus") + +[physics] + +common/physics_ticks_per_second=60 [display] @@ -19,6 +23,4 @@ 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 diff --git a/scripts/check_architecture.sh b/scripts/check_architecture.sh index 94d0038..6f2c38d 100755 --- a/scripts/check_architecture.sh +++ b/scripts/check_architecture.sh @@ -16,6 +16,24 @@ fail_with_matches() { fi } +if ! rg -q 'config/features=.*"Forward Plus"' project.godot; then + fail_with_matches \ + "production project must declare Forward Plus" \ + "project.godot does not declare the Forward Plus feature" +fi + +renderer_overrides="$(rg -n '^renderer/rendering_method' project.godot || true)" +fail_with_matches "production project overrides its Forward+ renderer" "$renderer_overrides" + +autoload_entries="$({ + awk ' + /^\[autoload\]$/ { in_autoload = 1; next } + /^\[/ { in_autoload = 0 } + in_autoload && /^[^;[:space:]][^=]*=/ { print NR ":" $0 } + ' project.godot +} || true)" +fail_with_matches "custom autoloads require an ADR" "$autoload_entries" + source_directories="src tests" if [ -d game ]; then source_directories="$source_directories game" diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index 6b1cbd9..9d23069 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -4,7 +4,7 @@ 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" +REQUIRED_GODOT_VERSION="4.7.2" QUALITY_TEMP_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/ransoms-wake-quality.XXXXXX")" cleanup_quality_directory() { @@ -56,7 +56,7 @@ printf 'Quality gate: resource loading\n' 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' +printf 'Quality gate: POC unit tests\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/unit.log" \ --path . \ @@ -64,6 +64,34 @@ printf 'Quality gate: unit tests\n' 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/unit-console.log" check_godot_log "unit tests" "$QUALITY_TEMP_DIRECTORY/unit-console.log" +printf 'Quality gate: production dive unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/dive-unit.log" \ + --path . \ + --script tests/unit/run_dive_foundation_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/dive-unit-console.log" +check_godot_log "production dive unit tests" "$QUALITY_TEMP_DIRECTORY/dive-unit-console.log" + +printf 'Quality gate: production vehicle application unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/vehicle-application-unit.log" \ + --path . \ + --script tests/unit/run_vehicle_application_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/vehicle-application-unit-console.log" +check_godot_log \ + "production vehicle application unit tests" \ + "$QUALITY_TEMP_DIRECTORY/vehicle-application-unit-console.log" + +printf 'Quality gate: production foundation integration\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \ + --path . \ + --script tests/integration/run_vertical_slice_foundation.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log" +check_godot_log \ + "production foundation integration" \ + "$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log" + printf 'Quality gate: POC B integration\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \ diff --git a/tests/README.md b/tests/README.md index 0f9acdd..16c2f46 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,3 +8,13 @@ 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. + +Suites de production actuelles : + +- `unit/run_dive_foundation_tests.gd` : règles résolues, ticks, ressources, + pression, collisions, alertes et états terminaux ; +- `unit/run_vehicle_application_tests.gd` : validation et séquençage des + commandes de pilotage et de caméra ; +- `integration/run_vertical_slice_foundation.gd` : composition, mouvement + inertiel, caméra indépendante, cockpit 3D fixe, instruments physiques, couche + d'alerte, pause des ticks et remappage avec conflit. diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd new file mode 100644 index 0000000..2f0f3c9 --- /dev/null +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -0,0 +1,212 @@ +extends SceneTree + +const VerticalSliceRoot = preload( + "res://game/composition/vertical_slice/vertical_slice_root.gd" +) +const MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd") +const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd") +const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd") +const ControlsSettingsScreen = preload( + "res://game/presentation/settings/controls_settings_screen.gd" +) +const ProductionInputAdapter = preload( + "res://game/infrastructure/input/production_input_adapter.gd" +) + +const TEST_COUNT := 40 + +var _failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + change_scene_to_file( + "res://game/composition/vertical_slice/vertical_slice_root.tscn" + ) + await process_frame + await physics_frame + + var composition := current_scene as VerticalSliceRoot + _expect(composition != null, "production composition loads") + if composition != null: + await _test_vehicle_slice(composition) + + _expect( + int(ProjectSettings.get_setting("physics/common/physics_ticks_per_second")) == 60, + "project physics runs at 60 Hz" + ) + var project_features: PackedStringArray = ProjectSettings.get_setting( + "application/config/features" + ) + _expect("Forward Plus" in project_features, "production project uses Forward+") + + var exit_code := 0 + if _failures.is_empty(): + print("Vertical slice foundation integration: %d passed, 0 failed." % TEST_COUNT) + else: + for failure: String in _failures: + push_error(failure) + print("Vertical slice foundation integration: %d failed." % _failures.size()) + exit_code = 1 + + Input.action_release(ProductionInputAdapter.ACTION_FORWARD) + Input.action_release(ProductionInputAdapter.ACTION_RIGHT) + if current_scene != null: + current_scene.queue_free() + await process_frame + await process_frame + quit(exit_code) + + +func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: + var menu := composition.active_screen() as MainMenuScreen + _expect(menu != null, "composition starts on main menu") + if menu == null: + return + var continue_button := menu.get_node("%ContinueButton") as Button + _expect(continue_button.disabled, "continue is disabled without save") + var new_game_button := menu.get_node("%NewGameButton") as Button + new_game_button.pressed.emit() + await process_frame + await physics_frame + + var actor = composition.submarine_actor() + var camera = composition.camera_controller() + var instruments = composition.cockpit_instrument_panel() + var adapter := composition.input_adapter() as ProductionInputAdapter + var hud := composition.active_screen() as CockpitHud + _expect(composition.active_world() != null, "vehicle test world is assembled") + _expect(actor != null, "production submarine actor is assembled") + _expect(camera != null, "cockpit camera is attached to the submarine") + _expect(instruments != null, "physical cockpit instruments are assembled") + _expect(hud != null, "critical HUD overlay is assembled") + _expect(composition.latest_update() != null, "dive session starts with a typed update") + if actor == null or camera == null or instruments == null or adapter == null or hud == null: + return + + _expect(instruments.get_node("%WindowFrame") is MeshInstance3D, "cockpit has a physical porthole frame") + _expect(instruments.get_node("%Dashboard") is Node3D, "cockpit has a physical dashboard") + _expect(instruments.get_node("%SonarScreen") is MeshInstance3D, "cockpit reserves a physical sonar display") + _expect("kWh" in instruments.resources_text() and "kW" in instruments.resources_text(), "battery instrument has exact units") + _expect("L/min" in instruments.resources_text(), "oxygen instrument has volume and consumption") + _expect("bar" in instruments.integrity_text(), "hull instrument has current and certified pressure") + _expect(not hud.alert_overlay_visible(), "normal telemetry stays on physical instruments only") + + var start_position: Vector3 = actor.global_position + var starting_battery: float = composition.latest_update().snapshot.battery.remaining_kwh + Input.action_press(ProductionInputAdapter.ACTION_FORWARD) + for frame in 35: + await physics_frame + Input.action_release(ProductionInputAdapter.ACTION_FORWARD) + _expect(actor.global_position.distance_to(start_position) > 0.1, "keyboard propulsion moves the submarine") + _expect(actor.velocity.length() > 0.1, "propulsion builds inertial velocity") + _expect( + composition.latest_update().snapshot.battery.remaining_kwh < starting_battery, + "propulsion effort drains the domain battery" + ) + + var start_heading: float = actor.rotation.y + Input.action_press(ProductionInputAdapter.ACTION_RIGHT) + for frame in 24: + await physics_frame + Input.action_release(ProductionInputAdapter.ACTION_RIGHT) + _expect(absf(actor.rotation.y - start_heading) > 0.01, "keyboard steering changes submarine heading") + _expect(absf(actor.current_yaw_velocity_rps()) > 0.01, "yaw keeps angular inertia after release") + for frame in 90: + await physics_frame + + var settled_heading: float = actor.rotation.y + var initial_look_yaw: float = camera.look_yaw_radians() + var cockpit_transform: Transform3D = instruments.global_transform + var mouse_motion := InputEventMouseMotion.new() + mouse_motion.relative = Vector2(40.0, -20.0) + adapter._unhandled_input(mouse_motion) + await physics_frame + _expect(absf(camera.look_yaw_radians() - initial_look_yaw) > 0.01, "mouse rotates the cockpit camera") + _expect(absf(actor.rotation.y - settled_heading) < 0.001, "mouse does not steer the submarine") + _expect(instruments.global_transform.is_equal_approx(cockpit_transform), "cockpit remains fixed while the player looks around") + + var paused_sequence: int = composition.latest_update().snapshot.tick_sequence + adapter.pause_requested.emit() + await process_frame + _expect(paused, "pause menu suspends the scene tree") + var pause_menu := composition.active_overlay() as PauseMenu + _expect(pause_menu != null, "pause overlay is displayed") + await process_frame + await process_frame + _expect( + composition.latest_update().snapshot.tick_sequence == paused_sequence, + "dive ticks are frozen while paused" + ) + + var controls_button := pause_menu.get_node("%ControlsButton") as Button + controls_button.pressed.emit() + await process_frame + var controls := composition.active_overlay() as ControlsSettingsScreen + _expect(controls != null, "controls settings open over the paused dive") + _expect(adapter.binding_snapshots().size() == 9, "all gameplay keyboard bindings are exposed") + + var rebind_results: Array = [] + adapter.rebind_result_available.connect( + func(result) -> void: rebind_results.append(result) + ) + adapter.begin_rebind(ProductionInputAdapter.ACTION_FORWARD) + adapter._unhandled_input(_pressed_key(KEY_UP)) + _expect(not rebind_results.is_empty() and rebind_results.back().accepted, "free key remapping is accepted") + _expect( + _binding_for(adapter, ProductionInputAdapter.ACTION_FORWARD) == "Up", + "binding snapshot reflects the remapped key" + ) + + adapter.begin_rebind(ProductionInputAdapter.ACTION_BACKWARD) + adapter._unhandled_input(_pressed_key(KEY_UP)) + _expect( + not rebind_results.back().accepted + and rebind_results.back().conflicting_action_id == ProductionInputAdapter.ACTION_FORWARD, + "binding conflict identifies the existing action" + ) + var swap_result = adapter.resolve_pending_conflict(true) + _expect(swap_result.accepted, "conflicting bindings can be swapped") + _expect( + _binding_for(adapter, ProductionInputAdapter.ACTION_BACKWARD) == "Up", + "requested action receives the conflicting key" + ) + adapter.restore_default_bindings() + _expect( + _binding_for(adapter, ProductionInputAdapter.ACTION_FORWARD) == "W", + "default bindings can be restored" + ) + + adapter.pause_requested.emit() + await process_frame + _expect(composition.active_overlay() is PauseMenu, "escape from settings returns to pause") + adapter.pause_requested.emit() + await process_frame + _expect(not paused, "second escape resumes the dive") + await physics_frame + _expect( + composition.latest_update().snapshot.tick_sequence > paused_sequence, + "fixed dive ticks resume after pause" + ) + + +func _pressed_key(keycode: Key) -> InputEventKey: + var event := InputEventKey.new() + event.physical_keycode = keycode + event.pressed = true + return event + + +func _binding_for(adapter: ProductionInputAdapter, action_id: StringName) -> String: + for binding in adapter.binding_snapshots(): + if binding.action_id == action_id: + return binding.binding_text + return "" + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tests/unit/run_dive_foundation_tests.gd b/tests/unit/run_dive_foundation_tests.gd new file mode 100644 index 0000000..146676c --- /dev/null +++ b/tests/unit/run_dive_foundation_tests.gd @@ -0,0 +1,335 @@ +extends SceneTree + +const DiveDifficultyProfile = preload("res://game/domain/rules/dive_difficulty_profile.gd") +const DiveUpgradeEffects = preload("res://game/domain/rules/dive_upgrade_effects.gd") +const DiveAccessibilityAdjustments = preload( + "res://game/domain/rules/dive_accessibility_adjustments.gd" +) +const DiveRulesResolver = preload("res://game/domain/rules/dive_rules_resolver.gd") +const DiveSession = preload("res://game/domain/dive/dive_session.gd") +const DiveTick = preload("res://game/domain/dive/dive_tick.gd") +const DiveTelemetrySample = preload("res://game/domain/dive/dive_telemetry_sample.gd") +const CollisionObservation = preload("res://game/domain/dive/collision_observation.gd") +const ToolPowerDemand = preload("res://game/domain/dive/tool_power_demand.gd") +const EjectionIntent = preload("res://game/domain/dive/ejection_intent.gd") +const ReturnRouteSample = preload("res://game/domain/dive/return_route_sample.gd") +const DiveStep = preload("res://game/domain/dive/dive_step.gd") +const DiveAlertLevel = preload("res://game/domain/dive/dive_alert_level.gd") +const DiveTerminalReason = preload("res://game/domain/dive/dive_terminal_reason.gd") +const DiveEvent = preload("res://game/domain/dive/dive_event.gd") +const ReturnEstimate = preload("res://game/domain/dive/return_estimate.gd") + +const SESSION_ID := &"unit-dive" +const TEST_COUNT := 21 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_rules_resolve_difficulty_upgrades_and_accessibility() + _test_initial_snapshot_has_explicit_units() + _test_sixty_ticks_drain_one_second_of_oxygen() + _test_idle_does_not_drain_battery() + _test_propulsion_and_tool_power_are_summed() + _test_certified_depth_is_safe() + _test_excess_depth_damages_hull() + _test_crush_grace_delays_catastrophic_damage() + _test_collision_is_idempotent() + _test_collisions_are_ordered_and_cooled_down() + _test_invalid_sequence_is_rejected_without_mutation() + _test_oversized_tick_is_rejected() + _test_alert_transition_is_emitted_once() + _test_empty_battery_is_not_terminal() + _test_ejection_hold_resets_on_release() + _test_ejection_terminates_after_three_seconds() + _test_confirmed_ejection_terminates_immediately() + _test_oxygen_depletion_terminates_dive() + _test_hull_destruction_terminates_dive() + _test_return_estimate_uses_real_kwh() + _test_session_has_no_internal_clock() + + if _failures.is_empty(): + print("Dive foundation unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Dive foundation unit tests: %d failed." % _failures.size()) + quit(1) + + +func _test_rules_resolve_difficulty_upgrades_and_accessibility() -> void: + var resolver := DiveRulesResolver.new() + var expert = resolver.resolve(DiveDifficultyProfile.expert()) + _expect_near(expert.oxygen_consumption_l_per_min, 36.0, 0.001, "expert oxygen") + _expect_near(expert.longitudinal_power_kw, 2070.0, 0.001, "expert battery") + _expect_near(expert.pressure_damage_multiplier, 1.35, 0.001, "expert pressure") + _expect_near(expert.crush_grace_seconds, 1.125, 0.001, "expert crush grace") + + var upgraded = resolver.resolve( + DiveDifficultyProfile.standard(), + DiveUpgradeEffects.new(25.0, 120.0, 20.0, 40.0), + DiveAccessibilityAdjustments.new(0.5, 0.75, 0.5) + ) + _expect_near(upgraded.battery_capacity_kwh, 125.0, 0.001, "upgrade battery capacity") + _expect_near(upgraded.oxygen_capacity_l, 600.0, 0.001, "upgrade oxygen capacity") + _expect_near(upgraded.hull_max_integrity, 120.0, 0.001, "upgrade hull capacity") + _expect_near(upgraded.certified_depth_m, 100.0, 0.001, "upgrade certified depth") + _expect_near(upgraded.oxygen_consumption_l_per_min, 15.0, 0.001, "accessible oxygen") + _expect_near(upgraded.longitudinal_power_kw, 1350.0, 0.001, "accessible battery") + + +func _test_initial_snapshot_has_explicit_units() -> void: + var snapshot = _session().current_snapshot() + _expect_near(snapshot.battery.remaining_kwh, 100.0, 0.001, "initial battery kWh") + _expect_near(snapshot.oxygen.remaining_l, 480.0, 0.001, "initial oxygen litres") + _expect_near(snapshot.oxygen.consumption_l_per_min, 30.0, 0.001, "oxygen L/min") + _expect_near(snapshot.hull.certified_pressure_bar, 7.0, 0.001, "certified pressure") + _expect_near(snapshot.depth.certified_m, 60.0, 0.001, "certified depth") + + +func _test_sixty_ticks_drain_one_second_of_oxygen() -> void: + var session := _session() + for sequence in 60: + var update = session.advance(_step(sequence, 1.0 / 60.0)) + _expect(update.accepted, "60 Hz oxygen step %d accepted" % sequence) + _expect_near(session.current_snapshot().oxygen.remaining_l, 479.5, 0.001, "one second oxygen") + _expect(session.current_snapshot().tick_sequence == 59, "last 60 Hz sequence is 59") + + +func _test_idle_does_not_drain_battery() -> void: + var session := _session() + var update = session.advance(_step(0, 0.1)) + _expect(update.accepted, "idle tick accepted") + _expect_near(update.snapshot.battery.remaining_kwh, 100.0, 0.001, "idle battery") + _expect_near(update.snapshot.battery.current_power_kw, 0.0, 0.001, "idle power") + + +func _test_propulsion_and_tool_power_are_summed() -> void: + var session := _session() + var tools: Array[ToolPowerDemand] = [ToolPowerDemand.new(&"cutter", 30.0)] + var update = session.advance(_step(0, 1.0 / 60.0, 24.0, 0.5, [], tools)) + _expect(update.accepted, "powered tick accepted") + _expect_near(update.snapshot.battery.current_power_kw, 930.0, 0.001, "combined power") + _expect_near( + update.snapshot.battery.remaining_kwh, + 100.0 - 930.0 / 3600.0 / 60.0, + 0.00001, + "combined energy drain" + ) + + +func _test_certified_depth_is_safe() -> void: + var session := _session() + var update = session.advance(_step(0, 0.1, 60.0)) + _expect_near(update.snapshot.hull.current_integrity, 100.0, 0.001, "certified hull") + _expect_near(update.snapshot.hull.exterior_pressure_bar, 7.0, 0.001, "current pressure") + + +func _test_excess_depth_damages_hull() -> void: + var session := _session() + var update = session.advance(_step(0, 0.1, 70.0)) + _expect(update.snapshot.hull.current_integrity < 100.0, "excess depth damages hull") + _expect(_event_count(update.events, DiveEvent.Kind.HULL_DAMAGED) == 1, "pressure damage event") + + +func _test_crush_grace_delays_catastrophic_damage() -> void: + var session := _session() + var update + for sequence in 14: + update = session.advance(_step(sequence, 0.1, 80.0)) + _expect_near(update.snapshot.hull.current_integrity, 100.0, 0.001, "crush grace protects hull") + update = session.advance(_step(14, 0.1, 80.0)) + _expect(update.snapshot.hull.current_integrity < 100.0, "crush damage starts after grace") + + +func _test_collision_is_idempotent() -> void: + var session := _session() + var first_collisions: Array[CollisionObservation] = [ + CollisionObservation.new(&"impact-a", 0, 0, 6.5), + ] + var first = session.advance(_step(0, 0.1, 20.0, 0.0, first_collisions)) + _expect_near(first.snapshot.hull.current_integrity, 88.0, 0.001, "first collision damage") + + var repeated_collisions: Array[CollisionObservation] = [ + CollisionObservation.new(&"impact-a", 1, 1, 6.5), + ] + var repeated = session.advance(_step(1, 0.1, 20.0, 0.0, repeated_collisions)) + _expect(repeated.accepted, "repeated collision tick accepted") + _expect_near(repeated.snapshot.hull.current_integrity, 88.0, 0.001, "collision idempotence") + + +func _test_collisions_are_ordered_and_cooled_down() -> void: + var session := _session() + var collisions: Array[CollisionObservation] = [ + CollisionObservation.new(&"late", 5, 0, 9.0), + CollisionObservation.new(&"early", 1, 0, 4.5), + ] + var update = session.advance(_step(0, 0.1, 20.0, 0.0, collisions)) + _expect_near(update.snapshot.hull.current_integrity, 96.0, 0.001, "ordered collision cooldown") + _expect(_event_count(update.events, DiveEvent.Kind.HULL_DAMAGED) == 1, "one hull damage event") + + +func _test_invalid_sequence_is_rejected_without_mutation() -> void: + var session := _session() + var before = session.current_snapshot() + var update = session.advance(_step(2, 1.0 / 60.0)) + _expect(not update.accepted, "out-of-order tick rejected") + _expect(update.snapshot == before, "rejected tick preserves snapshot") + _expect(update.snapshot.tick_sequence == -1, "rejected tick preserves sequence") + + +func _test_oversized_tick_is_rejected() -> void: + var session := _session() + var update = session.advance(_step(0, 0.11)) + _expect(not update.accepted, "oversized tick rejected") + _expect_near(update.snapshot.oxygen.remaining_l, 480.0, 0.001, "oversized tick consumes nothing") + + +func _test_alert_transition_is_emitted_once() -> void: + var session := DiveSession.new(SESSION_ID, _standard_rules(), -1.0, 144.01) + var first = session.advance(_step(0, 0.1)) + _expect(first.snapshot.alerts.oxygen == DiveAlertLevel.Value.ATTENTION, "oxygen attention") + _expect(_alert_event_count(first.events, &"oxygen") == 1, "oxygen alert transition emitted") + var second = session.advance(_step(1, 0.1)) + _expect(_alert_event_count(second.events, &"oxygen") == 0, "oxygen alert not repeated") + + +func _test_empty_battery_is_not_terminal() -> void: + var session := DiveSession.new(SESSION_ID, _standard_rules(), 0.0) + var update = session.advance(_step(0, 0.1)) + _expect(update.accepted, "empty battery tick accepted") + _expect( + update.snapshot.terminal_reason == DiveTerminalReason.Value.NONE, + "empty battery not terminal" + ) + _expect(update.snapshot.alerts.battery == DiveAlertLevel.Value.FAILURE, "battery failure alert") + _expect(update.snapshot.oxygen.remaining_l < 480.0, "oxygen continues with empty battery") + + +func _test_ejection_hold_resets_on_release() -> void: + var session := _session() + for sequence in 20: + session.advance(_step(sequence, 0.1, 20.0, 0.0, [], [], true)) + var released = session.advance(_step(20, 0.1, 20.0, 0.0, [], [], false)) + _expect_near(released.snapshot.ejection.held_seconds, 0.0, 0.001, "ejection resets") + _expect(not released.snapshot.is_terminal(), "released ejection is not terminal") + + +func _test_ejection_terminates_after_three_seconds() -> void: + var session := _session() + var update + for sequence in 30: + update = session.advance(_step(sequence, 0.1, 20.0, 0.0, [], [], true)) + _expect( + update.snapshot.terminal_reason == DiveTerminalReason.Value.VOLUNTARY_EJECTION, + "three-second ejection terminates" + ) + _expect(_event_count(update.events, DiveEvent.Kind.DIVE_TERMINATED) == 1, "terminal event emitted") + var rejected = session.advance(_step(30, 0.1)) + _expect(not rejected.accepted, "terminal session rejects later ticks") + + +func _test_confirmed_ejection_terminates_immediately() -> void: + var session := _session() + var step := _step(0, 0.1) + step.ejection_intent = EjectionIntent.new(false, true) + var update = session.advance(step) + _expect( + update.snapshot.terminal_reason == DiveTerminalReason.Value.VOLUNTARY_EJECTION, + "confirmed pause-menu ejection terminates immediately" + ) + + +func _test_oxygen_depletion_terminates_dive() -> void: + var session := DiveSession.new(SESSION_ID, _standard_rules(), -1.0, 0.01) + var update = session.advance(_step(0, 0.1)) + _expect_near(update.snapshot.oxygen.remaining_l, 0.0, 0.001, "oxygen reaches zero") + _expect( + update.snapshot.terminal_reason == DiveTerminalReason.Value.OXYGEN_DEPLETED, + "oxygen terminal reason" + ) + + +func _test_hull_destruction_terminates_dive() -> void: + var session := DiveSession.new(SESSION_ID, _standard_rules(), -1.0, -1.0, 1.0) + var collisions: Array[CollisionObservation] = [ + CollisionObservation.new(&"fatal-impact", 0, 0, 8.0), + ] + var update = session.advance(_step(0, 0.1, 20.0, 0.0, collisions)) + _expect_near(update.snapshot.hull.current_integrity, 0.0, 0.001, "hull reaches zero") + _expect( + update.snapshot.terminal_reason == DiveTerminalReason.Value.HULL_DESTROYED, + "hull terminal reason" + ) + + +func _test_return_estimate_uses_real_kwh() -> void: + var session := _session() + var route := ReturnRouteSample.new(true, 600.0, 0.0) + var update = session.advance(_step(0, 0.1, 20.0, 0.0, [], [], false, route)) + _expect_near(update.snapshot.return_estimate.required_kwh, 50.0, 0.001, "return kWh") + _expect_near(update.snapshot.return_estimate.margin_kwh, 50.0, 0.001, "return margin") + _expect(update.snapshot.return_estimate.status == ReturnEstimate.Status.SAFE, "return safe") + + +func _test_session_has_no_internal_clock() -> void: + var session := _session() + var before = session.current_snapshot() + var after = session.current_snapshot() + _expect(after == before, "session cannot advance without an explicit DiveStep") + _expect_near(after.oxygen.remaining_l, 480.0, 0.001, "no implicit oxygen drain") + + +func _standard_rules(): + return DiveRulesResolver.new().resolve(DiveDifficultyProfile.standard()) + + +func _session() -> DiveSession: + return DiveSession.new(SESSION_ID, _standard_rules()) + + +func _step( + sequence: int, + delta_seconds: float, + depth_m: float = 20.0, + longitudinal_effort: float = 0.0, + collisions: Array[CollisionObservation] = [], + tools: Array[ToolPowerDemand] = [], + ejection_active: bool = false, + return_route: ReturnRouteSample = null +) -> DiveStep: + return DiveStep.new( + DiveTick.new(SESSION_ID, sequence, delta_seconds), + DiveTelemetrySample.new(depth_m, longitudinal_effort), + collisions, + tools, + EjectionIntent.new(ejection_active), + return_route if return_route != null else ReturnRouteSample.new() + ) + + +func _event_count(events: Array[DiveEvent], kind: DiveEvent.Kind) -> int: + var count := 0 + for event: DiveEvent in events: + if event.kind == kind: + count += 1 + return count + + +func _alert_event_count(events: Array[DiveEvent], subject: StringName) -> int: + var count := 0 + for event: DiveEvent in events: + if event.kind == DiveEvent.Kind.ALERT_LEVEL_CHANGED and event.subject == subject: + count += 1 + return count + + +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 %.5f, got %.5f" % [label, expected, actual]) diff --git a/tests/unit/run_vehicle_application_tests.gd b/tests/unit/run_vehicle_application_tests.gd new file mode 100644 index 0000000..88342e9 --- /dev/null +++ b/tests/unit/run_vehicle_application_tests.gd @@ -0,0 +1,79 @@ +extends SceneTree + +const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd") +const CameraLookCommand = preload( + "res://game/application/vehicle/camera_look_command.gd" +) +const PilotUseCase = preload("res://game/application/vehicle/pilot_use_case.gd") +const VehicleMotionPort = preload( + "res://game/application/interfaces/vehicle_motion_port.gd" +) + +const TEST_COUNT := 10 + +var _failures: Array[String] = [] + + +class RecordingMotionPort extends VehicleMotionPort: + var commands: Array[VehicleCommand] = [] + + + func apply_command(command: VehicleCommand) -> void: + commands.append(command) + + +func _init() -> void: + _test_vehicle_command_contract() + _test_camera_command_contract() + _test_pilot_use_case_orders_commands() + + if _failures.is_empty(): + print("Vehicle application unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Vehicle application unit tests: %d failed." % _failures.size()) + quit(1) + + +func _test_vehicle_command_contract() -> void: + var neutral: VehicleCommand = VehicleCommand.neutral(0) + _expect(neutral.validation_error().is_empty(), "neutral command is valid") + _expect( + VehicleCommand.new(-1, 0.0, 0.0, 0.0, false).validation_error() + == "vehicle command sequence is negative", + "negative sequence is rejected" + ) + _expect( + not VehicleCommand.new(0, 1.01, 0.0, 0.0, false).validation_error().is_empty(), + "non-normalized axis is rejected" + ) + + +func _test_camera_command_contract() -> void: + _expect(CameraLookCommand.new(0.2, -0.1).validation_error().is_empty(), "finite camera look is valid") + _expect( + not CameraLookCommand.new(NAN, 0.0).validation_error().is_empty(), + "non-finite camera look is rejected" + ) + + +func _test_pilot_use_case_orders_commands() -> void: + var port := RecordingMotionPort.new() + var pilot := PilotUseCase.new(port) + var first = pilot.execute(VehicleCommand.new(0, 1.0, 0.0, 0.0, false)) + _expect(first.accepted, "first consecutive vehicle command is accepted") + _expect(port.commands.size() == 1 and port.commands[0].longitudinal == 1.0, "accepted command reaches motion port") + + var skipped = pilot.execute(VehicleCommand.neutral(2)) + _expect(not skipped.accepted, "skipped vehicle sequence is rejected") + _expect(port.commands.size() == 1, "rejected command does not reach motion port") + + var retry = pilot.execute(VehicleCommand.neutral(1)) + _expect(retry.accepted and port.commands.size() == 2, "expected sequence remains retryable") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label)