diff --git a/.gitignore b/.gitignore index 3cbbc7e..037fca2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ *.tmp *.log builds/ +.claude/worktrees/ +.superpowers/ diff --git a/content/README.md b/content/README.md index 13043c6..de1d118 100644 --- a/content/README.md +++ b/content/README.md @@ -7,3 +7,19 @@ Une ressource décrit des données et référence des identifiants stables. Elle duplique pas une règle de gameplay et ne dépend pas d'une scène de POC. Chaque type de ressource recevra un validateur de contrat avant l'ajout de contenu en volume. + +## Sites + +`sites/.tres` est une `DiveSiteDefinition` ; `sites/_terrain.res` +est son `SiteTerrainData`. Les deux sont produits par +`tools/bake_site_terrain.gd -- ` depuis `tools/sites/_recipe.gd`, +la recette étant le document d'auteur (ADR 0012). Ne pas éditer les fichiers +produits à la main. + +Sites présents : + +- `vehicle_test` : site de calibration du véhicule (fixture des tests) ; +- `zone1_vigilant` : le Banc du Vigilant, site de mission de la tranche 3 ; +- `terrain_showcase` : le Jardin des Formes, catalogue des formes de relief + (canyon à strates, crevasse, chaos de blocs, éperons, falaise sinueuse), sans + épave ni route ; chargeable par `select_dive_site` pour inspection. diff --git a/content/sites/terrain_showcase.tres b/content/sites/terrain_showcase.tres new file mode 100644 index 0000000..d8a6035 --- /dev/null +++ b/content/sites/terrain_showcase.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="DiveSiteDefinition" format=3] + +[ext_resource type="Script" path="res://game/domain/sites/site_placement.gd" id="1_vfxru"] +[ext_resource type="Script" path="res://game/domain/sites/site_route.gd" id="2_eyncd"] +[ext_resource type="Script" path="res://game/domain/sites/dive_site_definition.gd" id="3_gsyse"] +[ext_resource type="Resource" path="res://content/sites/terrain_showcase_terrain.res" id="4_cdhpc"] + +[sub_resource type="Resource" id="Resource_1hr26"] +script = ExtResource("1_vfxru") +placement_id = &"vessel_anchor" +kind = 5 +position_m = Vector3(0, -16, 0) + +[resource] +script = ExtResource("3_gsyse") +site_id = &"terrain_showcase" +display_name = "Jardin des Formes" +terrain = ExtResource("4_cdhpc") +playable_bounds_m = Rect2(-220, -220, 440, 440) +spawn_position_m = Vector3(0, -12, 0) +placements = Array[ExtResource("1_vfxru")]([SubResource("Resource_1hr26")]) diff --git a/content/sites/terrain_showcase_terrain.res b/content/sites/terrain_showcase_terrain.res new file mode 100644 index 0000000..6eba7ff Binary files /dev/null and b/content/sites/terrain_showcase_terrain.res differ diff --git a/content/sites/vehicle_test.tres b/content/sites/vehicle_test.tres new file mode 100644 index 0000000..eb82678 --- /dev/null +++ b/content/sites/vehicle_test.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="DiveSiteDefinition" format=3] + +[ext_resource type="Script" path="res://game/domain/sites/site_placement.gd" id="1_j2ii4"] +[ext_resource type="Script" path="res://game/domain/sites/site_route.gd" id="2_tj3be"] +[ext_resource type="Script" path="res://game/domain/sites/dive_site_definition.gd" id="3_usfet"] +[ext_resource type="Resource" path="res://content/sites/vehicle_test_terrain.res" id="4_awe7g"] + +[sub_resource type="Resource" id="Resource_5juth"] +script = ExtResource("1_j2ii4") +placement_id = &"calibration_target" +kind = 1 +position_m = Vector3(0, -60.44367, -58) +footprint_m = Vector3(8, 5, 7) + +[sub_resource type="Resource" id="Resource_tboiu"] +script = ExtResource("1_j2ii4") +placement_id = &"navigation_beacon" +kind = 5 +position_m = Vector3(0, -15, -22) + +[resource] +script = ExtResource("3_usfet") +site_id = &"vehicle_test" +display_name = "Site de calibration du véhicule" +terrain = ExtResource("4_awe7g") +playable_bounds_m = Rect2(-90, -176, 180, 246) +spawn_position_m = Vector3(0, -12, 12) +placements = Array[ExtResource("1_j2ii4")]([SubResource("Resource_5juth"), SubResource("Resource_tboiu")]) diff --git a/content/sites/vehicle_test_terrain.res b/content/sites/vehicle_test_terrain.res new file mode 100644 index 0000000..fd14ed2 Binary files /dev/null and b/content/sites/vehicle_test_terrain.res differ diff --git a/content/sites/zone1_vigilant.tres b/content/sites/zone1_vigilant.tres new file mode 100644 index 0000000..eef9e9d --- /dev/null +++ b/content/sites/zone1_vigilant.tres @@ -0,0 +1,122 @@ +[gd_resource type="Resource" script_class="DiveSiteDefinition" format=3] + +[ext_resource type="Script" path="res://game/domain/sites/site_placement.gd" id="1_n3xpu"] +[ext_resource type="Script" path="res://game/domain/sites/site_route.gd" id="2_36hd6"] +[ext_resource type="Script" path="res://game/domain/sites/dive_site_definition.gd" id="3_vix3p"] +[ext_resource type="Resource" path="res://content/sites/zone1_vigilant_terrain.res" id="4_i0v8k"] + +[sub_resource type="Resource" id="Resource_rqt47"] +script = ExtResource("1_n3xpu") +placement_id = &"prospection_vessel" +kind = 0 +position_m = Vector3(-14.154642, -36.88306, 98.39586) +yaw_radians = -1.380808047881126 +footprint_m = Vector3(28, 8, 9) + +[sub_resource type="Resource" id="Resource_xcojm"] +script = ExtResource("1_n3xpu") +placement_id = &"decoy_yacht" +kind = 1 +position_m = Vector3(-30, -24.996498, -150) +yaw_radians = 1.1 +footprint_m = Vector3(12, 3, 4) + +[sub_resource type="Resource" id="Resource_yljal"] +script = ExtResource("1_n3xpu") +placement_id = &"decoy_coaster" +kind = 1 +position_m = Vector3(140, -35.34944, 40) +yaw_radians = -0.6 +footprint_m = Vector3(26, 6, 8) + +[sub_resource type="Resource" id="Resource_aqgwj"] +script = ExtResource("1_n3xpu") +placement_id = &"secondary_item" +kind = 2 +position_m = Vector3(-6, -63.33776, 160) +footprint_m = Vector3(1, 1, 1) + +[sub_resource type="Resource" id="Resource_aervf"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_1" +kind = 3 +position_m = Vector3(-12, -45.58156, 136) +yaw_radians = 0.4 +footprint_m = Vector3(3, 2, 2) + +[sub_resource type="Resource" id="Resource_qgawl"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_2" +kind = 3 +position_m = Vector3(-10, -48.026417, 139) +yaw_radians = 1.2 +footprint_m = Vector3(6, 2.5, 2.5) + +[sub_resource type="Resource" id="Resource_q0ace"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_3" +kind = 3 +position_m = Vector3(-9, -52.89768, 146) +footprint_m = Vector3(2, 1.5, 2) + +[sub_resource type="Resource" id="Resource_hd5cl"] +script = ExtResource("1_n3xpu") +placement_id = &"debris_4" +kind = 3 +position_m = Vector3(-7, -57.539745, 152) +yaw_radians = 2.0 +footprint_m = Vector3(4, 1.5, 1.5) + +[sub_resource type="Resource" id="Resource_wy8hl"] +script = ExtResource("1_n3xpu") +placement_id = &"vessel_anchor" +kind = 5 +position_m = Vector3(0, -14, -210) + +[sub_resource type="Resource" id="Resource_e72vy"] +script = ExtResource("1_n3xpu") +placement_id = &"echine" +position_m = Vector3(75, -20, -50) + +[sub_resource type="Resource" id="Resource_3n33i"] +script = ExtResource("1_n3xpu") +placement_id = &"deux_freres" +position_m = Vector3(-151, -18, -125) + +[sub_resource type="Resource" id="Resource_cdmnh"] +script = ExtResource("1_n3xpu") +placement_id = &"rides" +position_m = Vector3(0, -25, -230) + +[sub_resource type="Resource" id="Resource_u8dqp"] +script = ExtResource("1_n3xpu") +placement_id = &"saignee_entrance" +position_m = Vector3(-56.978443, -35.54769, -124.287895) + +[sub_resource type="Resource" id="Resource_3uttw"] +script = ExtResource("1_n3xpu") +placement_id = &"rupture" +position_m = Vector3(40, -40, 125) + +[sub_resource type="Resource" id="Resource_xvesq"] +script = ExtResource("2_36hd6") +route_id = &"safe_ridge" +waypoints_m = PackedVector3Array(0, -14, -210, 70, -16, -200, 76, -16, -100, 78, -16, 0, 76, -18, 80, 40, -24, 105, 0, -24, 100, -14, -30, 98) +maximum_depth_m = 32.0 + +[sub_resource type="Resource" id="Resource_lcu7s"] +script = ExtResource("2_36hd6") +route_id = &"risky_groove" +waypoints_m = PackedVector3Array(0, -14, -210, -60, -20, -150, -50, -31, -100, -35, -33, -20, -22, -34, 60, -14, -33, 98) +maximum_depth_m = 48.0 + +[resource] +script = ExtResource("3_vix3p") +site_id = &"zone1_vigilant" +display_name = "Banc du Vigilant" +terrain = ExtResource("4_i0v8k") +playable_bounds_m = Rect2(-300, -300, 600, 600) +spawn_position_m = Vector3(0, -10, -210) +spawn_yaw_radians = 3.141592653589793 +placements = Array[ExtResource("1_n3xpu")]([SubResource("Resource_rqt47"), SubResource("Resource_xcojm"), SubResource("Resource_yljal"), SubResource("Resource_aqgwj"), SubResource("Resource_aervf"), SubResource("Resource_qgawl"), SubResource("Resource_q0ace"), SubResource("Resource_hd5cl"), SubResource("Resource_wy8hl"), SubResource("Resource_e72vy"), SubResource("Resource_3n33i"), SubResource("Resource_cdmnh"), SubResource("Resource_u8dqp"), SubResource("Resource_3uttw")]) +routes = Array[ExtResource("2_36hd6")]([SubResource("Resource_xvesq"), SubResource("Resource_lcu7s")]) diff --git a/content/sites/zone1_vigilant_terrain.res b/content/sites/zone1_vigilant_terrain.res new file mode 100644 index 0000000..c138807 Binary files /dev/null and b/content/sites/zone1_vigilant_terrain.res differ diff --git a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md index 1406e72..bc63edc 100644 --- a/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md +++ b/docs/VERTICAL_SLICE_TECHNICAL_PLAN.md @@ -3,7 +3,7 @@ ## Statut - Date : 10 août 2026. -- Dernière révision d'implémentation : 4 octobre 2026. +- Dernière révision d'implémentation : 6 octobre 2026. - État : cadrage approuvé, tranches 0, 1 et 1.5 implémentées ; tranche 2 en cours. - Jalon : verticale jouable, après validation des POC A et B. - Durée directrice du plan général : quatre à six mois. @@ -864,6 +864,14 @@ rotation et remplacement d'un relevé. ### Tranche 3 — site et découverte +État au 2026-10-06 : terrain du site implémenté. Le Banc du Vigilant +(`content/sites/zone1_vigilant.tres`) est chargé par `Nouvelle partie` ; ses +placements d'épaves sont encore des volumes graybox métalliques. Voir +`docs/briefs/terrain-site-zone1-gameplay.md` et l'ADR 0012. Le vocabulaire de +formes du cuiseur couvre canyons à strates, crevasses, buttes, chaos de blocs +et falaises sinueuses, démontrés sur `content/sites/terrain_showcase.tres` +(`docs/briefs/terrain-varied-landforms.md`, amendement 1 de l'ADR 0012). + - 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. @@ -946,7 +954,9 @@ Une fois ce document approuvé, créer des ADR séparés pour : `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. + nécessitera un prototype et un nouvel ADR lorsqu'un site concret l'exigera ; +8. le terrain de site en champ de hauteur authoré, formalisé dans + `docs/adr/0012-authored-heightfield-dive-sites.md`. 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. diff --git a/docs/adr/0012-authored-heightfield-dive-sites.md b/docs/adr/0012-authored-heightfield-dive-sites.md new file mode 100644 index 0000000..0f4416c --- /dev/null +++ b/docs/adr/0012-authored-heightfield-dive-sites.md @@ -0,0 +1,99 @@ +# 0012 — Sites de plongée en champ de hauteur authoré + +- Statut : accepté +- Date : 2026-10-06 +- Amendé : 2026-10-06 (vocabulaire de formes, voir « Amendement 1 ») +- Remplace : aucun + +## Contexte + +Le seul terrain de production est une formule analytique codée dans +`vehicle_test_environment.gd`. La tranche 3 exige un site conçu à la main, +lisible au sonar (ADR 0010 : surface unique), avec une route sûre et une route +risquée, et le sonar a besoin de distinguer roche et sable par cellule, pas par +corps. Il faut un contrat de données pour le terrain, son stockage dans le +dépôt et sa production. + +## Décision + +1. Un site de plongée est décrit par une ressource typée `DiveSiteDefinition` + (`game/domain/sites/`) : identifiant, terrain, limites jouables, point de + départ, placements d'objets et routes de contrôle. +2. Le terrain est un champ de hauteur à surface unique `SiteTerrainData` : + grille régulière de `columns × rows` cellules de `cell_size_m` mètres, + élévations en mètres (axe Y monde, négatif sous la surface) dans un + `PackedFloat32Array`, matériau sonar par cellule dans un `PackedByteArray` + (`SonarMaterial.Value`, ROCK ou SAND). Repère : +X est, +Z sud, +Y haut ; + la colonne croît avec +X, la ligne avec +Z ; la cellule `(0, 0)` est à + `(origin_x_m, origin_z_m)`. +3. Les données sont cuites hors exécution par `tools/bake_site_terrain.gd` à + partir d'une recette déclarative de primitives géomorphologiques nommées + (plateau, crête, piton, sillon, rupture de pente, rides, rugosité + déterministe par graine). La recette est le document d'auteur versionné ; + le résultat est écrit en `.res` binaire sous `content/sites/`. Le jeu ne + génère jamais de terrain à l'exécution. `tools/` dépend du domaine + (`game/domain/`) et de rien d'autre ; aucun code sous `game/` ou `content/` + ne dépend de `tools/`, ce que la barrière d'architecture vérifie. +4. Un PNG 16 bits n'est pas utilisé pour les hauteurs : le chargeur d'images + de Godot ramène les PNG 16 bits en 8 bits, soit des marches de 0,27 m sur + 68 m de dénivelé. Un PNG 8 bits de prévisualisation est produit pour la + revue humaine seulement. +5. Un seul acteur de monde, `BathymetricTerrainActor`, construit le maillage + visuel et la `HeightMapShape3D` depuis le même champ ; un test compare les + deux. Il expose `sonar_material_at(position)` et + `sonar_reflectivity_at(position)` que l'acquisition sonar privilégie sur + les métadonnées de corps. +6. Les limites de la zone jouable sont une règle applicative + (`PlayableBoundsRule`) qui produit un avertissement ; elles ne sont jamais + un mur de terrain (ADR 0010). + +## Conséquences + +- L'environnement de test du véhicule devient une définition de site cuite, + sans formule analytique dans `game/`. +- Toute modification de terrain passe par la recette puis une cuisson ; les + fichiers `.res` sont des artefacts dérivés mais versionnés pour que le + chargement du site reste sous 3 s et que le jeu n'ait pas besoin de l'outil. +- Les tests de contrat de contenu peuvent vérifier profondeur, position et + dégagement des placements contre le champ de hauteur réel. +- Une sculpture manuelle (Blender) ultérieure devra ré-exporter vers le même + `SiteTerrainData` ; le contrat ne change pas. + +## Amendement 1 — vocabulaire de formes (2026-10-06) + +Le point 3 est précisé sans changer le contrat de données. La recette est une +somme de **primitives** additives, suivie de **modificateurs** qui reçoivent la +pente locale du champ en cours, puis du plancher, de la règle de matériau par +pente, des matériaux imposés par primitive et des `MaterialPatch`. + +- Primitives lisses : plan incliné, bosse gaussienne, crête, sillon, rupture + de pente, rides, rugosité déterministe. +- Primitives de relief marqué : `Canyon` (tranchée à fond plat le long d'une + polyligne, contour bruité), `Escarpment` (rupture de pente le long d'une + polyligne, le terrain descend à droite du sens de parcours), `Outcrop` + (butte ou fosse à sommet plat, contour bruité, matériau roche imposé), + `BoulderField` (blocs semi-ellipsoïdaux tirés par graine, combinés par + maximum, roche imposée). +- Modificateurs : `Strata` quantifie les pentes raides en gradins horizontaux + dans un rectangle monde, les fonds et sommets plats ne bougent pas ; + `SlopeRoughness` ajoute un bruit à crêtes pondéré par la pente (roche + éraillée, sable lisse). `Roughness` accepte un nombre d'octaves et un type + fractal pour donner du relief de fond à plusieurs échelles. + +Tout aléa passe par une graine explicite (`FastNoiseLite`, +`RandomNumberGenerator`) ; `tests/unit/run_site_baker_tests.gd` vérifie que +le Banc du Vigilant se recuit à l'identique. Le site de démonstration +`content/sites/terrain_showcase.tres` (« Jardin des Formes ») sert de +catalogue et de banc d'essai des contrats géométriques +(`docs/briefs/terrain-varied-landforms.md`). + +## Alternatives considérées + +- **Plugin de terrain externe** : rejeté, dépendance et ADR supplémentaires + sans besoin démontré ; une heightmap tuilée suffit à la V1. +- **Génération à l'exécution depuis la recette** : rejeté, le plan impose un + site conçu à la main et un chargement borné ; la cuisson garantit un + artefact figé et revu. +- **Heightmap PNG 16 bits** : rejeté pour la raison de précision ci-dessus. +- **Maillage sculpté sans champ de hauteur** : rejeté, le sonar et les tests + ont besoin d'une requête de hauteur et de matériau par position. diff --git a/docs/briefs/captures/terrain_showcase_boulders.png b/docs/briefs/captures/terrain_showcase_boulders.png new file mode 100644 index 0000000..3b62ea9 Binary files /dev/null and b/docs/briefs/captures/terrain_showcase_boulders.png differ diff --git a/docs/briefs/captures/terrain_showcase_boulders.png.import b/docs/briefs/captures/terrain_showcase_boulders.png.import new file mode 100644 index 0000000..1684d08 --- /dev/null +++ b/docs/briefs/captures/terrain_showcase_boulders.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b016ai62hf6xp" +path="res://.godot/imported/terrain_showcase_boulders.png-f5e21fa321cb65fb6aa9fbf5caf37b89.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/terrain_showcase_boulders.png" +dest_files=["res://.godot/imported/terrain_showcase_boulders.png-f5e21fa321cb65fb6aa9fbf5caf37b89.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/terrain_showcase_canyon.png b/docs/briefs/captures/terrain_showcase_canyon.png new file mode 100644 index 0000000..232aeca Binary files /dev/null and b/docs/briefs/captures/terrain_showcase_canyon.png differ diff --git a/docs/briefs/captures/terrain_showcase_canyon.png.import b/docs/briefs/captures/terrain_showcase_canyon.png.import new file mode 100644 index 0000000..625b48e --- /dev/null +++ b/docs/briefs/captures/terrain_showcase_canyon.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfrf0k4vcu8tq" +path="res://.godot/imported/terrain_showcase_canyon.png-8807a6e80b6f315ca35d641eacaaafac.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/terrain_showcase_canyon.png" +dest_files=["res://.godot/imported/terrain_showcase_canyon.png-8807a6e80b6f315ca35d641eacaaafac.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/terrain_showcase_crevasse.png b/docs/briefs/captures/terrain_showcase_crevasse.png new file mode 100644 index 0000000..9c46cbc Binary files /dev/null and b/docs/briefs/captures/terrain_showcase_crevasse.png differ diff --git a/docs/briefs/captures/terrain_showcase_crevasse.png.import b/docs/briefs/captures/terrain_showcase_crevasse.png.import new file mode 100644 index 0000000..29fd9b3 --- /dev/null +++ b/docs/briefs/captures/terrain_showcase_crevasse.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://buueygdk82kc7" +path="res://.godot/imported/terrain_showcase_crevasse.png-c7855555d13c530e16be9a09645f6bd8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/terrain_showcase_crevasse.png" +dest_files=["res://.godot/imported/terrain_showcase_crevasse.png-c7855555d13c530e16be9a09645f6bd8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/terrain_showcase_escarpment.png b/docs/briefs/captures/terrain_showcase_escarpment.png new file mode 100644 index 0000000..15d90ca Binary files /dev/null and b/docs/briefs/captures/terrain_showcase_escarpment.png differ diff --git a/docs/briefs/captures/terrain_showcase_escarpment.png.import b/docs/briefs/captures/terrain_showcase_escarpment.png.import new file mode 100644 index 0000000..532bdcd --- /dev/null +++ b/docs/briefs/captures/terrain_showcase_escarpment.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jcf3hfw1jyvg" +path="res://.godot/imported/terrain_showcase_escarpment.png-d07b0ca5e8ab4a969f7f6f86eb67d87f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/terrain_showcase_escarpment.png" +dest_files=["res://.godot/imported/terrain_showcase_escarpment.png-d07b0ca5e8ab4a969f7f6f86eb67d87f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/terrain_showcase_spurs.png b/docs/briefs/captures/terrain_showcase_spurs.png new file mode 100644 index 0000000..5986c7e Binary files /dev/null and b/docs/briefs/captures/terrain_showcase_spurs.png differ diff --git a/docs/briefs/captures/terrain_showcase_spurs.png.import b/docs/briefs/captures/terrain_showcase_spurs.png.import new file mode 100644 index 0000000..45baf42 --- /dev/null +++ b/docs/briefs/captures/terrain_showcase_spurs.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://u83dpa3ka4qr" +path="res://.godot/imported/terrain_showcase_spurs.png-9ac47645be3d60559c74797904950bdd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/terrain_showcase_spurs.png" +dest_files=["res://.godot/imported/terrain_showcase_spurs.png-9ac47645be3d60559c74797904950bdd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/terrain_showcase_terrain_preview.png b/docs/briefs/captures/terrain_showcase_terrain_preview.png new file mode 100644 index 0000000..ed2ab62 Binary files /dev/null and b/docs/briefs/captures/terrain_showcase_terrain_preview.png differ diff --git a/docs/briefs/captures/terrain_showcase_terrain_preview.png.import b/docs/briefs/captures/terrain_showcase_terrain_preview.png.import new file mode 100644 index 0000000..34d9476 --- /dev/null +++ b/docs/briefs/captures/terrain_showcase_terrain_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c448uwrxq3foi" +path="res://.godot/imported/terrain_showcase_terrain_preview.png-6c9dafd8892db2b24553477d0b6fc653.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/terrain_showcase_terrain_preview.png" +dest_files=["res://.godot/imported/terrain_showcase_terrain_preview.png-6c9dafd8892db2b24553477d0b6fc653.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/vehicle_test_terrain_preview.png b/docs/briefs/captures/vehicle_test_terrain_preview.png new file mode 100644 index 0000000..834319c Binary files /dev/null and b/docs/briefs/captures/vehicle_test_terrain_preview.png differ diff --git a/docs/briefs/captures/vehicle_test_terrain_preview.png.import b/docs/briefs/captures/vehicle_test_terrain_preview.png.import new file mode 100644 index 0000000..c5bc213 --- /dev/null +++ b/docs/briefs/captures/vehicle_test_terrain_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crrgsnvuf3ttv" +path="res://.godot/imported/vehicle_test_terrain_preview.png-4e68e296dd4625902c8d2c03244ab133.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/vehicle_test_terrain_preview.png" +dest_files=["res://.godot/imported/vehicle_test_terrain_preview.png-4e68e296dd4625902c8d2c03244ab133.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/zone1_vigilant_hull_in_groove.png b/docs/briefs/captures/zone1_vigilant_hull_in_groove.png new file mode 100644 index 0000000..bc15655 Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_hull_in_groove.png differ diff --git a/docs/briefs/captures/zone1_vigilant_patch_reef.png b/docs/briefs/captures/zone1_vigilant_patch_reef.png new file mode 100644 index 0000000..e164a3c Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_patch_reef.png differ diff --git a/docs/briefs/captures/zone1_vigilant_patch_reef.png.import b/docs/briefs/captures/zone1_vigilant_patch_reef.png.import new file mode 100644 index 0000000..de1c60f --- /dev/null +++ b/docs/briefs/captures/zone1_vigilant_patch_reef.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfhdetospcaup" +path="res://.godot/imported/zone1_vigilant_patch_reef.png-c13e4b56c466e2ab61173a145fd844d2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/zone1_vigilant_patch_reef.png" +dest_files=["res://.godot/imported/zone1_vigilant_patch_reef.png-c13e4b56c466e2ab61173a145fd844d2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/zone1_vigilant_plateau.png b/docs/briefs/captures/zone1_vigilant_plateau.png new file mode 100644 index 0000000..82fc2f2 Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_plateau.png differ diff --git a/docs/briefs/captures/zone1_vigilant_plateau.png.import b/docs/briefs/captures/zone1_vigilant_plateau.png.import new file mode 100644 index 0000000..95b6baa --- /dev/null +++ b/docs/briefs/captures/zone1_vigilant_plateau.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3qajwshxpplq" +path="res://.godot/imported/zone1_vigilant_plateau.png-6e6da03a831034d096e040e9808fd1b8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/zone1_vigilant_plateau.png" +dest_files=["res://.godot/imported/zone1_vigilant_plateau.png-6e6da03a831034d096e040e9808fd1b8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/zone1_vigilant_sonar_groove.png b/docs/briefs/captures/zone1_vigilant_sonar_groove.png new file mode 100644 index 0000000..c633935 Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_sonar_groove.png differ diff --git a/docs/briefs/captures/zone1_vigilant_terrain_preview.png b/docs/briefs/captures/zone1_vigilant_terrain_preview.png new file mode 100644 index 0000000..78da661 Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_terrain_preview.png differ diff --git a/docs/briefs/captures/zone1_vigilant_terrain_preview.png.import b/docs/briefs/captures/zone1_vigilant_terrain_preview.png.import new file mode 100644 index 0000000..bdb1095 --- /dev/null +++ b/docs/briefs/captures/zone1_vigilant_terrain_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bseks0pf7k0t4" +path="res://.godot/imported/zone1_vigilant_terrain_preview.png-c84b076e243497f30f994bef5c7d9142.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/zone1_vigilant_terrain_preview.png" +dest_files=["res://.godot/imported/zone1_vigilant_terrain_preview.png-c84b076e243497f30f994bef5c7d9142.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/captures/zone1_vigilant_tombant.png b/docs/briefs/captures/zone1_vigilant_tombant.png new file mode 100644 index 0000000..8c9d3a6 Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_tombant.png differ diff --git a/docs/briefs/captures/zone1_vigilant_tombant.png.import b/docs/briefs/captures/zone1_vigilant_tombant.png.import new file mode 100644 index 0000000..515220c --- /dev/null +++ b/docs/briefs/captures/zone1_vigilant_tombant.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cm2nm65ktggp" +path="res://.godot/imported/zone1_vigilant_tombant.png-71b9b2e227c187a355cfe8ea0a7a15f7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/zone1_vigilant_tombant.png" +dest_files=["res://.godot/imported/zone1_vigilant_tombant.png-71b9b2e227c187a355cfe8ea0a7a15f7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/terrain-site-zone1-gameplay.md b/docs/briefs/terrain-site-zone1-gameplay.md new file mode 100644 index 0000000..4101fd2 --- /dev/null +++ b/docs/briefs/terrain-site-zone1-gameplay.md @@ -0,0 +1,393 @@ +# Proposition — terrain du site de la zone I : « Banc du Vigilant » + +**Statut : validé par Olivier le 2026-10-06** (plan de masse, objet secondaire à 62 m, pipeline par script de cuisson, nom « Banc du Vigilant » conservé). Sert de spécification de référence pour l'implémentation. + +**Recuisson du 2026-10-06 (demande d'Olivier)** : la rupture et le tombant +sont stratifiés en gradins de 4 m (`Strata`, bande z ≥ 118 m, hors Saignée et +amers) ; le plateau reçoit un relief de fond à trois échelles (250–15 m ± 2,2 m, +80–10 m ± 1,2 m, 20–5 m ± 0,6 m) et la roche un bruit de pente (`SlopeRoughness`). +L'objet secondaire est reculé de 3 m (63 m), le premier débris de 2 m, et le +point de la route sûre au-dessus de la lèvre de la Saignée remonté à 24 m pour +garder 3 m de dégagement. Voir `docs/briefs/terrain-varied-landforms.md` §4.3 bis. + +Rédigée le 2026-10-06 à partir de `docs/GAME_DESIGN_PLAN.md`, +`docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`, `docs/POC_B_TECHNICAL_BASELINE.md`, +des ADR 0004, 0006, 0008, 0010 et du code sous `game/world/`. Rien n'a été +exécuté ; aucun fichier de jeu n'est modifié par ce brief. + +--- + +## 1. Objectif + +Donner au premier site de plongée (navire de prospection, zone I, 35 m) un +terrain statique, crédible et porteur de gameplay : le relief doit cacher +l'épave, se lire au sonar, offrir une route sûre et une route risquée, et +rendre la recherche intéressante sans marqueur. C'est la pièce « terrain +graybox » de la tranche 3 du plan technique. + +Terminé quand : + +- un joueur sans aide trouve l'accès à l'épave cible en 5 à 10 minutes après + l'arrivée dans la zone (critère §5 du plan technique) ; +- les deux routes existent, se distinguent au sonar et se justifient par les + ressources (profondeur, batterie, collisions) ; +- le sonar renvoie roche, sable et métal selon la cellule touchée, pas un seul + matériau pour tout le fond ; +- l'environnement de test du véhicule utilise la même implémentation de terrain + que le site (une seule implémentation par concept) ; +- `bash scripts/quality_gate.sh` passe. + +## 2. Ce qui existe et ce qui contraint + +| Élément | État | Conséquence pour le terrain | +|---|---|---| +| `game/world/sites/vehicle_test_environment.gd` | heightmap analytique 61×69, 180×200 m, -40 à -78 m, `HeightMapShape3D` | graybox de test ; devient un client du nouvel acteur terrain | +| `game/world/sonar/reference_bathymetry_acquisition.gd` | rayons verticaux, lit `sonar_material` / `sonar_reflectivity` sur le collider | le sonar suit tout terrain qui a une collision ; matériau par corps, pas par cellule | +| ADR 0010 | surface unique en V1 | pas de grotte, tunnel ni surplomb porteur d'information | +| Plan technique §3 | terrain conçu à la main, route sûre + route risquée | pas de génération procédurale à l'exécution | +| Plan technique §13 | chargement du site ≤ 3 s, 60 FPS 1080p | taille et découpage du maillage bornés | +| ADR 0010 | les limites de zone ne sont pas un relief à interpréter | la frontière jouable est une règle applicative, pas un mur de terrain | +| GDP §18 | low-poly atmosphérique, textures simples, identité par lumière et brouillard | réalisme = formes et lumière crédibles, pas photoréalisme | +| GDP §4.1 | coque I certifiée 60 m ; 100–115 % = dégâts croissants | une route ou un objet entre 60 et 69 m est un risque lisible, pas une mort | +| GDP §7.1 | zone d'écho de surface : 150 m, puis 75 m, minimum 50 m | le joueur arrive à ≤ 75 m de l'épave ; la portée sonar est 95 m | +| GDP §13.2 | une cible récente, deux leurres légers inscrits au registre | trois sites d'épave sur un seul terrain | + +**Point à vérifier avant de bâtir dessus** (non vérifié à l'exécution) : dans +`vehicle_test_environment.gd`, le maillage visuel place la ligne 0 à z = +28 +(proche) alors que `HeightMapShape3D` place la ligne 0 au z local le plus +négatif. Si c'est confirmé, la collision actuelle est le terrain visuel +retourné avant/arrière, et le sonar scanne la collision, pas le maillage. Les +tests d'intégration ne le détecteraient pas (ils comptent des échantillons, +pas une forme). Le nouvel acteur terrain doit avoir un test qui compare +hauteur visuelle et hauteur de collision en quelques points. Résolu : l'acteur +terrain compare collision et champ de hauteur dans +`tests/integration/run_site_terrain_integration.gd`. + +## 3. Partis pris de réalisme + +Le site est une **plate-forme carbonatée de bordure de plateau** caraïbe, +l'environnement réel le plus courant à 25–45 m dans l'arc antillais : + +- **Plateau** à 22–32 m : dalle de calcaire corallien recouverte de sable + biogène, patch reefs isolés, rides de sable orientées par la houle. +- **Système d'éperons et sillons** (« spur and groove ») sur la bordure + externe : crêtes rocheuses parallèles séparées de couloirs de sable de 8 à + 20 m de large, descendant vers la rupture de pente. C'est la forme la plus + lisible au sonar et la plus naturelle pour créer des couloirs de navigation. +- **Rupture de pente** à 42–48 m puis **tombant** jusqu'à 85 m, avec un + **cône d'éboulis** au pied des sillons. +- **Sédimentation** : un navire échoué sur une pente de sable biogène + s'enfonce et se recouvre partiellement en quelques mois ; les objets lourds + glissent vers le bas de la pente. Les deux phénomènes servent le gameplay. + +Ce qui reste hors réalisme volontairement : pas de corail animé, pas de +végétation dense, pas de courant (V0.5), pas de houle. Les formes, la lumière +décroissante avec la profondeur, le brouillard et les particules portent la +crédibilité, conformément à la direction artistique. + +## 4. Le site + +### 4.1 Dimensions + +| Donnée | Valeur proposée | +|---|---:| +| Zone jouable | 600 × 600 m | +| Terrain physique | 768 × 768 m (marge de 84 m de chaque côté, noyée dans le brouillard) | +| Champ de hauteur | 513 × 513 cellules, 1,5 m par cellule, élévations float32 (`SiteTerrainData`) | +| Profondeur min / max du terrain | 20 m / 88 m | +| Profondeur d'écrasement coque I | hors terrain : le fond ne tue jamais par lui-même | +| Mouillage du navire | centre nord du plateau, 25 m de fond | +| Point de départ du sous-marin | sous le navire, 10 m de profondeur | + +Les distances sont dimensionnées pour l'autonomie Standard (environ 1,2 km, +16 minutes d'oxygène) : aucune épave à plus de 320 m du mouillage, aucune +paire d'épaves à plus de 260 m l'une de l'autre. + +### 4.2 Plan de masse (nord en haut, navire au centre nord) + +```text + N + +---------------------------+ 20 m plateau clair, sable + patch reefs + | [Navire] | + | Rides . . | 26 m [B] leurre 1 : voilier de plaisance + | ~ ~ ~ [B] | posé à plat sur le sable, visible + | ~ ~ ~ ~ | + | les Deux ^^ l'Échine | 28 m crête longue nord-sud = fil d'Ariane + | Frères ^^ |||| | + | |||| | + | la Saignée |||| | 40 m [C] leurre 2 : caboteur acier dans + | \\\\\\ || [C] | un patch reef, échos roche + métal + | \\\\\\ || | + | \\\\\\ ====== | 45 m rupture de pente (ligne continue) + | \\\\\ [A] | 35 m [A] CIBLE : navire de prospection + | ----- .. . | dans un sillon, poupe sur la rupture + | tombant . . . | 50-64 traîne de débris descendant la pente + | ======== . | 62 m objet secondaire, légèrement + | 85 m | sous la profondeur certifiée + +---------------------------+ + S +``` + +Noms de travail : **l'Échine** (crête de 6 m de haut), **les Deux Frères** +(deux pitons jumeaux de 6 m de haut), **les Rides** (champ de rides de +sable), **la Saignée** (sillon principal), **le Tombant**. + +### 4.3 Les trois épaves et leur terrain + +**[A] Cible — navire de prospection (35 m).** Couché dans le sillon de la +Saignée, à 15 m de la rupture de pente. Coque enfoncée d'un tiers dans le +sable (GDP §7.6, « coque enterrée dans le sédiment ») : au sonar, elle se lit +comme une bosse allongée métallique dans un couloir, pas comme une silhouette +évidente. La poupe dépasse au-dessus de la rupture ; le portique, un conteneur +et une partie du gréement ont glissé en contrebas et forment une **traîne de +débris** de 50 à 64 m le long du tombant. La traîne est un fil conducteur : +un joueur qui descend trop tôt sur le tombant la rencontre et peut la +remonter jusqu'à la coque. Caché par : la bosse de l'Échine à l'est, la +profondeur du sillon, le sédiment. + +**[B] Leurre 1 — voilier de plaisance (26 m).** Posé à plat sur le sable, en +plein plateau, au bord des Rides. Facile à trouver, visible de loin. Bois et +résine : écho faible, aucun métal massif. Il apprend au joueur à lire une +silhouette et une matière au sonar. Contradiction découvrable : pas de +portique, un seul mât, taille moitié moindre. + +**[C] Leurre 2 — caboteur en acier (40 m).** Encastré dans un patch reef sur +le flanc est de l'Échine. Même ordre de taille que la cible, même matériau : +le vrai piège. Les échos roche et métal se mêlent ; il faut contourner et +scanner deux fois pour isoler le contact. Contradictions : immatriculation +différente, deux hélices au lieu d'une, pas de portique, avarie qui ne +correspond pas au rapport. + +### 4.4 Les deux routes vers la cible + +**Route sûre — par l'Échine (≈ 450 m, 26–32 m).** Depuis le navire, suivre la +crête longue vers le sud. Elle est lumineuse, large, sans obstacle, et se lit +d'un seul scan. Elle mène au bord du plateau au-dessus de la Saignée : il +faut ensuite descendre dans le sillon par son extrémité nord, à faible +pente. Coût : temps et batterie. Récompense : zéro dégât, retour facile à +estimer. + +**Route risquée — par la Saignée (≈ 340 m, 36–48 m).** Un sillon de 12 à +18 m de large, aux parois de 8 à 10 m, qui coupe le plateau en diagonale et +débouche directement sur la proue de la cible. Sombre (les projecteurs +deviennent nécessaires), étroit (collisions), en pente (la batterie de +remontée compte). Au sonar : une dépression continue, lisible comme une +« ouverture possible » (GDP §7.2, point 4). Aucune paroi ne surplombe, donc +compatible ADR 0010. Récompense : trajet plus court et direct sur la proue. + +**Le tombant — la tentation (50–64 m).** Pas une route : une pente qui +descend sous la rupture. L'objet secondaire (1 unité, ~200 €) repose à 62 m, +dans la zone 100–107 % de la profondeur certifiée : craquements, dégâts lents, +alerte claire. Le joueur voit la traîne de débris descendre et doit décider +de s'arrêter. C'est le « site légèrement sous la profondeur certifiée » de +GDP §7.6 appliqué à un objet optionnel, jamais à la cible. + +### 4.5 Repères pour naviguer sans GPS + +Le joueur n'a ni carte locale ni marqueur. Le terrain doit donc fournir des +amers reconnaissables au sonar à 95 m : + +| Amer | Forme sonar | Rôle | +|---|---|---| +| l'Échine | crête longue de 6 m de haut, orientée nord-sud | fil d'Ariane entre le navire et la cible | +| les Deux Frères | deux pitons jumeaux, 6 m de haut, 20 m d'écart | repère ouest unique, sert à se recaler | +| les Rides | ondulations régulières de 1 m | signe « plateau nord, navire proche » | +| la Saignée | sillon rectiligne, bords nets | entrée de la route risquée | +| la rupture de pente | ligne continue de rupture | « ne pas dépasser » et limite sud | + +Chaque amer est unique sur le site : aucune forme n'est répétée ailleurs, +pour que la reconnaissance ne trompe pas. + +## 5. Ce que le terrain apporte au gameplay + +1. **Chercher avant de récupérer** (pilier GDP §1.4) : la cible n'est ni la + plus visible [B] ni la plus « métallique » [C] ; elle est la plus cachée. + L'écho de surface amène à ≤ 75 m, le sillon et le sédiment font le reste. +2. **Lecture du sonar comme compétence** : rides = plateau, crête = route, + dépression = passage, bosse dans un couloir = chose enfouie. Le site + enseigne un vocabulaire que les sites suivants réutiliseront. +3. **Risque prévisible** : chaque danger s'annonce avant de coûter. Le sillon + est visiblement étroit, la pente visiblement longue, l'objet à 62 m + visiblement sous la ligne de rupture. +4. **Décision de retour** : la route risquée à l'aller et l'Échine au retour + est le plan « malin » ; le terrain rend ce raisonnement possible. +5. **Enquête matérielle** : la traîne de débris raconte le naufrage (impact + sur la rupture, glissement, enfouissement) et concorde avec le rapport de + la société, ce que les leurres ne font pas. +6. **Rejouabilité** : revenir au site après identification (GDP §8, « repère + local ») pour récupérer l'objet secondaire est une mini-expédition avec un + seul but : gérer la profondeur. + +## 6. Sonar et matériaux par cellule + +Aujourd'hui le matériau sonar est une meta par corps. Le site a besoin de +roche, sable et métal selon l'endroit touché : + +- un **champ d'octets de matériaux** (un octet par cellule : roche ou sable, + déduit de la pente à la cuisson puis corrigé par des `MaterialPatch` nommés + dans la recette) est stocké dans le `SiteTerrainData` à côté des + élévations ; +- l'acteur terrain expose `sonar_material_at(world_position)` et + `sonar_reflectivity_at(world_position)` ; l'acquisition les appelle quand + le collider les possède, sinon lit la meta comme aujourd'hui ; +- réflectivités de départ : roche 0,72 (valeur actuelle), sable 0,45, métal + 1,0. Les valeurs vivent dans la définition de site, pas dans le code. + +Les épaves restent des corps distincts avec leur propre meta ; le clustering +de contacts existant les sépare du fond. + +## 7. Rendu + +- Un seul matériau terrain, shader Forward+ : mélange **triplanaire** sable / + roche piloté par la pente (sable sous 25°, roche au-dessus de 40°, fondu + entre), normal map de rides sur le sable, teinte assombrie avec la + profondeur pour suivre la palette GDP §18.2 (vert clair à 25 m, bleu froid + à 45 m, indigo au pied du tombant). +- Maillage découpé en **64 tuiles de 96 × 96 m** (64 × 64 cellules), avec + `visibility_range` pour ne dessiner que les tuiles dans le brouillard + utile ; une seule `HeightMapShape3D` pour la collision. Les tuiles qui + contiennent une pente de plus de 35° sont rendues à deux sommets par + cellule sur la surface bicubique (Catmull-Rom, qui passe par chaque nœud + du champ, bornée entre nœuds pour ne jamais dépasser une marche) : les + facettes de la grille disparaissent sur les parois, la collision reste sur + la grille et coïncide avec le rendu aux nœuds. L'échantillonnage des tuiles + est pur et s'exécute en parallèle (`WorkerThreadPool`), les maillages sont + assemblés ensuite sur le thread principal. +- Props low-poly posés à la main, en nombre borné : blocs d'éboulis au pied + du tombant, têtes de corail sur les patch reefs, rien d'animé. +- Flore et encroûtantes (2026-10-07, demande d'Olivier) : `SeabedFloraActor` + sème, depuis le champ de hauteur et une graine dérivée de l'identifiant du + site, des touffes d'herbier sur le sable plat de la bande éclairée (pleines + à 20 m, absentes à 36 m, en prairies par bruit) et des gorgones sur la roche + de 15 à 60 m, cap croisé sur la houle ; une `MultiMesh` par tuile et par + espèce, maillages et textures à découpe alpha procéduraux + (`SeabedFloraAssets`), ondulation en vertex shader, aucune collision ni + présence sonar. Le shader roche ajoute gazon algal sur les faces éclairées, + croûtes corallines par plaques et éponges encroûtantes à l'ombre, le tout + s'éteignant entre 40 et 75 m. L'optique de la colonne d'eau est partagée par + `water_light.gdshaderinc`. Têtes de corail en volume (même jour) : trois + dômes bosselés procéduraux (massif, cerveau à crêtes, lobé) à base plate, + semés sur la roche douce (< 30°) de 12 à 40 m en patch reefs par bruit, une + `MultiMesh` par tuile et par variante, shader opaque `seabed_coral.gdshader` + (couleur de sommet × teinte d'instance, marbrure, même colonne d'eau). +- Particules en suspension (même jour) : `MarineSnow`, émetteur + `GPUParticles3D` en espace monde recentré sur la caméra active, boîte de + 44 × 28 × 44 m, 1 800 flocons de 3 à 9 cm qui coulent à 3,5 cm/s avec + turbulence, disques doux additifs en billboard, atténués par la colonne + d'eau et fondus en début et fin de vie. Remplace le volume de sphères + attaché au sous-marin (qui tournait avec lui). +- Brouillard, lumière directionnelle et particules de la scène de test + actuelle sont conservés et réglés par site. +- Passe lumière du 2026-10-06 (`dive_site.tscn`) : l'ambiance vient d'un ciel + procédural sous-marin (haut turquoise, horizon aligné sur le brouillard, bas + presque noir), ce qui éclaire les faces à l'ombre ; la source d'ambiance + « ciel » était déclarée sans ciel, d'où les ombres noires. Directionnelle + plus haute et plus forte (62°, 1,35) avec ombres douces à 70 % d'opacité, + SSAO pour l'occlusion à l'échelle du maillage, tonemap filmique, perspective + aérienne et albédo du brouillard volumétrique teintés. +- Lumière par profondeur (même jour) : le shader atténue la lumière du jour + par Beer-Lambert spectral (absorption par canal 0,11 / 0,04 / 0,032 par + mètre, normalisée à 24 m) ; la partie scalaire réduit le soleil (fonction + `light()`, directionnelle seulement) et l'ambiance ciel (`AO`), la partie + chromatique teinte l'albédo. Les projecteurs du sous-marin ne sont pas + atténués. Plancher à 5 % pour que le pied du tombant reste lisible. Palette + GDP §18.2 obtenue de fait : vert-bleu à 24 m, bleu froid à 45 m, indigo au + pied du tombant ; ciel, brouillard et soleil (0,9, ombres 45 %) refroidis. +- Brouillard par profondeur : un `FogVolume` (`WaterColumn`, 1,8 km × 260 m) + porte un shader `fog` (`water_column_fog.gdshader`) dont l'albédo suit la même + transmittance spectrale que le fond et dont la densité croît lentement sous + 24 m ; la densité volumétrique globale passe à 0, la portée volumétrique à + 128 m, le brouillard de distance (couleur fixe, 0,0045) ne couvre que + l'au-delà. Tuilage du sable cassé par une déformation de domaine lente + (37 m), une seconde échelle de rides (11,3 m) et une extinction des rides + entre 40 et 60 m de fond. + +**État du 2026-10-06 (demande d'Olivier)** : le shader mélange sable et roche +par la pente à partir de 20° (sable sous 20°, roche au-dessus de 40°, fondu +entre ; le matériau sonar bascule à 20°). La roche est en normal map +**triplanaire** monde (deux échelles, 7 m et 1,7 m) ; le sable porte une normal +map de rides orientée par la houle sur le plan xz ; une carte de grain sert +d'occlusion de détail et de marbrure d'albédo. Les trois cartes sont +procédurales, sans asset, construites par `SeabedDetailTextures` (bruit seedé, +sans couture, mipmaps) dans la couche monde. Le maillage n'a pas de tangentes : +les normales perturbées sont calculées en espace monde dans le shader. + +## 8. Architecture proposée + +| Couche | Élément | Responsabilité | +|---|---|---| +| `game/domain/sites/` | `DiveSiteDefinition` (Resource typée) | identifiant, nom affiché, `SiteTerrainData`, limites jouables, position et cap de départ, placements (épaves, leurres, objet secondaire, débris), routes de contrôle | +| `game/domain/sites/` | `TerrainHeightField` (RefCounted pur) | lecture de hauteur et de matériau à une position, conversion monde ↔ cellule, bornes ; testable sans scène | +| `game/application/sites/` | `PlayableBoundsRule` | « hors zone » comme résultat typé depuis la télémétrie ; émet un avertissement, jamais un mur | +| `game/world/sites/` | `BathymetricTerrainActor` | construit tuiles, collision et matériau depuis la définition ; expose matériau sonar par position | +| `game/world/sites/` | `dive_site.tscn` (générique, configurée par une `DiveSiteDefinition`) | scène de site : terrain, environnement, points d'injection pour épaves et navire | +| `game/composition/` | câblage | charge la définition, instancie le site, injecte la règle de bornes | +| `content/sites/` | `zone1_vigilant.tres` + `zone1_vigilant_terrain.res` | données du site | +| `tools/` (hors runtime) | script de cuisson | produit le `.res` depuis une description de primitives géomorphologiques ; déterministe, versionné, jamais appelé par le jeu | + +Les conventions du contrat : hauteur en mètres négatifs vers le bas, +X est, +-Z nord (convention Godot, convertie une seule fois dans +`TerrainHeightField`). Le terrain est stocké en `SiteTerrainData` au format +`.res` (élévations en float32 et matériaux en octets) produit par la recette +de cuisson ; le PNG 16 bits est écarté (ADR 0012). Ces points sont formalisés +dans l'**ADR 0012 — terrain de site en champ de hauteur authoré**, requis par +les guardrails §5 et §11 (nouveau contrat de données persistant dans le +dépôt). + +`vehicle_test_environment.gd` perd sa formule analytique : la formule devient +le premier cas d'usage du script de cuisson, et la scène de test charge une +petite définition de site (fixture) comme le ferait le vrai site. + +## 9. Pipeline d'auteur + +1. **Graybox cuit** (cette tranche) : une description déclarative des + primitives (plateau, crête, pitons, sillon, rupture, tombant, rides) + dans le script de cuisson. Itération en minutes, diff lisible, aucun outil + externe. C'est « conçu à la main » : chaque forme est placée et nommée. +2. **Passe de sculpture** (tranche 6) : export de la heightmap vers Blender, + retouche manuelle, réimport. Le contrat ne change pas. +3. **Jamais** de génération à l'exécution ni de dépendance à un plugin + terrain ; si un plugin devenait souhaitable, ADR d'abord. + +## 10. Tests + +- **Unitaires** : `TerrainHeightField` (interpolation bilinéaire, bornes, + conversion monde ↔ cellule, matériau par cellule) ; `PlayableBoundsRule`. +- **Acteur** : hauteur de collision égale à la hauteur du champ en plusieurs + points, dont les quatre coins (ferme le doute du §2). +- **Contrats** sur `zone1_vigilant.tres` : trois placements d'épave dans les + limites ; cible et objets requis au-dessus de 60 m ; objet secondaire entre + 60 et 69 m ; les amers listés sont uniques (pas deux crêtes de même + signature) ; les deux routes existent sous forme de chemins de contrôle + (liste de points) dont chaque point respecte sa profondeur annoncée et + n'est pas dans la roche. +- **Intégration** : charger le site, lancer un scan de référence depuis la + position de départ et vérifier roche et sable dans le buffer ; un rayon + vers le sillon touche le terrain ; temps de chargement sous 3 s. +- **Humains** (playtest tranche 3) : 8/10 trouvent l'accès en 5–10 min ; les + testeurs nomment spontanément au moins un amer. + +## 11. Ordre de construction + +1. ADR 0012, `DiveSiteDefinition`, `TerrainHeightField` et leurs tests. +2. `BathymetricTerrainActor` avec tuiles, collision et matériau sonar par + position ; migration de l'environnement de test sur une fixture. +3. Script de cuisson et première cuisson du Banc du Vigilant (graybox) ; + bornes jouables ; scène générique `dive_site.tscn` configurée par une + `DiveSiteDefinition`. +4. Shader triplanaire et props bornés. +5. Points d'injection pour les trois épaves et le navire (les épaves + elles-mêmes appartiennent au reste de la tranche 3). + +Chaque étape laisse la barrière verte et la scène jouable. + +## 12. Décisions demandées à Olivier + +1. **Validation du plan de masse** (§4.2–4.4) : trois épaves, deux routes, un + tombant tentateur. Variante possible : déplacer le leurre 2 dans la Saignée + pour piéger la route risquée. +2. **Objet secondaire à 62 m** : garder la tentation sous la profondeur + certifiée, ou le remonter à 55 m pour un premier site plus doux ? +3. **Pipeline** : script de cuisson d'abord (recommandé) ou sculpture Blender + dès le graybox ? +4. **Nom** : « Banc du Vigilant » ou autre. diff --git a/docs/briefs/terrain-varied-landforms.md b/docs/briefs/terrain-varied-landforms.md new file mode 100644 index 0000000..7949ed0 --- /dev/null +++ b/docs/briefs/terrain-varied-landforms.md @@ -0,0 +1,205 @@ +# Proposition — formes de relief variées pour les sites de plongée + +**Statut : exécuté en autonomie le 2026-10-06** sur demande d'Olivier +(« implémente ce genre de terrains variés, terrain statique, ne te concentre que +sur le terrain, pas les effets de rendu ou la végétation »). Les décisions +ouvertes sont listées en §8. + +Rédigée à partir de cinq photos de référence fournies par Olivier, de l'ADR +0012, de `tools/site_terrain_baker.gd` et du brief +`docs/briefs/terrain-site-zone1-gameplay.md`. + +--- + +## 1. Objectif + +Étendre le vocabulaire de formes du script de cuisson pour que les sites +suivants (U-Boot « sous falaise », navire océanographique « dans un canyon », +GDP §15) puissent être authorés avec le même pipeline que le Banc du Vigilant : +canyons à parois raides et strates, crevasses étroites, éperons et chaos de +blocs, buttes rocheuses, falaises sinueuses. Terrain statique, surface unique +(ADR 0010), aucun changement de rendu ni de végétation. + +Terminé quand : + +- chaque forme des cinq références a une primitive nommée, testée unitairement, + dans `tools/site_terrain_baker.gd` ; +- un site de démonstration cuit (`content/sites/terrain_showcase.tres`) montre + les cinq formes côte à côte, se charge en moins de 3 s et passe des tests de + contrat géométriques (profondeur, pente, largeur, matériau) ; +- le Banc du Vigilant est cuit à l'identique (aucun octet de + `zone1_vigilant_terrain.res` ne change) ; +- `bash scripts/quality_gate.sh` passe. + +## 2. Lecture des références + +| Photo | Ce qu'on voit | Forme de relief | Ce qu'un champ de hauteur peut en garder | +|---|---|---|---| +| 1 | Deux masses rocheuses alvéolées encadrant un chenal de sable grossier | éperons rocheux + chenal | deux buttes aux bords irréguliers, chenal plat entre elles, rugosité forte sur la roche | +| 2 | Fente très étroite et profonde entre deux parois quasi verticales | crevasse | sillon de 5–6 m de large, 20 m de profondeur, parois à 80°+ ; pas de surplomb | +| 3 | Pente de mamelons rocheux arrondis | mamelons | buttes à flancs doux sur une pente, rugosité moyenne | +| 4 | Canyon aux parois verticales **stratifiées**, fond de sable plat **ridé**, mesas en haut | canyon à strates | tranchée à fond plat, parois raides quantifiées en gradins horizontaux, rides sur le fond, plateaux sommitaux | +| 5 | Platier : têtes de corail et blocs épars sur sable ridé | chaos de blocs | champ de demi-ellipsoïdes de 2–6 m de rayon, matériau roche, sable ridé entre | + +Hors périmètre, volontairement : lumière, caustiques, couleur de l'eau, +herbier, corail (rendu et végétation). + +## 3. Ce qui existe et ce qui manque + +Le cuiseur additionne des primitives `elevation_delta(x, z)` puis déduit le +matériau de la pente (roche au-dessus de 20°) et applique des `MaterialPatch` +circulaires. Les primitives actuelles (`SlopedPlane`, `Blob`, `Ridge`, +`Groove`, `Step`, `Ripples`, `Roughness`) sont toutes **lisses** (gaussiennes +ou `smoothstep`) et **mono-segment**. Il manque : + +1. des **tracés en polyligne** (canyon, crevasse, falaise sinueuse) ; +2. des **bords irréguliers** (bruit de contour) pour casser l'aspect « gaussien » ; +3. des **formes à sommet plat et flancs raides** (butte, mesa) ; +4. des **champs d'objets discrets** combinés par maximum, pas par somme + (blocs qui ne fusionnent pas en bosse) ; +5. une phase de **modification** après la somme, qui connaît la pente locale, + pour quantifier les parois en **strates** ; +6. un **matériau imposé par une primitive** (un bloc est roche même sur son + sommet plat). + +## 4. Décision : extension du cuiseur + +### 4.1 Contrat + +- `Primitive.elevation_delta(x_m, z_m) -> float` : inchangé, additif. +- `Primitive.material_at(x_m, z_m) -> int` : nouveau, renvoie + `NO_MATERIAL` (-1) par défaut ; une valeur `SonarMaterial.Value` l'emporte sur + la règle de pente, dans l'ordre des primitives, avant les `MaterialPatch`. +- `Modifier.modify(x_m, z_m, elevation_m, gradient) -> float` : nouvelle phase. + `gradient` est la pente locale `Vector2(dE/dx, dE/dz)` calculée par + différences centrées sur le champ courant ; les modificateurs s'enchaînent + dans l'ordre de `Recipe.modifiers`, après la somme et avant le plancher. +- Ordre de cuisson : somme des primitives → modificateurs → plancher + `floor_elevation_m` → matériau par pente → matériau par primitive → + `MaterialPatch`. + +### 4.2 Nouvelles primitives + +| Primitive | Paramètres | Forme | +|---|---|---| +| `Canyon` | `path: PackedVector2Array`, `half_width_m`, `depth_m`, `wall_softness_m`, `edge_seed`, `edge_amplitude_m`, `floor_material` | tranchée à fond plat le long d'une polyligne ; parois `smoothstep` sur `wall_softness_m` ; distance au tracé perturbée par un bruit de contour ; se referme sur une demi-largeur à chaque bout ; impose `floor_material` (sable en général) sur le fond plat, même à travers une butte rocheuse | +| `Outcrop` | `centre`, `radius_x_m`, `radius_z_m`, `height_m`, `wall_width_m`, `edge_seed`, `edge_amplitude_m`, `rock_top` | butte elliptique à sommet plat, flanc de largeur `wall_width_m`, contour perturbé ; matériau roche partout où elle dépasse 30 % de sa hauteur si `rock_top` | +| `BoulderField` | `centre`, `radius_m`, `seed`, `count`, `boulder_radius_min_m`, `boulder_radius_max_m`, `height_ratio`, `angularity` (0–1) | `count` blocs tirés par `RandomNumberGenerator` seedé dans le disque, combinés par **maximum** ; roche sur toute leur emprise ; `angularity` 0 = demi-ellipsoïdes, 1 = dalles en super-ellipse allongées, orientées et basculées au hasard, sommet plat et épaules vives | +| `Escarpment` | `path: PackedVector2Array`, `drop_m`, `softness_m`, `slope_beyond`, `edge_seed`, `edge_amplitude_m` | `Step` le long d'une polyligne : le terrain descend à **droite** du sens de parcours ; ligne de crête perturbée par un bruit de contour | + +### 4.3 Nouveau modificateur + +| Modificateur | Paramètres | Effet | +|---|---|---| +| `Strata` | `layer_height_m`, `sharpness` (0–1), `minimum_slope_degrees`, `full_slope_degrees`, `region_m: Rect2` (vide = partout), `dip`, `warp_seed`, `warp_amplitude_m`, `thickness_variation` | quantifie l'élévation en gradins d'environ `layer_height_m` là où la pente dépasse `minimum_slope_degrees` (plein effet à `full_slope_degrees`) ; `sharpness` 0 = aucun effet, 1 = marches franches ; le fond plat et le plateau ne bougent pas ; `dip` incline la pile, le warp courbe et encoche les bancs, chaque banc a son épaisseur et sa dureté, les bornes restent ordonnées (pas d'inversion) | + +Formule : `f = frac(e / h)`, `f' = clamp((f - 0.5) / (1 - s) + 0.5, 0, 1)`, +`e' = lerp(e, floor(e / h) * h + h * f', w)` avec +`w = smoothstep(tan(min), tan(full), |gradient|) * masque`. + +### 4.3 bis Relief de fond (ajout du 2026-10-06, demande d'Olivier) + +Les plateaux cuits avec la seule `Roughness` à 50 m (± 0,6 m) restaient +plats. `Roughness` prend maintenant un nombre d'octaves et un type fractal +(`FRACTAL_FBM` roulant, `FRACTAL_RIDGED` à crêtes) : une fréquence de 0,004 +sur cinq octaves donne des mamelons et des cuvettes de 250 m à 15 m en une +primitive. Le modificateur `SlopeRoughness` (`seed`, `frequency`, +`amplitude_m`, `minimum_slope_degrees`, `full_slope_degrees`) ajoute un bruit +à crêtes pondéré par la pente : la roche s'éraille, le sable reste lisse. Les +deux sites l'utilisent (ondulation ± 2,2 m, roche ± 0,7 m). + +### 4.4 Ce qui ne change pas + +Le contrat de données (`SiteTerrainData`, ADR 0012 §2), l'acteur de monde, la +collision, le sonar, le shader. Les `.res` restent les artefacts dérivés ; la +recette reste le document d'auteur. Le bruit de contour est un +`FastNoiseLite` seedé, donc déterministe. + +## 5. Site de démonstration : `terrain_showcase` + +But : prouver chaque forme en jeu et au sonar, servir de catalogue pour +l'auteur des sites suivants. Pas un site de mission : un seul placement +(balise de mouillage), aucune épave, aucune route. + +| Donnée | Valeur | +|---|---| +| Champ | 513 × 513 cellules, 1,0 m, origine (-256, -256) | +| Zone jouable | 440 × 440 m centrée | +| Plateau de base | 30 m, incliné de 4 m vers le sud | +| Profondeur min / max | 18 m / 80 m | +| Départ | (0, -12, 0), cap nord | + +Plan de masse (nord en haut) : + +```text + +---------------------------------------------+ + | NW : canyon à strates NE : crevasse | + | mesa à -24 m, tranchée fente 6 m de | + | 28 m de large au sommet, large, 20 m | + | 20 m de profond, parois à de profond, | + | 55°, gradins 4 m, fond ridé entre buttes | + | [départ] | + | SW : chaos de blocs SE : éperons | + | 90 blocs r 2–6 m sur deux buttes | + | sable ridé alvéolées, | + | chenal 20 m | + | ====== falaise sinueuse (Escarpment) ===== | + | tombant vers -80 m, mamelons sur la pente | + +---------------------------------------------+ +``` + +## 6. Tests + +- **Unitaires (`run_site_baker_tests.gd`)** : `Canyon` (fond plat à la + profondeur demandée, nul hors tracé, suit le coude d'une polyligne, + fermeture aux bouts) ; `Outcrop` (sommet à `height_m`, roche sur le sommet + si `rock_top`, nul à deux rayons) ; `BoulderField` (déterministe, nul hors + disque, roche sur un bloc, maximum et non somme) ; `Escarpment` (côté droit + plus bas, drop égal à `drop_m`) ; `Strata` (une pente raide devient des + gradins : au moins deux niveaux plats ; un fond plat ne bouge pas) ; + cuisson du Banc du Vigilant identique à l'artefact versionné. +- **Contrat (`run_site_content_tests.gd`, section showcase)** : canyon + (profondeur ≥ 18 m sous le sommet bruité de la mesa, fond de sable plat à + ± 1 m sur 10 m, paroi ≥ 60° quelque part sur une transversale, ≥ 2 replats + distincts) ; crevasse (fond de sable ≥ 16 m sous le plateau ouvert, 3 à 8 m + de large à 10 m sous le plateau) ; chaos (roche sur un + bloc, sable entre deux blocs, ≥ 40 cellules de roche distinctes… mesuré par + fraction de roche dans le disque) ; éperons (chenal plus bas que les deux + buttes, roche sur chaque butte) ; falaise (marche ≥ 18 m mesurée dans la + seule bande de crête, pour que la pente en contrebas ne masque pas une + marche absente) ; bornes et profondeurs globales. +- **Intégration (`run_site_terrain_integration.gd`)** : chargement du showcase + sous 3 s, rayon dans le canyon plus bas que sur la mesa, scan de référence + lisant roche et sable. + +## 7. Documentation + +- ADR 0012 : amendement §3 (liste des primitives, phase de modification, + matériau par primitive), sans changer le contrat de données. +- `content/README.md`, `tests/README.md` : le site de démonstration. +- Captures sous `docs/briefs/captures/` : `terrain_showcase_terrain_preview.png` + (champ de hauteur vu de dessus, roche teintée) et cinq vues en jeu, graybox, + `terrain_showcase_canyon.png` (gradins et fond ridé), `_crevasse.png`, + `_boulders.png`, `_spurs.png`, `_escarpment.png` (face de la falaise vue du + pied) ; pour le Banc du Vigilant recuit, `zone1_vigilant_tombant.png` + (gradins du tombant), `zone1_vigilant_plateau.png` (plateau ondulé) et + `zone1_vigilant_patch_reef.png` (têtes de corail autour du caboteur). Les + captures sont faites avec le shader à normal maps et ombrage de détail + (brief du Banc du Vigilant, §7) ; brouillard et lumière restent ceux de la + scène de site. + +## 8. Décisions laissées à Olivier + +1. **Résolution** : le showcase est cuit à 1,0 m par cellule (crevasse de 6 m + lisible). Les gradins imposent une paroi d'au moins 2 à 3 cellules par + couche : à 1 m par cellule, des couches de 4 m sur une paroi à 55° ; une + paroi à 76° (première cuisson) ne montrait aucun gradin. Les sites de mission restent à 1,5 m sauf décision contraire ; une + crevasse de 5 m à 1,5 m par cellule n'a que trois cellules de large. +2. **Strates sur le Banc du Vigilant** : décidé par Olivier le 2026-10-06, + appliquées à la rupture et au tombant (bande z ≥ 118 m, couches de 4 m, + plein effet dès 40° ; la Saignée, l'Échine et les amers sont hors bande). + Le `.res` du site a été recuit ; l'objet secondaire, les débris et les + routes restent dans leurs contrats. +3. **Sites de mission suivants** (U-Boot sous falaise, océanographique en + canyon) : les primitives sont prêtes ; le plan de masse de chaque site + reste à proposer et valider séparément. diff --git a/docs/superpowers/plans/2026-10-06-terrain-site-zone1.md b/docs/superpowers/plans/2026-10-06-terrain-site-zone1.md new file mode 100644 index 0000000..4cf861a --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-terrain-site-zone1.md @@ -0,0 +1,2965 @@ +# Terrain du site zone I (« Banc du Vigilant ») — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the analytic test terrain with a data-driven, authored heightfield site (terrain, bounds, spawn, placements, routes), bake the zone I site "Banc du Vigilant" from a declarative recipe, and make it the terrain the player dives on from `Nouvelle partie`. + +**Architecture:** A pure domain model (`SiteTerrainData` Resource + `TerrainHeightField` sampler + `DiveSiteDefinition` Resource) is produced offline by a GDScript bake tool under `tools/` and stored under `content/sites/`. One world actor (`BathymetricTerrainActor`) builds tiled meshes and a `HeightMapShape3D` from the same field, and answers per-position sonar material queries. A generic world scene (`dive_site.tscn`) places graybox placeholders from the definition. Composition selects a site definition, instantiates the world, spawns the submarine, and evaluates an application `PlayableBoundsRule` each tick. + +**Tech Stack:** Godot 4.7.2 (Forward+), GDScript, headless SceneTree test runners, `bash scripts/quality_gate.sh`. + +**Spec:** `docs/briefs/terrain-site-zone1-gameplay.md` (validated 2026-10-06). Read it first. + +## Global Constraints + +- Godot exactly 4.7.2. Local binary for the executor (Git Bash): + `export GODOT_BIN="/c/Users/olivi/Downloads/Godot_v4.7.2-stable_win64.exe/Godot_v4.7.2-stable_win64_console.exe"`. + Every Godot command below is written as `"$GODOT_BIN" --headless --path . ...`. +- Layer rules from `AGENTS.md`: domain imports nothing from other layers; application imports only domain; world imports application and domain; presentation never imports world; composition wires everything. `scripts/check_architecture.sh` enforces this with `rg`. +- No `res://src/`, `res://scenes/`, `res://tests/` imports in `game/`. No autoload. No plugin. +- No dictionaries across production boundaries: typed Resources / RefCounted with `validation_error() -> String` (existing convention, see `game/domain/rules/dive_difficulty_profile.gd`). +- GDScript style in repo: tabs, static typing, `preload` constants at top, `class_name`, snake_case, French user-facing strings via `tr()`. +- Test runner convention: `extends SceneTree`, `_failures: Array[String]`, `_expect(condition, label)`, `const TEST_COUNT := N` equal to the number of `_expect` calls, prints `": %d passed, 0 failed."` and `quit(0)`, else `push_error` each failure and `quit(1)`. Every new suite MUST be added to `scripts/quality_gate.sh` (a suite not in the gate is not a validation). +- Spec numbers (verbatim): playable zone 600 × 600 m; terrain 768 × 768 m; cell 1.5 m; terrain depth 20 m to 88 m; vessel anchor on ~24 m of seabed, north centre; spawn 10 m deep under the vessel; target wreck ~35 m in the groove; decoy 1 (sail yacht) ~26 m on open sand; decoy 2 (steel coaster) in a patch reef east of the ridge; secondary item at 62 m (between 100 % and 115 % of the 60 m certified depth, i.e. 60–69 m); safe route via the ridge 26–32 m; risky route via the groove 36–48 m, groove 12–18 m wide, walls 8–10 m; sonar reflectivity rock 0.72, sand 0.45, metal 1.0. +- Spec deviation (decided here, record in ADR 0012): heights are NOT stored as a 16-bit PNG. Godot's `Image` PNG loader converts 16-bit PNGs to 8-bit, which at a 68 m range gives 0.27 m steps, too coarse for sonar relief. Heights are stored as `PackedFloat32Array` inside a binary `.res` `SiteTerrainData` resource written by the bake tool. An 8-bit preview PNG is written only for humans. +- Working tree warning: another session has uncommitted cockpit changes (`game/presentation/cockpit/*`, `game/world/sites/vehicle_test_environment.tscn`, `tests/integration/run_vertical_slice_foundation.gd`, `project.godot`). This plan modifies the last two. Before Task 1, either commit that work with its owner or execute this plan in a worktree branched from `main` (superpowers:using-git-worktrees) and rebase at the end. Never `git checkout --`/`stash` someone else's changes. +- Commit after each task. Commit messages end with `Co-Authored-By: Claude Fable 5.1 `. + +## File Structure + +| Path | Responsibility | +|---|---| +| `docs/adr/0012-authored-heightfield-dive-sites.md` | Contract: authored heightfield, units, axes, storage, bounds as rule not relief | +| `game/domain/sonar/sonar_material.gd` (modify) | Add `SAND` | +| `game/domain/sites/site_terrain_data.gd` | Resource: grid metadata + elevations + materials + reflectivities | +| `game/domain/sites/terrain_height_field.gd` | Pure sampler: bilinear height, nearest material, normals, bounds, cell↔world | +| `game/domain/sites/site_placement.gd` | Resource: one authored object (wreck, decoy, item, debris, landmark, anchor) | +| `game/domain/sites/site_route.gd` | Resource: control waypoints for one route | +| `game/domain/sites/dive_site_definition.gd` | Resource: site id, terrain, bounds, spawn, placements, routes + validation | +| `game/application/sites/playable_bounds_status.gd` | Typed result of the bounds rule | +| `game/application/sites/playable_bounds_rule.gd` | Inside / near edge / outside from a position | +| `game/world/sites/bathymetric_terrain_actor.gd` | StaticBody3D: tiled meshes + HeightMapShape3D + `sonar_material_at` | +| `game/world/sites/seabed_terrain.gdshader` | Slope-blended sand/rock, depth tint, ripples | +| `game/world/sites/dive_site.gd` + `dive_site.tscn` | Generic site scene: environment, terrain actor, placeholders from definition | +| `game/world/sites/vehicle_test_environment.gd/.tscn` (delete) | Replaced by `dive_site.tscn` + `content/sites/vehicle_test_site.tres` | +| `game/world/sonar/reference_bathymetry_acquisition.gd` (modify) | Per-position material via `sonar_material_at` when available | +| `game/composition/vertical_slice/dive_world.tscn` | Replaces `vehicle_test_world.tscn`: DiveSite + SubmarineActor (+ particles) + cockpit | +| `game/composition/vertical_slice/vertical_slice_root.gd` (modify) | `select_dive_site`, spawn from definition, bounds rule each tick | +| `game/presentation/cockpit/cockpit_hud.gd/.tscn` (modify) | `present_bounds(status)` warning panel | +| `tools/site_terrain_baker.gd` | Primitives + bake to `SiteTerrainData` + preview PNG | +| `tools/sites/vehicle_test_terrain_recipe.gd` | Recipe reproducing the current analytic calibration terrain | +| `tools/sites/zone1_vigilant_recipe.gd` | Recipe of the Banc du Vigilant | +| `tools/bake_site_terrain.gd` | Headless entry point: `-- ` | +| `content/sites/vehicle_test_terrain.res`, `vehicle_test.tres` | Calibration site data | +| `content/sites/zone1_vigilant_terrain.res`, `zone1_vigilant.tres` | Zone I site data | +| `tests/unit/run_site_domain_tests.gd` | Terrain field, data, definition validation | +| `tests/unit/run_site_application_tests.gd` | Bounds rule | +| `tests/unit/run_site_baker_tests.gd` | Baker determinism and primitives | +| `tests/integration/run_site_terrain_integration.gd` | Actor collision = field, sonar materials per cell, scene load time | +| `tests/contract/run_site_content_tests.gd` | Zone I content rules from the spec | +| `scripts/quality_gate.sh` (modify) | Add the four suites | +| `tests/run_load_checks.gd` (modify) | Add `res://tools` to `SEARCH_ROOTS` | +| `tests/README.md`, `content/README.md`, `docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`, `docs/briefs/terrain-site-zone1-gameplay.md` (modify) | Document the new suites, data, status, and the `.res` deviation | + +Coordinate contract used everywhere below: Godot world axes. `+X` = east, `+Z` = south (so `-Z` = north), `+Y` = up; elevation values are world `Y` in metres (negative under the surface; depth = `-Y`). Grid column index grows with `+X`, row index grows with `+Z`. Cell `(column, row)` is at world `(origin_x_m + column * cell_size_m, elevation, origin_z_m + row * cell_size_m)`. `Rect2` bounds: `position = (x_min, z_min)`, `size = (width_x, depth_z)` in metres. + +--- + +### Task 1: ADR 0012 — authored heightfield dive sites + +**Files:** +- Create: `docs/adr/0012-authored-heightfield-dive-sites.md` + +- [ ] **Step 1: Write the ADR** + +```markdown +# 0012 — Sites de plongée en champ de hauteur authoré + +- Statut : accepté +- Date : 2026-10-06 +- Remplace : aucun + +## Contexte + +Le seul terrain de production est une formule analytique codée dans +`vehicle_test_environment.gd`. La tranche 3 exige un site conçu à la main, +lisible au sonar (ADR 0010 : surface unique), avec une route sûre et une route +risquée, et le sonar a besoin de distinguer roche et sable par cellule, pas par +corps. Il faut un contrat de données pour le terrain, son stockage dans le +dépôt et sa production. + +## Décision + +1. Un site de plongée est décrit par une ressource typée `DiveSiteDefinition` + (`game/domain/sites/`) : identifiant, terrain, limites jouables, point de + départ, placements d'objets et routes de contrôle. +2. Le terrain est un champ de hauteur à surface unique `SiteTerrainData` : + grille régulière de `columns × rows` cellules de `cell_size_m` mètres, + élévations en mètres (axe Y monde, négatif sous la surface) dans un + `PackedFloat32Array`, matériau sonar par cellule dans un `PackedByteArray` + (`SonarMaterial.Value`, ROCK ou SAND). Repère : +X est, +Z sud, +Y haut ; + la colonne croît avec +X, la ligne avec +Z ; la cellule `(0, 0)` est à + `(origin_x_m, origin_z_m)`. +3. Les données sont cuites hors exécution par `tools/bake_site_terrain.gd` à + partir d'une recette déclarative de primitives géomorphologiques nommées + (plateau, crête, piton, sillon, rupture de pente, rides, rugosité + déterministe par graine). La recette est le document d'auteur versionné ; + le résultat est écrit en `.res` binaire sous `content/sites/`. Le jeu ne + génère jamais de terrain à l'exécution. +4. Un PNG 16 bits n'est pas utilisé pour les hauteurs : le chargeur d'images + de Godot ramène les PNG 16 bits en 8 bits, soit des marches de 0,27 m sur + 68 m de dénivelé. Un PNG 8 bits de prévisualisation est produit pour la + revue humaine seulement. +5. Un seul acteur de monde, `BathymetricTerrainActor`, construit le maillage + visuel et la `HeightMapShape3D` depuis le même champ ; un test compare les + deux. Il expose `sonar_material_at(position)` et + `sonar_reflectivity_at(position)` que l'acquisition sonar privilégie sur + les métadonnées de corps. +6. Les limites de la zone jouable sont une règle applicative + (`PlayableBoundsRule`) qui produit un avertissement ; elles ne sont jamais + un mur de terrain (ADR 0010). + +## Conséquences + +- L'environnement de test du véhicule devient une définition de site cuite, + sans formule analytique dans `game/`. +- Toute modification de terrain passe par la recette puis une cuisson ; les + fichiers `.res` sont des artefacts dérivés mais versionnés pour que le + chargement du site reste sous 3 s et que le jeu n'ait pas besoin de l'outil. +- Les tests de contrat de contenu peuvent vérifier profondeur, position et + dégagement des placements contre le champ de hauteur réel. +- Une sculpture manuelle (Blender) ultérieure devra ré-exporter vers le même + `SiteTerrainData` ; le contrat ne change pas. + +## Alternatives considérées + +- **Plugin de terrain externe** : rejeté, dépendance et ADR supplémentaires + sans besoin démontré ; une heightmap tuilée suffit à la V1. +- **Génération à l'exécution depuis la recette** : rejeté, le plan impose un + site conçu à la main et un chargement borné ; la cuisson garantit un + artefact figé et revu. +- **Heightmap PNG 16 bits** : rejeté pour la raison de précision ci-dessus. +- **Maillage sculpté sans champ de hauteur** : rejeté, le sonar et les tests + ont besoin d'une requête de hauteur et de matériau par position. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/adr/0012-authored-heightfield-dive-sites.md +git commit -m "docs: ADR 0012 authored heightfield dive sites + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: `SonarMaterial.SAND` + +**Files:** +- Modify: `game/domain/sonar/sonar_material.gd` +- Test: `tests/unit/run_sonar_application_tests.gd` + +**Interfaces:** +- Produces: `SonarMaterial.Value.SAND` (int 3). `SonarMaterial.is_valid(3) == true`. + +- [ ] **Step 1: Add failing assertions** + +In `tests/unit/run_sonar_application_tests.gd`, inside `_test_sample_buffer_contract()` after the first `_expect(sample.validation_error()...)` line, add: + +```gdscript + var sand_sample := SonarSample.new( + Vector3(2.0, -4.0, 9.0), + Vector3.UP, + 0.4, + -4.0, + SonarMaterial.Value.SAND, + true + ) + _expect(sand_sample.validation_error().is_empty(), "sand is a valid terrain material") + _expect(SonarMaterial.is_valid(SonarMaterial.Value.SAND), "sand is inside the material range") +``` + +Increase `TEST_COUNT` by 2 (32 → 34). + +- [ ] **Step 2: Run, expect failure** + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_sonar_application_tests.gd +``` +Expected: parse error `Identifier "SAND" not declared` (quit non-zero). + +- [ ] **Step 3: Implement** + +`game/domain/sonar/sonar_material.gd`: + +```gdscript +class_name SonarMaterial +extends RefCounted + +enum Value { + UNKNOWN, + ROCK, + METAL, + SAND, +} + + +static func is_valid(value: int) -> bool: + return value >= Value.UNKNOWN and value <= Value.SAND + + +static func is_terrain(value: int) -> bool: + return value == Value.ROCK or value == Value.SAND +``` + +- [ ] **Step 4: Run, expect pass** + +Same command. Expected: `Sonar application unit tests: 34 passed, 0 failed.` +Also run `bash scripts/check_architecture.sh` → `Architecture checks: passed.` + +- [ ] **Step 5: Commit** + +```bash +git add game/domain/sonar/sonar_material.gd tests/unit/run_sonar_application_tests.gd +git commit -m "feat(sonar): add SAND terrain material + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: `SiteTerrainData` and `TerrainHeightField` (domain) + +**Files:** +- Create: `game/domain/sites/site_terrain_data.gd` +- Create: `game/domain/sites/terrain_height_field.gd` +- Create: `tests/unit/run_site_domain_tests.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- Produces `SiteTerrainData extends Resource` with `@export` fields `columns: int`, `rows: int`, `cell_size_m: float`, `origin_x_m: float`, `origin_z_m: float`, `elevations_m: PackedFloat32Array`, `materials: PackedByteArray`, `rock_reflectivity: float`, `sand_reflectivity: float`; `validation_error() -> String`; `index_of(column, row) -> int`. +- Produces `TerrainHeightField extends RefCounted`: `static func from_data(data: SiteTerrainData) -> TerrainHeightField` (null when invalid); `columns()`, `rows()`, `cell_size_m()`, `origin_m() -> Vector2` (x, z), `bounds_m() -> Rect2`, `contains(x_m, z_m) -> bool`, `elevation_at_cell(column, row) -> float`, `material_at_cell(column, row) -> int`, `cell_world_position(column, row) -> Vector3`, `normal_at_cell(column, row) -> Vector3`, `elevation_at(x_m, z_m) -> float` (bilinear, clamped to the grid), `material_at(x_m, z_m) -> int` (nearest cell), `reflectivity_for(material: int) -> float`, `minimum_elevation_m()`, `maximum_elevation_m()`. + +- [ ] **Step 1: Write the failing test suite** + +`tests/unit/run_site_domain_tests.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") + +const TEST_COUNT := 20 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_terrain_data_validation() + _test_height_field_sampling() + + if _failures.is_empty(): + print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site domain unit tests: %d failed." % _failures.size()) + quit(1) + + +func _make_terrain(columns: int, rows: int) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = columns + data.rows = rows + data.cell_size_m = 2.0 + data.origin_x_m = -10.0 + data.origin_z_m = 100.0 + # Packed arrays are passed by value in GDScript: build locally, then assign. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(columns * rows) + materials.resize(columns * rows) + for row in rows: + for column in columns: + var index := row * columns + column + # Plane sloping down toward +Z (south): -20 at row 0, -2 m per row. + elevations[index] = -20.0 - 2.0 * float(row) + materials[index] = ( + SonarMaterial.Value.ROCK if column == 0 else SonarMaterial.Value.SAND + ) + data.elevations_m = elevations + data.materials = materials + return data + + +func _test_terrain_data_validation() -> void: + var data := _make_terrain(3, 4) + _expect(data.validation_error().is_empty(), "regular terrain data is valid") + _expect(data.index_of(2, 3) == 11, "index is row-major with columns along X") + + var tiny := _make_terrain(1, 4) + _expect(not tiny.validation_error().is_empty(), "terrain needs at least two columns") + + var mismatched := _make_terrain(3, 4) + mismatched.elevations_m.resize(5) + _expect(not mismatched.validation_error().is_empty(), "elevation array must match the grid") + + var non_finite := _make_terrain(3, 4) + non_finite.elevations_m[0] = INF + _expect(not non_finite.validation_error().is_empty(), "elevations must be finite") + + var metal_terrain := _make_terrain(3, 4) + metal_terrain.materials[1] = SonarMaterial.Value.METAL + _expect(not metal_terrain.validation_error().is_empty(), "terrain cells are rock or sand only") + + var bad_cell := _make_terrain(3, 4) + bad_cell.cell_size_m = 0.0 + _expect(not bad_cell.validation_error().is_empty(), "cell size must be positive") + + var bad_reflectivity := _make_terrain(3, 4) + bad_reflectivity.sand_reflectivity = 1.5 + _expect(not bad_reflectivity.validation_error().is_empty(), "reflectivity stays within 0..1") + _expect(TerrainHeightField.from_data(bad_reflectivity) == null, "invalid data yields no field") + + +func _test_height_field_sampling() -> void: + var field := TerrainHeightField.from_data(_make_terrain(3, 4)) + _expect(field != null, "valid data yields a field") + if field == null: + return + _expect(field.bounds_m().is_equal_approx(Rect2(-10.0, 100.0, 4.0, 6.0)), "bounds cover (columns-1) by (rows-1) cells") + _expect(field.cell_world_position(2, 3).is_equal_approx(Vector3(-6.0, -26.0, 106.0)), "cell position follows origin and cell size") + _expect(is_equal_approx(field.elevation_at(-10.0, 100.0), -20.0), "elevation at a vertex is exact") + _expect(is_equal_approx(field.elevation_at(-9.0, 101.0), -21.0), "elevation is bilinear between vertices") + _expect(is_equal_approx(field.elevation_at(-50.0, 1000.0), -26.0), "sampling outside clamps to the nearest edge") + _expect(field.material_at(-10.4, 100.0) == SonarMaterial.Value.ROCK, "material is the nearest cell") + _expect(field.material_at(-6.0, 103.0) == SonarMaterial.Value.SAND, "material of an interior cell") + _expect(field.normal_at_cell(1, 1).is_equal_approx(Vector3(0.0, 1.0, 1.0).normalized()), "normal leans against the downhill direction") + _expect(not field.contains(-11.0, 100.0) and field.contains(-8.0, 103.0), "contains follows the bounds") + _expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +Note on the normal: for the test plane, elevation decreases by 1 m per metre of `+Z` (`-2 m` per `2 m` cell), so `dz = -1`, `dx = 0`, normal = `normalize(Vector3(-dx, 1, -dz)) = normalize(0, 1, 1)`. + +- [ ] **Step 2: Run, expect failure** + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_site_domain_tests.gd +``` +Expected: failure loading `res://game/domain/sites/site_terrain_data.gd` (file missing). + +- [ ] **Step 3: Implement `SiteTerrainData`** + +`game/domain/sites/site_terrain_data.gd`: + +```gdscript +class_name SiteTerrainData +extends Resource + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") + +## Regular single-surface heightfield. Column grows with world +X (east), +## row grows with world +Z (south). Elevations are world Y in metres. +@export var columns: int = 0 +@export var rows: int = 0 +@export var cell_size_m: float = 1.0 +@export var origin_x_m: float = 0.0 +@export var origin_z_m: float = 0.0 +@export var elevations_m := PackedFloat32Array() +@export var materials := PackedByteArray() +@export var rock_reflectivity: float = 0.72 +@export var sand_reflectivity: float = 0.45 + + +func index_of(column: int, row: int) -> int: + return row * columns + column + + +func validation_error() -> String: + if columns < 2 or rows < 2: + return "terrain needs at least 2 columns and 2 rows" + if not is_finite(cell_size_m) or cell_size_m <= 0.0: + return "terrain cell size must be positive" + if not is_finite(origin_x_m) or not is_finite(origin_z_m): + return "terrain origin must be finite" + var cell_count := columns * rows + if elevations_m.size() != cell_count: + return "terrain elevation count does not match the grid" + if materials.size() != cell_count: + return "terrain material count does not match the grid" + for elevation: float in elevations_m: + if not is_finite(elevation): + return "terrain elevations must be finite" + for material: int in materials: + if not SonarMaterial.is_terrain(material): + return "terrain materials must be rock or sand" + for reflectivity: float in [rock_reflectivity, sand_reflectivity]: + if not is_finite(reflectivity) or reflectivity <= 0.0 or reflectivity > 1.0: + return "terrain reflectivity must be within ]0, 1]" + return "" +``` + +- [ ] **Step 4: Implement `TerrainHeightField`** + +`game/domain/sites/terrain_height_field.gd`: + +```gdscript +class_name TerrainHeightField +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") + +var _data: SiteTerrainData +var _minimum_elevation_m := 0.0 +var _maximum_elevation_m := 0.0 + + +static func from_data(data: SiteTerrainData) -> TerrainHeightField: + if data == null or not data.validation_error().is_empty(): + return null + var field := TerrainHeightField.new() + field._data = data + field._minimum_elevation_m = data.elevations_m[0] + field._maximum_elevation_m = data.elevations_m[0] + for elevation: float in data.elevations_m: + field._minimum_elevation_m = minf(field._minimum_elevation_m, elevation) + field._maximum_elevation_m = maxf(field._maximum_elevation_m, elevation) + return field + + +func columns() -> int: + return _data.columns + + +func rows() -> int: + return _data.rows + + +func cell_size_m() -> float: + return _data.cell_size_m + + +func origin_m() -> Vector2: + return Vector2(_data.origin_x_m, _data.origin_z_m) + + +func bounds_m() -> Rect2: + return Rect2( + _data.origin_x_m, + _data.origin_z_m, + float(_data.columns - 1) * _data.cell_size_m, + float(_data.rows - 1) * _data.cell_size_m + ) + + +func contains(x_m: float, z_m: float) -> bool: + return bounds_m().has_point(Vector2(x_m, z_m)) + + +func minimum_elevation_m() -> float: + return _minimum_elevation_m + + +func maximum_elevation_m() -> float: + return _maximum_elevation_m + + +func elevation_at_cell(column: int, row: int) -> float: + return _data.elevations_m[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func material_at_cell(column: int, row: int) -> int: + return _data.materials[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func cell_world_position(column: int, row: int) -> Vector3: + return Vector3( + _data.origin_x_m + float(column) * _data.cell_size_m, + elevation_at_cell(column, row), + _data.origin_z_m + float(row) * _data.cell_size_m + ) + + +func normal_at_cell(column: int, row: int) -> Vector3: + var left := elevation_at_cell(column - 1, row) + var right := elevation_at_cell(column + 1, row) + var north := elevation_at_cell(column, row - 1) + var south := elevation_at_cell(column, row + 1) + var span_x := float(_clamp_column(column + 1) - _clamp_column(column - 1)) * _data.cell_size_m + var span_z := float(_clamp_row(row + 1) - _clamp_row(row - 1)) * _data.cell_size_m + var slope_x := (right - left) / span_x + var slope_z := (south - north) / span_z + return Vector3(-slope_x, 1.0, -slope_z).normalized() + + +func elevation_at(x_m: float, z_m: float) -> float: + var column_f := clampf( + (x_m - _data.origin_x_m) / _data.cell_size_m, + 0.0, + float(_data.columns - 1) + ) + var row_f := clampf( + (z_m - _data.origin_z_m) / _data.cell_size_m, + 0.0, + float(_data.rows - 1) + ) + var column := mini(int(floorf(column_f)), _data.columns - 2) + var row := mini(int(floorf(row_f)), _data.rows - 2) + var tx := column_f - float(column) + var tz := row_f - float(row) + var top := lerpf(elevation_at_cell(column, row), elevation_at_cell(column + 1, row), tx) + var bottom := lerpf( + elevation_at_cell(column, row + 1), + elevation_at_cell(column + 1, row + 1), + tx + ) + return lerpf(top, bottom, tz) + + +func material_at(x_m: float, z_m: float) -> int: + var column := int(roundf((x_m - _data.origin_x_m) / _data.cell_size_m)) + var row := int(roundf((z_m - _data.origin_z_m) / _data.cell_size_m)) + return material_at_cell(column, row) + + +func reflectivity_for(material: int) -> float: + match material: + SonarMaterial.Value.SAND: + return _data.sand_reflectivity + _: + return _data.rock_reflectivity + + +func _clamp_column(column: int) -> int: + return clampi(column, 0, _data.columns - 1) + + +func _clamp_row(row: int) -> int: + return clampi(row, 0, _data.rows - 1) +``` + +- [ ] **Step 5: Run, expect pass** + +Same command. Expected: `Site domain unit tests: 20 passed, 0 failed.` If a count differs, fix the assertion, not `TEST_COUNT`, unless you added or removed an `_expect`. + +- [ ] **Step 6: Add the suite to the gate** + +In `scripts/quality_gate.sh`, after the "production sonar application unit tests" block, insert: + +```bash +printf 'Quality gate: production site domain unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-domain-unit.log" \ + --path . \ + --script tests/unit/run_site_domain_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log" +check_godot_log \ + "production site domain unit tests" \ + "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log" +``` + +Run `bash -n scripts/quality_gate.sh` → no output. + +- [ ] **Step 7: Commit** + +```bash +git add game/domain/sites tests/unit/run_site_domain_tests.gd scripts/quality_gate.sh +git commit -m "feat(sites): typed terrain data and height field sampler + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: `SitePlacement`, `SiteRoute`, `DiveSiteDefinition` (domain) + +**Files:** +- Create: `game/domain/sites/site_placement.gd` +- Create: `game/domain/sites/site_route.gd` +- Create: `game/domain/sites/dive_site_definition.gd` +- Modify: `tests/unit/run_site_domain_tests.gd` + +**Interfaces:** +- `SitePlacement extends Resource`: `enum Kind { WRECK_TARGET, WRECK_DECOY, SECONDARY_ITEM, DEBRIS, LANDMARK, VESSEL_ANCHOR }`; `@export placement_id: StringName`, `kind: Kind`, `position_m: Vector3` (world, absolute Y), `yaw_radians: float`, `footprint_m: Vector3` (graybox size; `Vector3.ZERO` allowed for LANDMARK / VESSEL_ANCHOR); `validation_error() -> String`; `is_sonar_metal() -> bool` (true for WRECK_*, SECONDARY_ITEM, DEBRIS). +- `SiteRoute extends Resource`: `@export route_id: StringName`, `waypoints_m: PackedVector3Array`, `maximum_depth_m: float`; `validation_error() -> String`. +- `DiveSiteDefinition extends Resource`: `@export site_id: StringName`, `display_name: String`, `terrain: SiteTerrainData`, `playable_bounds_m: Rect2`, `spawn_position_m: Vector3`, `spawn_yaw_radians: float`, `placements: Array[SitePlacement]`, `routes: Array[SiteRoute]`; `validation_error() -> String`; `height_field() -> TerrainHeightField` (null when invalid); `placement(placement_id) -> SitePlacement`; `placements_of_kind(kind) -> Array[SitePlacement]`; `route(route_id) -> SiteRoute`; `spawn_transform() -> Transform3D` (rotation about Y by `spawn_yaw_radians`, origin `spawn_position_m`). + +- [ ] **Step 1: Add failing tests** + +In `tests/unit/run_site_domain_tests.gd` add the preloads + +```gdscript +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +``` + +call `_test_site_definition_validation()` from `_init()`, set `TEST_COUNT := 38` (20 + 18 new `_expect` calls), and add: + +```gdscript +func _make_placement(id: String, kind: int, position: Vector3) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = StringName(id) + placement.kind = kind + placement.position_m = position + placement.footprint_m = Vector3(4.0, 2.0, 3.0) + return placement + + +func _make_route(id: String, waypoints: PackedVector3Array, maximum_depth: float) -> SiteRoute: + var route := SiteRoute.new() + route.route_id = StringName(id) + route.waypoints_m = waypoints + route.maximum_depth_m = maximum_depth + return route + + +func _make_definition() -> DiveSiteDefinition: + var definition := DiveSiteDefinition.new() + definition.site_id = &"test_site" + definition.display_name = "Site de test" + definition.terrain = _make_terrain(3, 4) + definition.playable_bounds_m = Rect2(-10.0, 100.0, 4.0, 6.0) + definition.spawn_position_m = Vector3(-8.0, -10.0, 102.0) + definition.spawn_yaw_radians = PI * 0.5 + definition.placements = [ + _make_placement("target", SitePlacement.Kind.WRECK_TARGET, Vector3(-8.0, -23.0, 103.0)), + _make_placement("anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(-8.0, -20.0, 100.5)), + ] + definition.routes = [ + _make_route( + "safe", + PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -14.0, 103.0)]), + 20.0 + ), + ] + return definition + + +func _test_site_definition_validation() -> void: + var definition := _make_definition() + _expect(definition.validation_error().is_empty(), "complete definition is valid") + _expect(definition.height_field() != null, "definition exposes its height field") + _expect(definition.placement(&"target").kind == SitePlacement.Kind.WRECK_TARGET, "placement lookup by id") + _expect(definition.placement(&"missing") == null, "unknown placement is null") + _expect(definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR).size() == 1, "placements filter by kind") + _expect(definition.route(&"safe").waypoints_m.size() == 2, "route lookup by id") + _expect(definition.spawn_transform().origin.is_equal_approx(Vector3(-8.0, -10.0, 102.0)), "spawn transform origin") + _expect( + (definition.spawn_transform().basis * Vector3.FORWARD).is_equal_approx(Vector3.LEFT), + "spawn yaw rotates the forward axis about Y" + ) + _expect(definition.placement(&"target").is_sonar_metal(), "wrecks read as metal on sonar") + _expect(not definition.placement(&"anchor").is_sonar_metal(), "anchor is not a sonar body") + + var no_id := _make_definition() + no_id.site_id = &"" + _expect(not no_id.validation_error().is_empty(), "site id is required") + + var no_terrain := _make_definition() + no_terrain.terrain = null + _expect(not no_terrain.validation_error().is_empty(), "terrain is required") + + var bounds_outside := _make_definition() + bounds_outside.playable_bounds_m = Rect2(-20.0, 100.0, 4.0, 6.0) + _expect(not bounds_outside.validation_error().is_empty(), "playable bounds must lie inside the terrain") + + var spawn_outside := _make_definition() + spawn_outside.spawn_position_m = Vector3(50.0, -10.0, 102.0) + _expect(not spawn_outside.validation_error().is_empty(), "spawn must lie inside the bounds") + + var duplicate_ids := _make_definition() + duplicate_ids.placements.append( + _make_placement("target", SitePlacement.Kind.WRECK_DECOY, Vector3(-7.0, -23.0, 104.0)) + ) + _expect(not duplicate_ids.validation_error().is_empty(), "placement ids are unique") + + var placement_outside := _make_definition() + placement_outside.placements.append( + _make_placement("far", SitePlacement.Kind.DEBRIS, Vector3(80.0, -23.0, 104.0)) + ) + _expect(not placement_outside.validation_error().is_empty(), "placements must lie inside the bounds") + + var empty_route := _make_definition() + empty_route.routes.append(_make_route("empty", PackedVector3Array(), 20.0)) + _expect(not empty_route.validation_error().is_empty(), "routes need at least two waypoints") + + var deep_route := _make_definition() + deep_route.routes.append( + _make_route("deep", PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -30.0, 103.0)]), 20.0) + ) + _expect(not deep_route.validation_error().is_empty(), "route waypoints respect the route maximum depth") +``` + +- [ ] **Step 2: Run, expect failure** (missing scripts). Same command as Task 3. + +- [ ] **Step 3: Implement `SitePlacement`** + +`game/domain/sites/site_placement.gd`: + +```gdscript +class_name SitePlacement +extends Resource + +enum Kind { + WRECK_TARGET, + WRECK_DECOY, + SECONDARY_ITEM, + DEBRIS, + LANDMARK, + VESSEL_ANCHOR, +} + +@export var placement_id: StringName +@export var kind: Kind = Kind.LANDMARK +@export var position_m: Vector3 = Vector3.ZERO +@export var yaw_radians: float = 0.0 +@export var footprint_m: Vector3 = Vector3.ZERO + + +func is_sonar_metal() -> bool: + return kind in [Kind.WRECK_TARGET, Kind.WRECK_DECOY, Kind.SECONDARY_ITEM, Kind.DEBRIS] + + +func validation_error() -> String: + if placement_id.is_empty(): + return "placement id is empty" + if not (is_finite(position_m.x) and is_finite(position_m.y) and is_finite(position_m.z)): + return "placement position must be finite" + if not is_finite(yaw_radians): + return "placement yaw must be finite" + if footprint_m.x < 0.0 or footprint_m.y < 0.0 or footprint_m.z < 0.0: + return "placement footprint must not be negative" + if is_sonar_metal() and footprint_m.length_squared() == 0.0: + return "sonar bodies need a footprint" + return "" +``` + +- [ ] **Step 4: Implement `SiteRoute`** + +`game/domain/sites/site_route.gd`: + +```gdscript +class_name SiteRoute +extends Resource + +@export var route_id: StringName +@export var waypoints_m := PackedVector3Array() +@export var maximum_depth_m: float = 0.0 + + +func validation_error() -> String: + if route_id.is_empty(): + return "route id is empty" + if waypoints_m.size() < 2: + return "route needs at least two waypoints" + if not is_finite(maximum_depth_m) or maximum_depth_m <= 0.0: + return "route maximum depth must be positive" + for waypoint: Vector3 in waypoints_m: + if not (is_finite(waypoint.x) and is_finite(waypoint.y) and is_finite(waypoint.z)): + return "route waypoints must be finite" + if -waypoint.y > maximum_depth_m: + return "route waypoint exceeds the route maximum depth" + return "" +``` + +- [ ] **Step 5: Implement `DiveSiteDefinition`** + +`game/domain/sites/dive_site_definition.gd`: + +```gdscript +class_name DiveSiteDefinition +extends Resource + +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") + +@export var site_id: StringName +@export var display_name: String = "" +@export var terrain: SiteTerrainData +## Metres, world XZ: position = (x_min, z_min), size = (width along +X, depth along +Z). +@export var playable_bounds_m: Rect2 = Rect2() +@export var spawn_position_m: Vector3 = Vector3.ZERO +@export var spawn_yaw_radians: float = 0.0 +@export var placements: Array[SitePlacement] = [] +@export var routes: Array[SiteRoute] = [] + + +func height_field() -> TerrainHeightField: + return TerrainHeightField.from_data(terrain) + + +func spawn_transform() -> Transform3D: + return Transform3D(Basis(Vector3.UP, spawn_yaw_radians), spawn_position_m) + + +func placement(placement_id: StringName) -> SitePlacement: + for candidate: SitePlacement in placements: + if candidate != null and candidate.placement_id == placement_id: + return candidate + return null + + +func placements_of_kind(kind: SitePlacement.Kind) -> Array[SitePlacement]: + var result: Array[SitePlacement] = [] + for candidate: SitePlacement in placements: + if candidate != null and candidate.kind == kind: + result.append(candidate) + return result + + +func route(route_id: StringName) -> SiteRoute: + for candidate: SiteRoute in routes: + if candidate != null and candidate.route_id == route_id: + return candidate + return null + + +func validation_error() -> String: + if site_id.is_empty(): + return "site id is empty" + if terrain == null: + return "site terrain is missing" + var terrain_error := terrain.validation_error() + if not terrain_error.is_empty(): + return terrain_error + var field := TerrainHeightField.from_data(terrain) + if playable_bounds_m.size.x <= 0.0 or playable_bounds_m.size.y <= 0.0: + return "playable bounds must have a positive size" + if not field.bounds_m().encloses(playable_bounds_m): + return "playable bounds must lie inside the terrain" + if not _inside_bounds(spawn_position_m): + return "spawn position must lie inside the playable bounds" + if not is_finite(spawn_yaw_radians): + return "spawn yaw must be finite" + var seen_ids: Dictionary = {} + for candidate: SitePlacement in placements: + if candidate == null: + return "placement entry is null" + var placement_error := candidate.validation_error() + if not placement_error.is_empty(): + return placement_error + if seen_ids.has(candidate.placement_id): + return "placement id %s is duplicated" % candidate.placement_id + seen_ids[candidate.placement_id] = true + if not _inside_bounds(candidate.position_m): + return "placement %s lies outside the playable bounds" % candidate.placement_id + var seen_routes: Dictionary = {} + for candidate_route: SiteRoute in routes: + if candidate_route == null: + return "route entry is null" + var route_error := candidate_route.validation_error() + if not route_error.is_empty(): + return route_error + if seen_routes.has(candidate_route.route_id): + return "route id %s is duplicated" % candidate_route.route_id + seen_routes[candidate_route.route_id] = true + for waypoint: Vector3 in candidate_route.waypoints_m: + if not _inside_bounds(waypoint): + return "route %s leaves the playable bounds" % candidate_route.route_id + return "" + + +func _inside_bounds(position: Vector3) -> bool: + return playable_bounds_m.has_point(Vector2(position.x, position.z)) +``` + +The local `Dictionary` for id uniqueness never crosses a boundary; it is allowed. + +- [ ] **Step 6: Run, expect pass** → `Site domain unit tests: 38 passed, 0 failed.` Then `bash scripts/check_architecture.sh`. + +- [ ] **Step 7: Commit** + +```bash +git add game/domain/sites tests/unit/run_site_domain_tests.gd +git commit -m "feat(sites): typed dive site definition with placements and routes + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: `PlayableBoundsRule` (application) + +**Files:** +- Create: `game/application/sites/playable_bounds_status.gd` +- Create: `game/application/sites/playable_bounds_rule.gd` +- Create: `tests/unit/run_site_application_tests.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- `PlayableBoundsStatus extends RefCounted`: `enum State { INSIDE, NEAR_EDGE, OUTSIDE }`; `state: State`; `distance_to_edge_m: float` (positive inside, negative outside); `return_direction: Vector3` (unit XZ vector pointing toward the bounds centre, `Vector3.ZERO` when `INSIDE`); `requires_warning() -> bool` (NEAR_EDGE or OUTSIDE). +- `PlayableBoundsRule extends RefCounted`: `_init(bounds_m: Rect2, warning_margin_m: float = 40.0)`; `evaluate(position_m: Vector3) -> PlayableBoundsStatus`. + +- [ ] **Step 1: Write the failing suite** + +`tests/unit/run_site_application_tests.gd`: + +```gdscript +extends SceneTree + +const PlayableBoundsRule = preload("res://game/application/sites/playable_bounds_rule.gd") +const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd") + +const TEST_COUNT := 9 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_bounds_rule() + + if _failures.is_empty(): + print("Site application unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site application unit tests: %d failed." % _failures.size()) + quit(1) + + +func _test_bounds_rule() -> void: + var rule := PlayableBoundsRule.new(Rect2(-300.0, -300.0, 600.0, 600.0), 40.0) + var centre := rule.evaluate(Vector3(0.0, -20.0, 0.0)) + _expect(centre.state == PlayableBoundsStatus.State.INSIDE, "centre is inside") + _expect(is_equal_approx(centre.distance_to_edge_m, 300.0), "distance to the nearest edge") + _expect(not centre.requires_warning(), "inside needs no warning") + var near := rule.evaluate(Vector3(275.0, -20.0, 10.0)) + _expect(near.state == PlayableBoundsStatus.State.NEAR_EDGE, "within the margin is near the edge") + _expect(is_equal_approx(near.distance_to_edge_m, 25.0), "near edge distance is measured to the east edge") + _expect(near.return_direction.is_equal_approx(Vector3.LEFT), "return direction points back toward the centre") + var outside := rule.evaluate(Vector3(0.0, -20.0, -340.0)) + _expect(outside.state == PlayableBoundsStatus.State.OUTSIDE, "beyond the edge is outside") + _expect(is_equal_approx(outside.distance_to_edge_m, -40.0), "outside distance is negative") + _expect(outside.return_direction.is_equal_approx(Vector3.BACK), "outside north returns south") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +- [ ] **Step 2: Run, expect failure** (missing scripts): + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_site_application_tests.gd +``` + +- [ ] **Step 3: Implement** + +`game/application/sites/playable_bounds_status.gd`: + +```gdscript +class_name PlayableBoundsStatus +extends RefCounted + +enum State { + INSIDE, + NEAR_EDGE, + OUTSIDE, +} + +var state: State +var distance_to_edge_m: float +var return_direction: Vector3 + + +func _init(p_state: State, p_distance_to_edge_m: float, p_return_direction: Vector3) -> void: + state = p_state + distance_to_edge_m = p_distance_to_edge_m + return_direction = p_return_direction + + +func requires_warning() -> bool: + return state != State.INSIDE +``` + +`game/application/sites/playable_bounds_rule.gd`: + +```gdscript +class_name PlayableBoundsRule +extends RefCounted + +const PlayableBoundsStatus = preload( + "res://game/application/sites/playable_bounds_status.gd" +) + +var _bounds_m: Rect2 +var _warning_margin_m: float + + +func _init(bounds_m: Rect2, warning_margin_m: float = 40.0) -> void: + _bounds_m = bounds_m + _warning_margin_m = maxf(warning_margin_m, 0.0) + + +func evaluate(position_m: Vector3) -> PlayableBoundsStatus: + var point := Vector2(position_m.x, position_m.z) + var to_west := point.x - _bounds_m.position.x + var to_east := _bounds_m.end.x - point.x + var to_north := point.y - _bounds_m.position.y + var to_south := _bounds_m.end.y - point.y + var distance := minf(minf(to_west, to_east), minf(to_north, to_south)) + var state := PlayableBoundsStatus.State.INSIDE + if distance < 0.0: + state = PlayableBoundsStatus.State.OUTSIDE + elif distance < _warning_margin_m: + state = PlayableBoundsStatus.State.NEAR_EDGE + var direction := Vector3.ZERO + if state != PlayableBoundsStatus.State.INSIDE: + var centre := _bounds_m.get_center() + var toward := Vector2(centre.x - point.x, centre.y - point.y) + if distance == to_west or distance == to_east: + toward = Vector2(signf(centre.x - point.x), 0.0) + else: + toward = Vector2(0.0, signf(centre.y - point.y)) + direction = Vector3(toward.x, 0.0, toward.y) + return PlayableBoundsStatus.new(state, distance, direction) +``` + +- [ ] **Step 4: Run, expect pass** → `Site application unit tests: 9 passed, 0 failed.` + +- [ ] **Step 5: Add to the gate**, after the site domain block: + +```bash +printf 'Quality gate: production site application unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-application-unit.log" \ + --path . \ + --script tests/unit/run_site_application_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" +check_godot_log \ + "production site application unit tests" \ + "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" +``` + +- [ ] **Step 6: Commit** + +```bash +git add game/application/sites tests/unit/run_site_application_tests.gd scripts/quality_gate.sh +git commit -m "feat(sites): playable bounds rule with typed status + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: Terrain baker, calibration recipe, bake entry point + +**Files:** +- Create: `tools/site_terrain_baker.gd` +- Create: `tools/sites/vehicle_test_terrain_recipe.gd` +- Create: `tools/bake_site_terrain.gd` +- Create: `tests/unit/run_site_baker_tests.gd` +- Create (generated): `content/sites/vehicle_test_terrain.res`, `docs/briefs/captures/vehicle_test_terrain_preview.png` +- Modify: `tests/run_load_checks.gd` (add `"res://tools"` to `SEARCH_ROOTS`), `scripts/quality_gate.sh` + +**Interfaces:** +- `SiteTerrainBaker extends RefCounted` with nested classes: + - `Recipe`: `columns: int`, `rows: int`, `cell_size_m: float`, `origin_x_m: float`, `origin_z_m: float`, `floor_elevation_m: float` (clamp), `rock_slope_degrees: float` (default 20), `rock_reflectivity`, `sand_reflectivity`, `primitives: Array[Primitive]`, `material_patches: Array[MaterialPatch]`. + - `Primitive` (base): `func elevation_delta(x_m: float, z_m: float) -> float`. + - `Plane(depth_m, slope_x, slope_z, reference_x, reference_z)`: `-depth_m + slope_x * (x - reference_x) + slope_z * (z - reference_z)`. + - `Blob(centre: Vector2, sigma_x_m, sigma_z_m, amplitude_m)`: `amplitude * exp(-(dx²/sigma_x² + dz²/sigma_z²))`. + - `Ridge(start: Vector2, end: Vector2, sigma_m, amplitude_m, taper_m)`: gaussian of the distance to the segment, multiplied by `smoothstep(0, taper_m, distance_from_each_end_along_segment)`. + - `Groove(start, end, half_width_m, depth_m, wall_softness_m)`: `-depth * (1 - smoothstep(half_width - softness, half_width + softness, distance_to_segment))`, same end taper as Ridge with `taper_m = half_width_m`. + - `Step(point: Vector2, normal: Vector2, drop_m, softness_m, slope_beyond)`: signed distance `d = dot(p - point, normal)`; delta `= -(drop * smoothstep(-softness, softness, d) + max(d - softness, 0) * slope_beyond)`. + - `Ripples(direction: Vector2, wavelength_m, amplitude_m, centre: Vector2, radius_m)`: `amplitude * sin(dot(p, dir_normalised) * TAU / wavelength) * (1 - smoothstep(radius*0.6, radius, |p - centre|))`; `radius_m <= 0` means global. + - `Roughness(seed: int, frequency, amplitude_m)`: `FastNoiseLite` (`TYPE_SIMPLEX_SMOOTH`, fractal FBM 3 octaves) `get_noise_2d(x, z) * amplitude`. + - `MaterialPatch(centre: Vector2, radius_m, material: int)`: override inside the radius. + - `static func bake(recipe: Recipe) -> SiteTerrainData`: elevation = sum of deltas, clamped to `>= floor_elevation_m`; material = `ROCK` when the slope (from `TerrainHeightField.normal_at_cell` of a provisional field) exceeds `rock_slope_degrees`, else `SAND`; then patches override. + - `static func write_preview_png(data: SiteTerrainData, path: String) -> Error`: 8-bit grayscale from min to max elevation, rock cells tinted red channel +40. +- `tools/bake_site_terrain.gd extends SceneTree`: reads `OS.get_cmdline_user_args()[0]` as recipe name, looks it up in a `const RECIPES := { "vehicle_test": ..., "zone1_vigilant": ... }` of script paths, calls `recipe_script.recipe()`, bakes, saves `res://content/sites/_terrain.res` with `ResourceSaver.save`, writes `res://docs/briefs/captures/_terrain_preview.png`, prints the min/max elevation and the rock cell ratio, `quit(0)`; any error → `push_error` + `quit(1)`. +- `tools/sites/vehicle_test_terrain_recipe.gd`: `static func recipe() -> SiteTerrainBaker.Recipe` reproducing the current analytic terrain of `vehicle_test_environment.gd` (61 × 69 cells, 3 m × ~2.94 m — see below). + +- [ ] **Step 1: Write the failing baker tests** + +`tests/unit/run_site_baker_tests.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const VehicleTestRecipe = preload("res://tools/sites/vehicle_test_terrain_recipe.gd") + +const TEST_COUNT := 10 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_plane_and_blob() + _test_materials_and_determinism() + + if _failures.is_empty(): + print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site baker unit tests: %d failed." % _failures.size()) + quit(1) + + +func _small_recipe() -> SiteTerrainBaker.Recipe: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 21 + recipe.rows = 21 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -20.0 + recipe.origin_z_m = -20.0 + recipe.floor_elevation_m = -80.0 + recipe.rock_slope_degrees = 20.0 + return recipe + + +func _test_plane_and_blob() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.Plane.new(30.0, 0.0, 0.1, 0.0, 0.0)) + var data := SiteTerrainBaker.bake(recipe) + _expect(data != null and data.validation_error().is_empty(), "plane recipe bakes valid data") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -30.0), "plane depth at the reference point") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 20)], -28.0), "plane slopes along +Z") + _expect(data.materials[data.index_of(10, 10)] == SonarMaterial.Value.SAND, "gentle plane is sand") + + recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(0.0, 0.0), 4.0, 4.0, 10.0)) + data = SiteTerrainBaker.bake(recipe) + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -20.0), "blob adds its amplitude at the centre") + _expect(data.materials[data.index_of(12, 10)] == SonarMaterial.Value.ROCK, "steep blob flank is rock") + + +func _test_materials_and_determinism() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.Plane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + recipe.primitives.append(SiteTerrainBaker.Roughness.new(1933, 0.05, 0.5)) + recipe.material_patches.append( + SiteTerrainBaker.MaterialPatch.new(Vector2(10.0, 10.0), 3.0, SonarMaterial.Value.ROCK) + ) + var first := SiteTerrainBaker.bake(recipe) + var second := SiteTerrainBaker.bake(recipe) + _expect(first.elevations_m == second.elevations_m, "same recipe bakes identical elevations") + _expect(first.materials == second.materials, "same recipe bakes identical materials") + _expect(first.materials[first.index_of(15, 15)] == SonarMaterial.Value.ROCK, "material patch overrides the slope rule") + var calibration := SiteTerrainBaker.bake(VehicleTestRecipe.recipe()) + _expect(calibration != null and calibration.validation_error().is_empty(), "calibration recipe bakes valid data") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +- [ ] **Step 2: Run, expect failure** (missing `tools/site_terrain_baker.gd`): + +```bash +"$GODOT_BIN" --headless --path . --script tests/unit/run_site_baker_tests.gd +``` + +- [ ] **Step 3: Implement `tools/site_terrain_baker.gd`** + +```gdscript +class_name SiteTerrainBaker +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") + + +class Primitive extends RefCounted: + func elevation_delta(_x_m: float, _z_m: float) -> float: + return 0.0 + + +class Plane extends Primitive: + var depth_m: float + var slope_x: float + var slope_z: float + var reference_x_m: float + var reference_z_m: float + + func _init(p_depth_m: float, p_slope_x: float, p_slope_z: float, p_reference_x_m: float, p_reference_z_m: float) -> void: + depth_m = p_depth_m + slope_x = p_slope_x + slope_z = p_slope_z + reference_x_m = p_reference_x_m + reference_z_m = p_reference_z_m + + func elevation_delta(x_m: float, z_m: float) -> float: + return -depth_m + slope_x * (x_m - reference_x_m) + slope_z * (z_m - reference_z_m) + + +class Blob extends Primitive: + var centre: Vector2 + var sigma_x_m: float + var sigma_z_m: float + var amplitude_m: float + + func _init(p_centre: Vector2, p_sigma_x_m: float, p_sigma_z_m: float, p_amplitude_m: float) -> void: + centre = p_centre + sigma_x_m = p_sigma_x_m + sigma_z_m = p_sigma_z_m + amplitude_m = p_amplitude_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var dx := (x_m - centre.x) / sigma_x_m + var dz := (z_m - centre.y) / sigma_z_m + return amplitude_m * exp(-(dx * dx + dz * dz)) + + +class Ridge extends Primitive: + var start: Vector2 + var end: Vector2 + var sigma_m: float + var amplitude_m: float + var taper_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_sigma_m: float, p_amplitude_m: float, p_taper_m: float) -> void: + start = p_start + end = p_end + sigma_m = p_sigma_m + amplitude_m = p_amplitude_m + taper_m = p_taper_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := SiteTerrainBaker.distance_to_segment(point, start, end) + var profile := exp(-pow(distance / sigma_m, 2.0)) + return amplitude_m * profile * SiteTerrainBaker.end_taper(point, start, end, taper_m) + + +class Groove extends Primitive: + var start: Vector2 + var end: Vector2 + var half_width_m: float + var depth_m: float + var wall_softness_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_half_width_m: float, p_depth_m: float, p_wall_softness_m: float) -> void: + start = p_start + end = p_end + half_width_m = p_half_width_m + depth_m = p_depth_m + wall_softness_m = p_wall_softness_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := SiteTerrainBaker.distance_to_segment(point, start, end) + var inside := 1.0 - smoothstep( + half_width_m - wall_softness_m, + half_width_m + wall_softness_m, + distance + ) + return -depth_m * inside * SiteTerrainBaker.end_taper(point, start, end, half_width_m) + + +class Step extends Primitive: + var point: Vector2 + var normal: Vector2 + var drop_m: float + var softness_m: float + var slope_beyond: float + + func _init(p_point: Vector2, p_normal: Vector2, p_drop_m: float, p_softness_m: float, p_slope_beyond: float) -> void: + point = p_point + normal = p_normal.normalized() + drop_m = p_drop_m + softness_m = p_softness_m + slope_beyond = p_slope_beyond + + func elevation_delta(x_m: float, z_m: float) -> float: + var signed_distance := (Vector2(x_m, z_m) - point).dot(normal) + var step := drop_m * smoothstep(-softness_m, softness_m, signed_distance) + var slope := maxf(signed_distance - softness_m, 0.0) * slope_beyond + return -(step + slope) + + +class Ripples extends Primitive: + var direction: Vector2 + var wavelength_m: float + var amplitude_m: float + var centre: Vector2 + var radius_m: float + + func _init(p_direction: Vector2, p_wavelength_m: float, p_amplitude_m: float, p_centre: Vector2, p_radius_m: float) -> void: + direction = p_direction.normalized() + wavelength_m = p_wavelength_m + amplitude_m = p_amplitude_m + centre = p_centre + radius_m = p_radius_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var phase := point.dot(direction) * TAU / wavelength_m + var falloff := 1.0 + if radius_m > 0.0: + falloff = 1.0 - smoothstep(radius_m * 0.6, radius_m, point.distance_to(centre)) + return amplitude_m * sin(phase) * falloff + + +class Roughness extends Primitive: + var amplitude_m: float + var _noise: FastNoiseLite + + func _init(p_seed: int, p_frequency: float, p_amplitude_m: float) -> void: + amplitude_m = p_amplitude_m + _noise = FastNoiseLite.new() + _noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _noise.seed = p_seed + _noise.frequency = p_frequency + _noise.fractal_type = FastNoiseLite.FRACTAL_FBM + _noise.fractal_octaves = 3 + + func elevation_delta(x_m: float, z_m: float) -> float: + return _noise.get_noise_2d(x_m, z_m) * amplitude_m + + +class MaterialPatch extends RefCounted: + var centre: Vector2 + var radius_m: float + var material: int + + func _init(p_centre: Vector2, p_radius_m: float, p_material: int) -> void: + centre = p_centre + radius_m = p_radius_m + material = p_material + + +class Recipe extends RefCounted: + var columns: int = 2 + var rows: int = 2 + var cell_size_m: float = 1.0 + var origin_x_m: float = 0.0 + var origin_z_m: float = 0.0 + var floor_elevation_m: float = -1000.0 + var rock_slope_degrees: float = 20.0 + var rock_reflectivity: float = 0.72 + var sand_reflectivity: float = 0.45 + var primitives: Array[Primitive] = [] + var material_patches: Array[MaterialPatch] = [] + + +static func bake(recipe: Recipe) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = recipe.columns + data.rows = recipe.rows + data.cell_size_m = recipe.cell_size_m + data.origin_x_m = recipe.origin_x_m + data.origin_z_m = recipe.origin_z_m + data.rock_reflectivity = recipe.rock_reflectivity + data.sand_reflectivity = recipe.sand_reflectivity + var cell_count := recipe.columns * recipe.rows + # Packed arrays are passed by value in GDScript: fill locals, assign once. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(cell_count) + materials.resize(cell_count) + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + var elevation := 0.0 + for primitive: Primitive in recipe.primitives: + elevation += primitive.elevation_delta(x_m, z_m) + elevations[data.index_of(column, row)] = maxf(elevation, recipe.floor_elevation_m) + materials[data.index_of(column, row)] = SonarMaterial.Value.SAND + data.elevations_m = elevations + data.materials = materials + var field := TerrainHeightField.from_data(data) + if field == null: + return null + var rock_cosine := cos(deg_to_rad(recipe.rock_slope_degrees)) + for row in recipe.rows: + for column in recipe.columns: + if field.normal_at_cell(column, row).y < rock_cosine: + materials[data.index_of(column, row)] = SonarMaterial.Value.ROCK + for patch: MaterialPatch in recipe.material_patches: + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + if Vector2(x_m, z_m).distance_to(patch.centre) <= patch.radius_m: + materials[data.index_of(column, row)] = patch.material + data.materials = materials + return data + + +static func write_preview_png(data: SiteTerrainData, path: String) -> Error: + var field := TerrainHeightField.from_data(data) + if field == null: + return ERR_INVALID_DATA + var image := Image.create(data.columns, data.rows, false, Image.FORMAT_RGB8) + var span := maxf(field.maximum_elevation_m() - field.minimum_elevation_m(), 0.001) + for row in data.rows: + for column in data.columns: + var ratio := (field.elevation_at_cell(column, row) - field.minimum_elevation_m()) / span + var color := Color(ratio, ratio, ratio) + if field.material_at_cell(column, row) == SonarMaterial.Value.ROCK: + color.r = minf(color.r + 0.16, 1.0) + image.set_pixel(column, row, color) + return image.save_png(path) + + +static func distance_to_segment(point: Vector2, start: Vector2, end: Vector2) -> float: + var segment := end - start + var length_squared := segment.length_squared() + if length_squared == 0.0: + return point.distance_to(start) + var t := clampf((point - start).dot(segment) / length_squared, 0.0, 1.0) + return point.distance_to(start + segment * t) + + +static func end_taper(point: Vector2, start: Vector2, end: Vector2, taper_m: float) -> float: + if taper_m <= 0.0: + return 1.0 + var segment := end - start + var length := segment.length() + if length == 0.0: + return 1.0 + var along := clampf((point - start).dot(segment) / length, 0.0, length) + return smoothstep(0.0, taper_m, along) * smoothstep(0.0, taper_m, length - along) +``` + +- [ ] **Step 4: Implement the calibration recipe** + +`tools/sites/vehicle_test_terrain_recipe.gd` reproduces `vehicle_test_environment.gd` (`_terrain_height`): base `lerp(-40, -78, (28 - z) / 200)` is a plane of depth 40 at `z = 28` with slope `+0.19` per metre of `+Z` (shallower toward +Z); gaussians `A * exp(-(dx²/a + dz²/b))` map to `Blob(centre, sqrt(a), sqrt(b), A)`; `1.8 * sin(0.075x) * cos(0.048z)` is approximated by two `Ripples` (one along X with wavelength `TAU / 0.075`, one along Z with wavelength `TAU / 0.048`, amplitude 0.9 each). Exact reproduction is not required: this is a calibration scene, not a level-design rule (POC B baseline). + +```gdscript +class_name VehicleTestTerrainRecipe +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 61 + result.rows = 69 + result.cell_size_m = 3.0 + result.origin_x_m = -90.0 + result.origin_z_m = -176.0 + result.floor_elevation_m = -95.0 + result.rock_slope_degrees = 20.0 + result.primitives = [ + SiteTerrainBaker.Plane.new(40.0, 0.0, 0.19, 0.0, 28.0), + SiteTerrainBaker.Blob.new(Vector2(-24.0, -48.0), sqrt(210.0), sqrt(980.0), 13.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, -58.0), sqrt(250.0), sqrt(1150.0), 15.0), + SiteTerrainBaker.Blob.new(Vector2(0.0, -66.0), sqrt(120.0), sqrt(2500.0), -8.5), + SiteTerrainBaker.Blob.new(Vector2(-5.0, -125.0), sqrt(900.0), sqrt(620.0), 8.0), + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.0), TAU / 0.075, 0.9, Vector2.ZERO, 0.0), + SiteTerrainBaker.Ripples.new(Vector2(0.0, 1.0), TAU / 0.048, 0.9, Vector2.ZERO, 0.0), + ] + return result +``` + +Grid: 61 columns × 3 m = 180 m wide (x from -90 to +90); 69 rows × 3 m = 204 m (z from -176 to +28). The original spanned z from -172 to +28 with 2.94 m rows; a square 3 m cell is used instead. + +- [ ] **Step 5: Implement the entry point** + +`tools/bake_site_terrain.gd`: + +```gdscript +extends SceneTree + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + +const RECIPES := { + "vehicle_test": "res://tools/sites/vehicle_test_terrain_recipe.gd", + "zone1_vigilant": "res://tools/sites/zone1_vigilant_recipe.gd", +} +const OUTPUT_DIRECTORY := "res://content/sites" +const PREVIEW_DIRECTORY := "res://docs/briefs/captures" + + +func _init() -> void: + var arguments := OS.get_cmdline_user_args() + if arguments.is_empty() or not RECIPES.has(arguments[0]): + push_error("usage: -- <%s>" % " | ".join(RECIPES.keys())) + quit(1) + return + var name: String = arguments[0] + var recipe_script := load(RECIPES[name]) as GDScript + if recipe_script == null: + push_error("recipe script %s is missing" % RECIPES[name]) + quit(1) + return + var recipe: SiteTerrainBaker.Recipe = recipe_script.recipe() + var data := SiteTerrainBaker.bake(recipe) + if data == null: + push_error("recipe %s did not bake valid terrain" % name) + quit(1) + return + DirAccess.make_dir_recursive_absolute(OUTPUT_DIRECTORY) + DirAccess.make_dir_recursive_absolute(PREVIEW_DIRECTORY) + var output_path := "%s/%s_terrain.res" % [OUTPUT_DIRECTORY, name] + var save_error := ResourceSaver.save(data, output_path) + if save_error != OK: + push_error("cannot save %s (%d)" % [output_path, save_error]) + quit(1) + return + var preview_path := "%s/%s_terrain_preview.png" % [PREVIEW_DIRECTORY, name] + var preview_error := SiteTerrainBaker.write_preview_png(data, preview_path) + if preview_error != OK: + push_error("cannot write %s (%d)" % [preview_path, preview_error]) + quit(1) + return + var rock_cells := 0 + for material: int in data.materials: + if material == SiteTerrainBaker.SonarMaterial.Value.ROCK: + rock_cells += 1 + var minimum := data.elevations_m[0] + var maximum := data.elevations_m[0] + for elevation: float in data.elevations_m: + minimum = minf(minimum, elevation) + maximum = maxf(maximum, elevation) + print("Baked %s: %d x %d cells, elevation %.1f .. %.1f m, rock %.0f %%" % [ + name, data.columns, data.rows, minimum, maximum, + 100.0 * float(rock_cells) / float(data.materials.size()) + ]) + quit(0) +``` + +The zone 1 recipe script does not exist yet; the `RECIPES` entry is harmless until Task 9 (the `load` only runs for the requested name). + +- [ ] **Step 6: Run the baker tests, expect pass** → `Site baker unit tests: 10 passed, 0 failed.` If "steep blob flank is rock" fails, check the normal: at column 12 (x = +4 m, one sigma from the centre) the blob slope is `10 * 2 * (4/16) * exp(-1) ≈ 4.6`, far steeper than 20°. + +- [ ] **Step 7: Bake the calibration terrain** + +```bash +"$GODOT_BIN" --headless --path . --script tools/bake_site_terrain.gd -- vehicle_test +``` +Expected: `Baked vehicle_test: 61 x 69 cells, elevation -8x.x .. -2x.x m, rock xx %` and the files `content/sites/vehicle_test_terrain.res` and `docs/briefs/captures/vehicle_test_terrain_preview.png`. Open the PNG: two bright ridges, a dark channel between them, brighter toward the bottom (south, z = +28). + +- [ ] **Step 8: Load checks and gate** + +In `tests/run_load_checks.gd` add `"res://tools",` to `SEARCH_ROOTS` (after `"res://content"`). Run: + +```bash +"$GODOT_BIN" --headless --path . --script tests/run_load_checks.gd +``` +Expected: `Resource loading: N passed, 0 failed.` + +Add to `scripts/quality_gate.sh` after the site application block: + +```bash +printf 'Quality gate: site baker unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-baker-unit.log" \ + --path . \ + --script tests/unit/run_site_baker_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +check_godot_log "site baker unit tests" "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +``` + +- [ ] **Step 9: Commit** + +```bash +git add tools tests/unit/run_site_baker_tests.gd tests/run_load_checks.gd scripts/quality_gate.sh content/sites/vehicle_test_terrain.res docs/briefs/captures/vehicle_test_terrain_preview.png +git commit -m "feat(tools): declarative site terrain baker and calibration recipe + +Co-Authored-By: Claude Fable 5.1 " +``` + +Godot writes `.import` side files for the PNG; commit `docs/briefs/captures/vehicle_test_terrain_preview.png.import` too if it appears. + +--- + +### Task 7: `BathymetricTerrainActor` + seabed shader (world) + +**Files:** +- Create: `game/world/sites/bathymetric_terrain_actor.gd` +- Create: `game/world/sites/seabed_terrain.gdshader` +- Create: `tests/integration/run_site_terrain_integration.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- `BathymetricTerrainActor extends StaticBody3D`: `configure(field: TerrainHeightField) -> String` (empty on success; rebuilds tiles and collision, replaces any previous build); `height_field() -> TerrainHeightField`; `tile_count() -> int`; `sonar_material_at(world_position: Vector3) -> int`; `sonar_reflectivity_at(world_position: Vector3) -> float`. Constants `TILE_CELLS := 64`, `VISIBILITY_RANGE_END_M := 260.0`. Collision layer stays the default (1), which is what `ReferenceBathymetryAcquisition.COLLISION_MASK` and the submarine use. + +- [ ] **Step 1: Write the failing integration test** + +`tests/integration/run_site_terrain_integration.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const BathymetricTerrainActor = preload( + "res://game/world/sites/bathymetric_terrain_actor.gd" +) + +const TEST_COUNT := 12 + +var _failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _test_actor_matches_field() + + var exit_code := 0 + if _failures.is_empty(): + print("Site terrain integration: %d passed, 0 failed." % TEST_COUNT) + else: + for failure: String in _failures: + push_error(failure) + print("Site terrain integration: %d failed." % _failures.size()) + exit_code = 1 + await process_frame + quit(exit_code) + + +func _asymmetric_field() -> TerrainHeightField: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 81 + recipe.rows = 81 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -80.0 + recipe.origin_z_m = -80.0 + recipe.floor_elevation_m = -90.0 + recipe.primitives = [ + SiteTerrainBaker.Plane.new(40.0, 0.0, 0.0, 0.0, 0.0), + # Tall bump in the north-west corner only: any front/back flip shows up. + SiteTerrainBaker.Blob.new(Vector2(-60.0, -60.0), 10.0, 10.0, 18.0), + ] + return TerrainHeightField.from_data(SiteTerrainBaker.bake(recipe)) + + +func _test_actor_matches_field() -> void: + var field := _asymmetric_field() + _expect(field != null, "asymmetric test field bakes") + if field == null: + return + var actor := BathymetricTerrainActor.new() + root.add_child(actor) + _expect(actor.configure(field).is_empty(), "actor accepts a valid field") + await physics_frame + await physics_frame + + _expect(actor.tile_count() == 4, "80 cells per side split into 2 x 2 tiles of 64") + var space := actor.get_world_3d().direct_space_state + var worst_error := 0.0 + for cell: Vector2i in [ + Vector2i(0, 0), Vector2i(80, 0), Vector2i(0, 80), Vector2i(80, 80), + Vector2i(10, 10), Vector2i(40, 40), Vector2i(64, 20), + ]: + var expected := field.cell_world_position(cell.x, cell.y) + var query := PhysicsRayQueryParameters3D.create( + Vector3(expected.x, 50.0, expected.z), + Vector3(expected.x, -200.0, expected.z) + ) + var hit := space.intersect_ray(query) + if hit.is_empty(): + _failures.append("collision ray at cell %s hits nothing" % cell) + continue + worst_error = maxf(worst_error, absf(float(hit["position"].y) - expected.y)) + _expect(worst_error < 0.05, "collision height equals the field height at vertices (worst %.3f m)" % worst_error) + + var north_west := _collision_height(space, -60.0, -60.0) + var south_east := _collision_height(space, 60.0, 60.0) + _expect(north_west > -30.0, "north-west bump is where the field puts it (collision)") + _expect(south_east < -39.0, "south-east stays flat (collision)") + _expect(absf(north_west - field.elevation_at(-60.0, -60.0)) < 0.05, "bump height matches the field") + + _expect(actor.sonar_material_at(Vector3(-52.0, -30.0, -60.0)) == SonarMaterial.Value.ROCK, "steep flank reports rock") + _expect(actor.sonar_material_at(Vector3(40.0, -40.0, 40.0)) == SonarMaterial.Value.SAND, "flat plain reports sand") + _expect(is_equal_approx(actor.sonar_reflectivity_at(Vector3(40.0, -40.0, 40.0)), 0.45), "sand reflectivity from the data") + + _expect(actor.configure(field).is_empty() and actor.tile_count() == 4, "reconfiguring rebuilds without duplicating tiles") + _expect(not actor.configure(null).is_empty(), "null field is refused") + actor.queue_free() + + +func _collision_height(space: PhysicsDirectSpaceState3D, x: float, z: float) -> float: + var hit := space.intersect_ray( + PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z)) + ) + if hit.is_empty(): + return INF + return float(hit["position"].y) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +- [ ] **Step 2: Run, expect failure** (missing actor script): + +```bash +"$GODOT_BIN" --headless --path . --script tests/integration/run_site_terrain_integration.gd +``` + +- [ ] **Step 3: Write the shader** + +`game/world/sites/seabed_terrain.gdshader`: + +```glsl +shader_type spatial; +render_mode cull_disabled, depth_draw_opaque; + +uniform vec3 sand_color : source_color = vec3(0.50, 0.46, 0.34); +uniform vec3 rock_color : source_color = vec3(0.21, 0.20, 0.18); +uniform vec3 deep_tint : source_color = vec3(0.04, 0.09, 0.15); +uniform float sand_slope_cosine = 0.9397; // cos(20 deg) +uniform float rock_slope_cosine = 0.7660; // cos(40 deg) +uniform float depth_tint_start_m = 20.0; +uniform float depth_tint_end_m = 90.0; +uniform float ripple_wavelength_m = 1.6; +uniform float ripple_contrast = 0.12; + +varying vec3 world_position; +varying vec3 world_normal; + +void vertex() { + world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); +} + +void fragment() { + float up = clamp(world_normal.y, 0.0, 1.0); + float rockness = 1.0 - smoothstep(rock_slope_cosine, sand_slope_cosine, up); + vec3 albedo = mix(sand_color, rock_color, rockness); + float phase = (world_position.x * 0.8 + world_position.z * 0.6) * TAU / ripple_wavelength_m; + float ripple = sin(phase) * ripple_contrast * (1.0 - rockness); + albedo *= 1.0 + ripple; + float depth_m = -world_position.y; + float tint = smoothstep(depth_tint_start_m, depth_tint_end_m, depth_m); + albedo = mix(albedo, deep_tint, tint * 0.6); + ALBEDO = albedo; + ROUGHNESS = mix(0.95, 0.82, rockness); + METALLIC = 0.0; +} +``` + +`cull_disabled` makes the winding order of the tile triangles irrelevant for visibility; normals come from the field, not from the winding. + +- [ ] **Step 4: Implement the actor** + +`game/world/sites/bathymetric_terrain_actor.gd`: + +```gdscript +class_name BathymetricTerrainActor +extends StaticBody3D + +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const TERRAIN_SHADER = preload("res://game/world/sites/seabed_terrain.gdshader") + +const TILE_CELLS := 64 +const VISIBILITY_RANGE_END_M := 260.0 +const VISIBILITY_RANGE_MARGIN_M := 40.0 + +var _field: TerrainHeightField +var _material: ShaderMaterial +var _tiles_root: Node3D +var _collision: CollisionShape3D + + +func configure(field: TerrainHeightField) -> String: + if field == null: + return "terrain field is missing" + _clear() + _field = field + _material = ShaderMaterial.new() + _material.shader = TERRAIN_SHADER + _build_tiles() + _build_collision() + return "" + + +func height_field() -> TerrainHeightField: + return _field + + +func tile_count() -> int: + return 0 if _tiles_root == null else _tiles_root.get_child_count() + + +func sonar_material_at(world_position: Vector3) -> int: + return _field.material_at(world_position.x, world_position.z) + + +func sonar_reflectivity_at(world_position: Vector3) -> float: + return _field.reflectivity_for(sonar_material_at(world_position)) + + +func _clear() -> void: + if _tiles_root != null: + remove_child(_tiles_root) + _tiles_root.free() + _tiles_root = null + if _collision != null: + remove_child(_collision) + _collision.free() + _collision = null + + +func _build_tiles() -> void: + _tiles_root = Node3D.new() + _tiles_root.name = "Tiles" + add_child(_tiles_root) + var last_column := _field.columns() - 1 + var last_row := _field.rows() - 1 + var start_row := 0 + while start_row < last_row: + var end_row := mini(start_row + TILE_CELLS, last_row) + var start_column := 0 + while start_column < last_column: + var end_column := mini(start_column + TILE_CELLS, last_column) + _tiles_root.add_child(_build_tile(start_column, end_column, start_row, end_row)) + start_column = end_column + start_row = end_row + + +func _build_tile(start_column: int, end_column: int, start_row: int, end_row: int) -> MeshInstance3D: + var corner_min := _field.cell_world_position(start_column, start_row) + var corner_max := _field.cell_world_position(end_column, end_row) + var centre := Vector3( + (corner_min.x + corner_max.x) * 0.5, + 0.0, + (corner_min.z + corner_max.z) * 0.5 + ) + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var indices := PackedInt32Array() + var columns_in_tile := end_column - start_column + 1 + for row in range(start_row, end_row + 1): + for column in range(start_column, end_column + 1): + vertices.append(_field.cell_world_position(column, row) - centre) + normals.append(_field.normal_at_cell(column, row)) + for row in range(end_row - start_row): + for column in range(end_column - start_column): + var top_left := row * columns_in_tile + column + var top_right := top_left + 1 + var bottom_left := top_left + columns_in_tile + var bottom_right := bottom_left + 1 + indices.append_array([top_left, bottom_left, top_right]) + indices.append_array([top_right, bottom_left, bottom_right]) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var instance := MeshInstance3D.new() + instance.name = "Tile_%d_%d" % [start_column / TILE_CELLS, start_row / TILE_CELLS] + instance.mesh = mesh + instance.material_override = _material + instance.position = centre + instance.visibility_range_end = VISIBILITY_RANGE_END_M + instance.visibility_range_end_margin = VISIBILITY_RANGE_MARGIN_M + instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF + return instance + + +func _build_collision() -> void: + var shape := HeightMapShape3D.new() + shape.map_width = _field.columns() + shape.map_depth = _field.rows() + var heights := PackedFloat32Array() + heights.resize(_field.columns() * _field.rows()) + for row in _field.rows(): + for column in _field.columns(): + heights[row * _field.columns() + column] = _field.elevation_at_cell(column, row) + shape.map_data = heights + _collision = CollisionShape3D.new() + _collision.name = "Collision" + _collision.shape = shape + var bounds := _field.bounds_m() + _collision.position = Vector3( + bounds.position.x + bounds.size.x * 0.5, + 0.0, + bounds.position.y + bounds.size.y * 0.5 + ) + _collision.scale = Vector3(_field.cell_size_m(), 1.0, _field.cell_size_m()) + add_child(_collision) +``` + +`HeightMapShape3D` indexes `map_data[z * map_width + x]` with `x` along local `+X` and `z` along local `+Z`, both centred on the shape origin. Row index grows with `+Z` in the field, so the two agree; the integration test proves it. + +- [ ] **Step 5: Run, expect pass** → `Site terrain integration: 12 passed, 0 failed.` If "collision height equals the field height" fails with a large error only at one side, the heightmap is flipped: check the row loop order in `_build_collision`, never patch the test tolerance. + +- [ ] **Step 6: Add to the gate**, after the foundation integration block: + +```bash +printf 'Quality gate: production site terrain integration\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-terrain-integration.log" \ + --path . \ + --script tests/integration/run_site_terrain_integration.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-terrain-integration-console.log" +check_godot_log \ + "production site terrain integration" \ + "$QUALITY_TEMP_DIRECTORY/site-terrain-integration-console.log" +``` + +- [ ] **Step 7: Commit** + +```bash +git add game/world/sites/bathymetric_terrain_actor.gd game/world/sites/seabed_terrain.gdshader tests/integration/run_site_terrain_integration.gd scripts/quality_gate.sh +git commit -m "feat(world): tiled heightfield terrain actor with per-cell sonar materials + +Co-Authored-By: Claude Fable 5.1 " +``` + +Godot creates `.uid` files next to new scripts and shaders; commit them with the scripts (the repo already tracks `.uid` files). + +--- + +### Task 8: Generic `DiveSite` scene, per-cell sonar acquisition, baked site definitions + +**Files:** +- Create: `game/world/sites/dive_site.gd`, `game/world/sites/dive_site.tscn` +- Modify: `game/world/sonar/reference_bathymetry_acquisition.gd:67-74` +- Modify: `tools/site_terrain_baker.gd` (no change), `tools/sites/vehicle_test_terrain_recipe.gd` (add `definition`), `tools/bake_site_terrain.gd` (save the definition) +- Create (generated): `content/sites/vehicle_test.tres` +- Modify: `tests/integration/run_site_terrain_integration.gd` + +**Interfaces:** +- `DiveSite extends Node3D`: `configure(definition: DiveSiteDefinition) -> String`; `definition() -> DiveSiteDefinition`; `terrain() -> BathymetricTerrainActor`; `spawn_transform() -> Transform3D`; `placeholder_count() -> int` (sonar bodies + beacons created). Scene unique names: `%Terrain` (BathymetricTerrainActor), `%Placements` (Node3D). +- Recipe scripts gain `static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition`. +- `tools/bake_site_terrain.gd` writes `res://content/sites/_terrain.res` (with `ResourceSaver.FLAG_CHANGE_PATH`) then `res://content/sites/.tres`. +- `ReferenceBathymetryAcquisition` prefers `collider.sonar_material_at(position)` / `sonar_reflectivity_at(position)` when the collider has those methods; otherwise metadata as today. + +- [ ] **Step 1: Extend the integration test (failing)** + +Add preloads to `tests/integration/run_site_terrain_integration.gd`: + +```gdscript +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const DiveSite = preload("res://game/world/sites/dive_site.gd") +const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd") +const ReferenceBathymetryAcquisition = preload( + "res://game/world/sonar/reference_bathymetry_acquisition.gd" +) +const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn") +``` + +Call `await _test_dive_site_scene()` after the actor test in `_run()`, set `TEST_COUNT := 23` (12 + 11 new `_expect` calls), and add: + +```gdscript +func _test_dive_site_scene() -> void: + var definition := load("res://content/sites/vehicle_test.tres") as DiveSiteDefinition + _expect(definition != null, "calibration site definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "calibration site definition is valid") + var started_usec := Time.get_ticks_usec() + var site := DIVE_SITE_SCENE.instantiate() as DiveSite + root.add_child(site) + _expect(site.configure(definition).is_empty(), "dive site scene accepts the definition") + var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0 + _expect(configure_ms < 3000.0, "site configures under the 3 s load budget (%.0f ms)" % configure_ms) + await physics_frame + await physics_frame + _expect(site.terrain().tile_count() > 0, "site builds its terrain tiles") + _expect(site.placeholder_count() == 2, "calibration target and beacon are placed") + var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D + _expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body") + _expect(site.spawn_transform().origin.is_equal_approx(Vector3(0.0, -12.0, 12.0)), "spawn comes from the definition") + + var sensor := Transform3D(Basis(Vector3.UP, 0.0), Vector3(0.0, -12.0, 10.0)) + sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z) + var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d()) + var request := SonarCaptureRequest.new( + &"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31 + ) + var result = acquisition.capture(request) + _expect(result.success, "reference acquisition scans the baked terrain") + var materials: Dictionary = {} + for sample in result.samples: + materials[sample.material] = true + _expect(materials.has(SonarMaterial.Value.SAND) and materials.has(SonarMaterial.Value.ROCK), "acquisition reports per-cell sand and rock") + _expect(materials.has(SonarMaterial.Value.METAL), "acquisition still reports the metal placeholder") + site.queue_free() +``` + +The `Dictionary` here is a test-local set, not a production contract. + +- [ ] **Step 2: Run, expect failure** (missing scene / resource). + +- [ ] **Step 3: Change the acquisition** + +In `game/world/sonar/reference_bathymetry_acquisition.gd`, replace lines 67-74 (`var material := ...` through the reflectivity read) with: + +```gdscript + var material := SonarMaterial.Value.ROCK + var reflectivity := 0.72 + if collider != null: + if collider.has_method("sonar_material_at"): + material = int(collider.call("sonar_material_at", world_position)) + reflectivity = float( + collider.call("sonar_reflectivity_at", world_position) + ) + else: + material = int(collider.get_meta("sonar_material", material)) + reflectivity = float( + collider.get_meta("sonar_reflectivity", reflectivity) + ) +``` + +- [ ] **Step 4: Write `dive_site.gd`** + +```gdscript +class_name DiveSite +extends Node3D + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const BathymetricTerrainActor = preload( + "res://game/world/sites/bathymetric_terrain_actor.gd" +) + +const METAL_REFLECTIVITY := 1.0 +const BEACON_COLOR := Color(0.2, 0.78, 0.9) + +@onready var _terrain: BathymetricTerrainActor = %Terrain +@onready var _placements_root: Node3D = %Placements + +var _definition: DiveSiteDefinition +var _placeholder_count := 0 + + +func configure(definition: DiveSiteDefinition) -> String: + if definition == null: + return "dive site definition is missing" + var error := definition.validation_error() + if not error.is_empty(): + return error + var terrain_error := _terrain.configure(definition.height_field()) + if not terrain_error.is_empty(): + return terrain_error + _definition = definition + for child in _placements_root.get_children(): + _placements_root.remove_child(child) + child.free() + _placeholder_count = 0 + for placement: SitePlacement in definition.placements: + _place(placement) + return "" + + +func definition() -> DiveSiteDefinition: + return _definition + + +func terrain() -> BathymetricTerrainActor: + return _terrain + + +func spawn_transform() -> Transform3D: + return _definition.spawn_transform() if _definition != null else Transform3D.IDENTITY + + +func placeholder_count() -> int: + return _placeholder_count + + +func _place(placement: SitePlacement) -> void: + if placement.is_sonar_metal(): + _placements_root.add_child(_metal_placeholder(placement)) + _placeholder_count += 1 + elif placement.kind == SitePlacement.Kind.VESSEL_ANCHOR: + _placements_root.add_child(_beacon(placement)) + _placeholder_count += 1 + + +func _metal_placeholder(placement: SitePlacement) -> StaticBody3D: + var body := StaticBody3D.new() + body.name = String(placement.placement_id) + body.position = placement.position_m + body.rotation.y = placement.yaw_radians + body.set_meta("sonar_material", SonarMaterial.Value.METAL) + body.set_meta("sonar_reflectivity", METAL_REFLECTIVITY) + var mesh := BoxMesh.new() + mesh.size = placement.footprint_m + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "Mesh" + mesh_instance.mesh = mesh + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.24, 0.18, 0.08) + material.metallic = 0.88 + material.roughness = 0.27 + mesh_instance.material_override = material + body.add_child(mesh_instance) + var shape := BoxShape3D.new() + shape.size = placement.footprint_m + var collision := CollisionShape3D.new() + collision.name = "Collision" + collision.shape = shape + body.add_child(collision) + return body + + +func _beacon(placement: SitePlacement) -> MeshInstance3D: + var mesh := SphereMesh.new() + mesh.radius = 0.55 + mesh.height = 1.1 + var beacon := MeshInstance3D.new() + beacon.name = String(placement.placement_id) + beacon.mesh = mesh + beacon.position = placement.position_m + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = BEACON_COLOR + material.emission_enabled = true + material.emission = BEACON_COLOR + material.emission_energy_multiplier = 5.0 + beacon.material_override = material + var light := OmniLight3D.new() + light.name = "BeaconLight" + light.omni_range = 12.0 + light.light_color = BEACON_COLOR + light.light_energy = 4.0 + beacon.add_child(light) + return beacon +``` + +- [ ] **Step 5: Write `dive_site.tscn`** + +Before writing, run `git diff game/world/sites/vehicle_test_environment.tscn` and carry any uncommitted environment tweak (fog, light) into the sub-resource below. + +```text +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"] +[ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"] + +[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 +glow_enabled = true +glow_levels/1 = 1.0 +glow_levels/2 = 1.0 +glow_levels/3 = 0.6 +glow_levels/4 = 0.2 +glow_intensity = 1.0 +glow_blend_mode = 0 +glow_hdr_threshold = 1.0 +fog_enabled = true +fog_light_color = Color(0.07, 0.25, 0.3, 1) +fog_light_energy = 0.48 +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 + +[node name="DiveSite" type="Node3D"] +script = ExtResource("1_site") + +[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="Terrain" type="StaticBody3D" parent="."] +unique_name_in_owner = true +script = ExtResource("2_terrain") + +[node name="Placements" type="Node3D" parent="."] +unique_name_in_owner = true +``` + +- [ ] **Step 6: Add `definition()` to the calibration recipe and save it from the baker** + +Append to `tools/sites/vehicle_test_terrain_recipe.gd`: + +```gdscript +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + var result := DiveSiteDefinition.new() + result.site_id = &"vehicle_test" + result.display_name = "Site de calibration du véhicule" + result.terrain = terrain + result.playable_bounds_m = Rect2(-90.0, -176.0, 180.0, 204.0) + result.spawn_position_m = Vector3(0.0, -12.0, 12.0) + result.spawn_yaw_radians = 0.0 + var target := SitePlacement.new() + target.placement_id = &"calibration_target" + target.kind = SitePlacement.Kind.WRECK_DECOY + target.position_m = Vector3(0.0, field.elevation_at(0.0, -58.0) + 2.5, -58.0) + target.footprint_m = Vector3(8.0, 5.0, 7.0) + var anchor := SitePlacement.new() + anchor.placement_id = &"navigation_beacon" + anchor.kind = SitePlacement.Kind.VESSEL_ANCHOR + anchor.position_m = Vector3(0.0, -15.0, -22.0) + result.placements = [target, anchor] + return result +``` + +(The `const` lines go at the top of the file with the existing preload.) + +In `tools/bake_site_terrain.gd`, change the terrain save to `ResourceSaver.save(data, output_path, ResourceSaver.FLAG_CHANGE_PATH)` and, after the preview is written, add: + +```gdscript + var definition = recipe_script.definition(data) + var definition_error: String = definition.validation_error() + if not definition_error.is_empty(): + push_error("recipe %s definition is invalid: %s" % [name, definition_error]) + quit(1) + return + var definition_path := "%s/%s.tres" % [OUTPUT_DIRECTORY, name] + var definition_save_error := ResourceSaver.save(definition, definition_path) + if definition_save_error != OK: + push_error("cannot save %s (%d)" % [definition_path, definition_save_error]) + quit(1) + return +``` + +Re-bake: + +```bash +"$GODOT_BIN" --headless --path . --script tools/bake_site_terrain.gd -- vehicle_test +``` + +Expected: `content/sites/vehicle_test.tres` exists and its text contains `[ext_resource type="Resource" path="res://content/sites/vehicle_test_terrain.res"` and two `[sub_resource type="Resource"` placement blocks. If the terrain was embedded as a sub-resource instead, the `FLAG_CHANGE_PATH` is missing. Site definition paths are always `res://content/sites/.tres`; zone 1 will be `res://content/sites/zone1_vigilant.tres`. + +- [ ] **Step 7: Run the integration test, expect pass** → `Site terrain integration: 23 passed, 0 failed.` Then `"$GODOT_BIN" --headless --path . --script tests/run_load_checks.gd` (the `.tres`, `.res`, scene and tool scripts all load) and `bash scripts/check_architecture.sh`. + +- [ ] **Step 8: Commit** + +```bash +git add game/world/sites/dive_site.gd game/world/sites/dive_site.tscn game/world/sites/*.uid game/world/sonar/reference_bathymetry_acquisition.gd tools content/sites tests/integration/run_site_terrain_integration.gd docs/briefs/captures +git commit -m "feat(world): generic dive site scene fed by a baked site definition + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 9: Composition loads a selected site; bounds warning on the HUD + +**Files:** +- Create: `game/composition/vertical_slice/dive_world.tscn` +- Delete: `game/composition/vertical_slice/vehicle_test_world.tscn`, `game/world/sites/vehicle_test_environment.gd`, `game/world/sites/vehicle_test_environment.tscn` (+ their `.uid`) +- Modify: `game/composition/vertical_slice/vertical_slice_root.gd` +- Modify: `game/presentation/cockpit/cockpit_hud.gd`, `game/presentation/cockpit/cockpit_hud.tscn` +- Modify: `tests/integration/run_vertical_slice_foundation.gd` + +**Interfaces:** +- `VerticalSliceRoot`: `const DEFAULT_SITE_PATH := "res://content/sites/vehicle_test.tres"` (flipped to zone 1 in Task 11); `select_dive_site(definition_path: String) -> void` (takes effect at the next `Nouvelle partie`); `dive_site() -> DiveSite`; `latest_bounds_status() -> PlayableBoundsStatus`. +- `CockpitHud.present_bounds(status: PlayableBoundsStatus) -> void`; `bounds_warning_visible() -> bool`. Scene nodes `%BoundsPanel`, `%BoundsLabel`. +- `dive_world.tscn` unique names: `%DiveSite`, `%SubmarineActor`, `%CockpitInterior`, `%CockpitCameraRig`. + +- [ ] **Step 1: Extend the foundation test (failing)** + +In `tests/integration/run_vertical_slice_foundation.gd`: +- add `const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd")`; +- in `_test_vehicle_slice`, immediately before `new_game_button.pressed.emit()`, add `composition.select_dive_site("res://content/sites/vehicle_test.tres")`; +- after `_expect(composition.latest_update() != null, ...)` add: + +```gdscript + _expect(composition.dive_site() != null, "composition instantiates the selected dive site") + _expect( + composition.dive_site() != null + and composition.dive_site().definition().site_id == &"vehicle_test", + "selected site definition drives the world" + ) + _expect( + actor != null and actor.global_position.is_equal_approx(Vector3(0.0, -12.0, 12.0)), + "submarine spawns at the site spawn point" + ) + _expect( + composition.latest_bounds_status() != null + and composition.latest_bounds_status().state == PlayableBoundsStatus.State.INSIDE, + "spawn is inside the playable bounds" + ) + _expect(hud != null and not hud.bounds_warning_visible(), "no bounds warning inside the zone") +``` + +Set `TEST_COUNT := 96` (91 + 5). + +- [ ] **Step 2: Run, expect failure** (`select_dive_site` undefined): + +```bash +"$GODOT_BIN" --headless --path . --script tests/integration/run_vertical_slice_foundation.gd +``` + +- [ ] **Step 3: Write `dive_world.tscn`** + +```text +[gd_scene load_steps=7 format=3] + +[ext_resource type="PackedScene" path="res://game/world/sites/dive_site.tscn" id="1_site"] +[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"] + +[sub_resource type="ParticleProcessMaterial" id="SuspendedMotion"] +emission_shape = 3 +emission_box_extents = Vector3(45, 28, 70) +direction = Vector3(0.1, 0.04, 0) +spread = 180.0 +initial_velocity_min = 0.02 +initial_velocity_max = 0.09 +gravity = Vector3(0, 0, 0) +scale_min = 0.025 +scale_max = 0.08 + +[sub_resource type="SphereMesh" id="SuspendedParticleMesh"] +radius = 0.035 +height = 0.07 + +[node name="DiveWorld" type="Node3D"] + +[node name="DiveSite" parent="." instance=ExtResource("1_site")] +unique_name_in_owner = true + +[node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")] +unique_name_in_owner = true + +[node name="SuspendedParticles" type="GPUParticles3D" parent="SubmarineActor"] +amount = 360 +lifetime = 14.0 +preprocess = 14.0 +visibility_aabb = AABB(-50, -35, -78, 100, 70, 156) +process_material = SubResource("SuspendedMotion") +draw_pass_1 = SubResource("SuspendedParticleMesh") + +[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 +``` + +`emission_shape = 3` is `EMISSION_SHAPE_BOX`. Particles are emitted in world space (`local_coords` default false), so they stay put while the submarine moves through them. + +- [ ] **Step 4: Update `vertical_slice_root.gd`** + +Add preloads after `ReferenceBathymetryAcquisition`: + +```gdscript +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const DiveSite = preload("res://game/world/sites/dive_site.gd") +const PlayableBoundsRule = preload("res://game/application/sites/playable_bounds_rule.gd") +const PlayableBoundsStatus = preload( + "res://game/application/sites/playable_bounds_status.gd" +) +``` + +Replace `VEHICLE_TEST_WORLD_SCENE_PATH` with: + +```gdscript +const DIVE_WORLD_SCENE_PATH := "res://game/composition/vertical_slice/dive_world.tscn" +const DEFAULT_SITE_PATH := "res://content/sites/vehicle_test.tres" +``` + +Add state next to `_active_world`: + +```gdscript +var _selected_site_path := DEFAULT_SITE_PATH +var _dive_site: DiveSite +var _bounds_rule: PlayableBoundsRule +var _bounds_status: PlayableBoundsStatus +``` + +In `_start_vehicle_test()` (rename it `_start_dive` and update the `new_game_requested.connect` call in `_show_main_menu`), replace the block from `var world_scene := load(VEHICLE_TEST_WORLD_SCENE_PATH)` through `_instrument_panel = ...` with: + +```gdscript + var definition := load(_selected_site_path) as DiveSiteDefinition + if definition == null: + push_error("dive site definition %s cannot be loaded" % _selected_site_path) + return + var definition_error := definition.validation_error() + if not definition_error.is_empty(): + push_error("dive site %s is invalid: %s" % [_selected_site_path, definition_error]) + return + var world_scene := load(DIVE_WORLD_SCENE_PATH) as PackedScene + if world_scene == null: + return + var world := world_scene.instantiate() as Node3D + _replace_active_world(world) + _dive_site = world.get_node("%DiveSite") as DiveSite + var site_error := _dive_site.configure(definition) + if not site_error.is_empty(): + push_error("dive site %s cannot be built: %s" % [_selected_site_path, site_error]) + _show_main_menu() + return + _actor = world.get_node("%SubmarineActor") as SubmarineActor + _actor.global_transform = _dive_site.spawn_transform() + _bounds_rule = PlayableBoundsRule.new(definition.playable_bounds_m) + _bounds_status = _bounds_rule.evaluate(_actor.global_position) + _camera_controller = world.get_node("%CockpitCameraRig") as CockpitCameraController + _instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel +``` + +After `_hud.present(_latest_update, initial_telemetry)` in the same function add `_hud.present_bounds(_bounds_status)`. + +In `_physics_process`, after `_hud.present(_latest_update, telemetry)` add: + +```gdscript + _bounds_status = _bounds_rule.evaluate(_actor.global_position) + _hud.present_bounds(_bounds_status) +``` + +In `_show_main_menu`, reset `_dive_site = null`, `_bounds_rule = null`, `_bounds_status = null` next to `_actor = null`. + +Add accessors next to `sonar_display()`: + +```gdscript +func select_dive_site(definition_path: String) -> void: + _selected_site_path = definition_path + + +func dive_site() -> DiveSite: + return _dive_site + + +func latest_bounds_status() -> PlayableBoundsStatus: + return _bounds_status +``` + +- [ ] **Step 5: HUD panel** + +In `cockpit_hud.tscn`, after the `AlertLabel` node, add: + +```text +[node name="BoundsPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_left = 560.0 +offset_top = 88.0 +offset_right = -560.0 +offset_bottom = 136.0 +grow_horizontal = 2 +theme_override_styles/panel = SubResource("AlertStyle") + +[node name="BoundsLabel" type="Label" parent="BoundsPanel"] +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 = 18 +text = "LIMITE DE ZONE" +horizontal_alignment = 1 +``` + +In `cockpit_hud.gd` add the preload `const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd")`, the `@onready var _bounds_panel: PanelContainer = %BoundsPanel` / `_bounds_label: Label = %BoundsLabel`, and: + +```gdscript +func present_bounds(status: PlayableBoundsStatus) -> void: + if status == null or not status.requires_warning(): + _bounds_panel.visible = false + return + _bounds_panel.visible = true + if status.state == PlayableBoundsStatus.State.OUTSIDE: + _bounds_label.text = tr("HORS ZONE · FAITES DEMI-TOUR") + else: + _bounds_label.text = "%s · %d m" % [tr("LIMITE DE ZONE"), int(status.distance_to_edge_m)] + + +func bounds_warning_visible() -> bool: + return _bounds_panel.visible +``` + +Presentation imports an application type (`PlayableBoundsStatus`) only; that is allowed. + +- [ ] **Step 6: Delete the old environment and world** + +```bash +git rm game/composition/vertical_slice/vehicle_test_world.tscn game/world/sites/vehicle_test_environment.gd game/world/sites/vehicle_test_environment.gd.uid game/world/sites/vehicle_test_environment.tscn +``` + +If `git rm` refuses because `vehicle_test_environment.tscn` has local modifications, that is the other session's work: inspect `git diff` on it, port what matters into `dive_site.tscn` (Task 8 Step 5), then `git rm -f`. + +`rg -n "vehicle_test_environment|vehicle_test_world|VehicleTestEnvironment" game tests docs` must return only historical mentions in `docs/` (acceptable) and none in `game/` or `tests/`. + +- [ ] **Step 7: Run, expect pass** + +```bash +"$GODOT_BIN" --headless --path . --script tests/integration/run_vertical_slice_foundation.gd +``` +Expected: `Vertical slice foundation integration: 96 passed, 0 failed.` The existing assertions `observed_count() > 1000` and `contacts.size() >= 1` keep passing because the calibration placeholder is a metal body in front of the spawn. + +Run also: `"$GODOT_BIN" --headless --path . --script tests/run_load_checks.gd` and `bash scripts/check_architecture.sh`. + +- [ ] **Step 8: Commit** + +```bash +git add -A game/composition game/presentation/cockpit/cockpit_hud.gd game/presentation/cockpit/cockpit_hud.tscn game/world/sites tests/integration/run_vertical_slice_foundation.gd +git commit -m "feat(composition): dive world assembled from a selected site definition + +Co-Authored-By: Claude Fable 5.1 " +``` + +Only stage the cockpit HUD files from `game/presentation/cockpit/`; the other cockpit files there belong to the other session. + +--- + +### Task 10: Zone 1 recipe « Banc du Vigilant » and content contract tests + +**Files:** +- Create: `tools/sites/zone1_vigilant_recipe.gd` +- Create (generated): `content/sites/zone1_vigilant_terrain.res`, `content/sites/zone1_vigilant.tres`, `docs/briefs/captures/zone1_vigilant_terrain_preview.png` +- Create: `tests/contract/run_site_content_tests.gd` +- Modify: `scripts/quality_gate.sh` + +**Interfaces:** +- Placement ids (StringName) used by tests: `&"prospection_vessel"` (WRECK_TARGET), `&"decoy_yacht"`, `&"decoy_coaster"` (WRECK_DECOY), `&"secondary_item"` (SECONDARY_ITEM), `&"debris_1"`..`&"debris_4"` (DEBRIS), `&"vessel_anchor"` (VESSEL_ANCHOR), landmarks `&"echine"`, `&"deux_freres"`, `&"rides"`, `&"saignee_entrance"`, `&"rupture"`. +- Route ids: `&"safe_ridge"` (maximum_depth 32), `&"risky_groove"` (maximum_depth 48). + +- [ ] **Step 1: Write the failing contract suite** + +`tests/contract/run_site_content_tests.gd`: + +```gdscript +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") +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 SITE_PATH := "res://content/sites/zone1_vigilant.tres" +const ROUTE_CLEARANCE_M := 3.0 +const MINIMUM_LANDMARK_SPACING_M := 60.0 +const TEST_COUNT := 30 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_zone1_content() + + if _failures.is_empty(): + print("Site content contract tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site content contract tests: %d failed." % _failures.size()) + quit(1) + + +func _test_zone1_content() -> void: + var definition := load(SITE_PATH) as DiveSiteDefinition + _expect(definition != null, "zone 1 definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "zone 1 definition is valid: %s" % definition.validation_error()) + var field := definition.height_field() + if field == null: + return + var rules = DiveRulesResolver.new().resolve( + DiveDifficultyProfile.standard(), + DiveUpgradeEffects.new(), + DiveAccessibilityAdjustments.new() + ) + var certified_m: float = rules.certified_depth_m + var critical_m: float = rules.critical_depth_m + + _expect(definition.playable_bounds_m.is_equal_approx(Rect2(-300.0, -300.0, 600.0, 600.0)), "playable zone is 600 x 600 m") + _expect(field.minimum_elevation_m() >= -88.5 and field.maximum_elevation_m() <= -19.5, "terrain stays between 20 m and 88 m deep") + _expect(field.cell_size_m() == 1.5 and field.columns() == 513 and field.rows() == 513, "terrain grid is 513 x 513 cells of 1.5 m") + + var targets := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET) + var decoys := definition.placements_of_kind(SitePlacement.Kind.WRECK_DECOY) + var items := definition.placements_of_kind(SitePlacement.Kind.SECONDARY_ITEM) + var anchors := definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR) + var debris := definition.placements_of_kind(SitePlacement.Kind.DEBRIS) + var landmarks := definition.placements_of_kind(SitePlacement.Kind.LANDMARK) + _expect(targets.size() == 1, "exactly one target wreck") + _expect(decoys.size() == 2, "exactly two decoy wrecks") + _expect(items.size() == 1, "exactly one secondary item") + _expect(anchors.size() == 1, "exactly one vessel anchor") + _expect(debris.size() >= 3, "a debris trail of at least three pieces") + _expect(landmarks.size() >= 4, "at least four named landmarks") + + for wreck: SitePlacement in targets + decoys: + _expect(-wreck.position_m.y <= certified_m, "%s lies above the certified depth" % wreck.placement_id) + _expect(_rests_on_terrain(field, wreck), "%s rests on or in the seabed" % wreck.placement_id) + if items.size() == 1: + var item_depth := -items[0].position_m.y + _expect(item_depth > certified_m and item_depth <= critical_m, "secondary item is between 100 %% and 115 %% of the certified depth (%.1f m)" % item_depth) + _expect(_rests_on_terrain(field, items[0]), "secondary item rests on the seabed") + for piece: SitePlacement in debris: + if -piece.position_m.y < 45.0 or -piece.position_m.y > 66.0 or not _rests_on_terrain(field, piece): + _failures.append("debris %s is off the slope or floating" % piece.placement_id) + _expect(true, "debris pieces checked individually") + + if anchors.size() == 1: + var seabed_under_anchor := -field.elevation_at(anchors[0].position_m.x, anchors[0].position_m.z) + _expect(seabed_under_anchor >= 20.0 and seabed_under_anchor <= 30.0, "vessel anchors over 20-30 m of seabed") + var spawn := definition.spawn_position_m + _expect(spawn.y - field.elevation_at(spawn.x, spawn.z) >= 8.0, "spawn has at least 8 m of water under the keel") + _expect(is_equal_approx(-spawn.y, 10.0), "spawn is 10 m deep") + + var safe := definition.route(&"safe_ridge") + var risky := definition.route(&"risky_groove") + _expect(safe != null and safe.maximum_depth_m == 32.0, "safe route is declared with a 32 m ceiling") + _expect(risky != null and risky.maximum_depth_m == 48.0, "risky route is declared with a 48 m ceiling") + if safe != null and risky != null and targets.size() == 1: + _expect(_route_clears_terrain(field, safe), "safe route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + _expect(_route_clears_terrain(field, risky), "risky route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + var target := targets[0].position_m + _expect( + _xz_distance(safe.waypoints_m[-1], target) <= 25.0 and _xz_distance(risky.waypoints_m[-1], target) <= 25.0, + "both routes end within 25 m of the target" + ) + _expect(_route_length(risky) < _route_length(safe) * 0.9, "risky route is shorter than the safe route") + + var spaced := true + for i in landmarks.size(): + for j in range(i + 1, landmarks.size()): + if _xz_distance(landmarks[i].position_m, landmarks[j].position_m) < MINIMUM_LANDMARK_SPACING_M: + spaced = false + _expect(spaced, "landmarks are at least 60 m apart") + + +func _rests_on_terrain(field, placement: SitePlacement) -> bool: + var seabed := field.elevation_at(placement.position_m.x, placement.position_m.z) + var bottom := placement.position_m.y - placement.footprint_m.y * 0.5 + return bottom >= seabed - placement.footprint_m.y and bottom <= seabed + 1.0 + + +func _route_clears_terrain(field, route: SiteRoute) -> bool: + for i in range(route.waypoints_m.size() - 1): + var a := route.waypoints_m[i] + var b := route.waypoints_m[i + 1] + var steps := maxi(int(ceilf(a.distance_to(b) / 2.0)), 1) + for step in range(steps + 1): + var point := a.lerp(b, float(step) / float(steps)) + if point.y - field.elevation_at(point.x, point.z) < ROUTE_CLEARANCE_M: + return false + return true + + +func _route_length(route: SiteRoute) -> float: + var length := 0.0 + for i in range(route.waypoints_m.size() - 1): + length += route.waypoints_m[i].distance_to(route.waypoints_m[i + 1]) + return length + + +func _xz_distance(a: Vector3, b: Vector3) -> float: + return Vector2(a.x, a.z).distance_to(Vector2(b.x, b.z)) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) +``` + +`TEST_COUNT` is the number of `_expect` calls reached when everything passes: 3 (load, valid, bounds) + 2 (depth range, grid) + 6 (kinds) + 6 (two per wreck, three wrecks) + 2 (item) + 1 (debris) + 1 (anchor) + 2 (spawn) + 2 (route declarations) + 4 (route checks) + 1 (landmarks) = 30. Per-debris failures append directly to `_failures` and do not change the count. + +- [ ] **Step 2: Run, expect failure** (missing `.tres`): + +```bash +"$GODOT_BIN" --headless --path . --script tests/contract/run_site_content_tests.gd +``` + +- [ ] **Step 3: Write the zone 1 recipe** + +`tools/sites/zone1_vigilant_recipe.gd`. Coordinates: `+X` east, `+Z` south; the vessel is north (`z = -250`), the shelf break is the line `z = 125`, the drop-off is south of it. + +```gdscript +class_name Zone1VigilantRecipe +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") + +# Groove axis (la Saignée): north entrance to the shelf break. +const GROOVE_START := Vector2(-60.0, -140.0) +const GROOVE_END := Vector2(-10.0, 120.0) +# Ridge axis (l'Échine). Starts south of the vessel so its crest never rises above 20 m. +const RIDGE_START := Vector2(70.0, -150.0) +const RIDGE_END := Vector2(80.0, 90.0) +const BREAK_Z := 125.0 + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 513 + result.rows = 513 + result.cell_size_m = 1.5 + result.origin_x_m = -384.0 + result.origin_z_m = -384.0 + result.floor_elevation_m = -88.0 + result.rock_slope_degrees = 20.0 + result.rock_reflectivity = 0.72 + result.sand_reflectivity = 0.45 + result.primitives = [ + # Plateau: 24 m deep at the vessel (z = -300), 6 m deeper over 400 m toward the south. + SiteTerrainBaker.Plane.new(24.0, 0.0, -0.015, 0.0, -300.0), + # L'Échine: long north-south rock ridge, 6 m high, flanks steeper than 20°. + SiteTerrainBaker.Ridge.new(RIDGE_START, RIDGE_END, 12.0, 6.0, 25.0), + # Les Deux Frères: twin pinnacles, 20 m apart, 6 m tall (crests stay below 20 m deep). + SiteTerrainBaker.Blob.new(Vector2(-160.0, -130.0), 8.4, 8.4, 6.0), + SiteTerrainBaker.Blob.new(Vector2(-142.0, -121.0), 8.4, 8.4, 6.0), + # La Saignée: 16 m wide groove, 9 m deep, steep walls, the risky route. + SiteTerrainBaker.Groove.new(GROOVE_START, GROOVE_END, 8.0, 9.0, 4.0), + # Basin and patch reef for the steel coaster decoy, east of the ridge. + SiteTerrainBaker.Blob.new(Vector2(140.0, 40.0), 30.0, 30.0, -8.0), + SiteTerrainBaker.Blob.new(Vector2(125.0, 30.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(155.0, 55.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(135.0, 60.0), 7.7, 7.7, 2.5), + # Shelf break (la rupture) then the drop-off (le tombant), clamped at -88 m. + SiteTerrainBaker.Step.new(Vector2(0.0, BREAK_Z), Vector2(0.0, 1.0), 16.0, 10.0, 0.7), + # Scree cones at the foot of the drop-off. + SiteTerrainBaker.Blob.new(Vector2(-40.0, 200.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(-5.0, 205.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, 198.0), 6.3, 6.3, 2.0), + # Les Rides: sand ripples near the vessel only. + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.35), 9.0, 0.45, Vector2(0.0, -220.0), 90.0), + # Deterministic roughness everywhere. + SiteTerrainBaker.Roughness.new(1933, 0.02, 0.6), + ] + result.material_patches = [ + SiteTerrainBaker.MaterialPatch.new(Vector2(140.0, 40.0), 28.0, SonarMaterial.Value.ROCK), + ] + return result + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + var result := DiveSiteDefinition.new() + result.site_id = &"zone1_vigilant" + result.display_name = "Banc du Vigilant" + result.terrain = terrain + result.playable_bounds_m = Rect2(-300.0, -300.0, 600.0, 600.0) + result.spawn_position_m = Vector3(0.0, -10.0, -250.0) + result.spawn_yaw_radians = 0.0 + result.placements = [ + _on_seabed(field, &"prospection_vessel", SitePlacement.Kind.WRECK_TARGET, + Vector2(-12.0, 108.0), 0.35, Vector3(28.0, 8.0, 9.0), 2.0), + _on_seabed(field, &"decoy_yacht", SitePlacement.Kind.WRECK_DECOY, + Vector2(-40.0, -200.0), 1.1, Vector3(12.0, 3.0, 4.0), 0.5), + _on_seabed(field, &"decoy_coaster", SitePlacement.Kind.WRECK_DECOY, + Vector2(140.0, 40.0), -0.6, Vector3(26.0, 6.0, 8.0), 1.2), + _on_seabed(field, &"secondary_item", SitePlacement.Kind.SECONDARY_ITEM, + Vector2(-6.0, 157.0), 0.0, Vector3(1.0, 1.0, 1.0), 0.0), + _on_seabed(field, &"debris_1", SitePlacement.Kind.DEBRIS, + Vector2(-12.0, 134.0), 0.4, Vector3(3.0, 2.0, 2.0), 0.3), + _on_seabed(field, &"debris_2", SitePlacement.Kind.DEBRIS, + Vector2(-10.0, 139.0), 1.2, Vector3(6.0, 2.5, 2.5), 0.4), + _on_seabed(field, &"debris_3", SitePlacement.Kind.DEBRIS, + Vector2(-9.0, 146.0), 0.0, Vector3(2.0, 1.5, 2.0), 0.2), + _on_seabed(field, &"debris_4", SitePlacement.Kind.DEBRIS, + Vector2(-7.0, 152.0), 2.0, Vector3(4.0, 1.5, 1.5), 0.3), + _marker(&"vessel_anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(0.0, -14.0, -250.0)), + _marker(&"echine", SitePlacement.Kind.LANDMARK, Vector3(75.0, -20.0, -50.0)), + _marker(&"deux_freres", SitePlacement.Kind.LANDMARK, Vector3(-151.0, -18.0, -125.0)), + _marker(&"rides", SitePlacement.Kind.LANDMARK, Vector3(0.0, -25.0, -230.0)), + _marker(&"saignee_entrance", SitePlacement.Kind.LANDMARK, Vector3(-60.0, -34.0, -140.0)), + _marker(&"rupture", SitePlacement.Kind.LANDMARK, Vector3(40.0, -40.0, 125.0)), + ] + result.routes = [ + _route(&"safe_ridge", 32.0, [ + Vector3(0.0, -14.0, -250.0), Vector3(70.0, -16.0, -200.0), Vector3(76.0, -16.0, -100.0), + Vector3(78.0, -16.0, 0.0), Vector3(76.0, -18.0, 80.0), Vector3(40.0, -24.0, 105.0), + Vector3(0.0, -26.0, 100.0), Vector3(-14.0, -30.0, 98.0), + ]), + _route(&"risky_groove", 48.0, [ + Vector3(0.0, -14.0, -250.0), Vector3(-60.0, -20.0, -150.0), Vector3(-50.0, -31.0, -100.0), + Vector3(-35.0, -33.0, -20.0), Vector3(-22.0, -34.0, 60.0), Vector3(-14.0, -33.0, 98.0), + ]), + ] + return result + + +static func _on_seabed( + field: TerrainHeightField, + id: StringName, + kind: SitePlacement.Kind, + xz: Vector2, + yaw_radians: float, + footprint_m: Vector3, + burial_m: float +) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.footprint_m = footprint_m + placement.yaw_radians = yaw_radians + var seabed := field.elevation_at(xz.x, xz.y) + placement.position_m = Vector3(xz.x, seabed + footprint_m.y * 0.5 - burial_m, xz.y) + return placement + + +static func _marker(id: StringName, kind: SitePlacement.Kind, position_m: Vector3) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.position_m = position_m + return placement + + +static func _route(id: StringName, maximum_depth_m: float, waypoints: Array) -> SiteRoute: + var route := SiteRoute.new() + route.route_id = id + route.maximum_depth_m = maximum_depth_m + route.waypoints_m = PackedVector3Array(waypoints) + return route +``` + +Expected values from this recipe (check against the bake log and the contract test): plateau ≈ 24.75 m at the vessel; ridge and pinnacle crests ≈ 20.5–22.5 m (never shallower than 20 m, roughness ± 0.6 m included); yacht ≈ 25.5 m; coaster basin ≈ 36.8 m (box 6 m tall, top ≈ 32 m); groove floor at the target ≈ 39 m (hull 8 m tall, 2 m buried, top ≈ 33 m); secondary item ≈ 62 m; debris ≈ 45–59 m; drop-off reaches 88 m at `z ≈ 195`. Route lengths ≈ 430 m (safe) and ≈ 370 m (risky): shorter, darker and deeper, not half the length as the brief first estimated; Task 11 corrects the brief. + +- [ ] **Step 4: Bake zone 1** + +```bash +"$GODOT_BIN" --headless --path . --script tools/bake_site_terrain.gd -- zone1_vigilant +``` +Expected: `Baked zone1_vigilant: 513 x 513 cells, elevation -88.0 .. -2x.x m, rock xx %` plus `content/sites/zone1_vigilant_terrain.res`, `content/sites/zone1_vigilant.tres`, `docs/briefs/captures/zone1_vigilant_terrain_preview.png`. Open the preview: ripples top-centre, a bright ridge running down the right-centre, two bright dots left, a dark diagonal groove, a dark band across the lower third (the drop-off), a reddish patch right-centre (reef). If the preview is mirrored top/bottom, the baker's row/`z` mapping is wrong; fix the baker, not the recipe. + +- [ ] **Step 5: Run the contract suite, expect pass** → `Site content contract tests: 32 passed, 0 failed.` + +If a route clearance or burial check fails, adjust the failing waypoint `y` (keep safe ≤ 32 m, risky ≤ 48 m) or the `burial_m`, re-bake, re-run. The recipe is the authored document; never edit the `.tres` by hand. + +- [ ] **Step 6: Add to the gate**, after the site baker block: + +```bash +printf 'Quality gate: site content contract tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-content-contract.log" \ + --path . \ + --script tests/contract/run_site_content_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" +check_godot_log "site content contract tests" "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" +``` + +- [ ] **Step 7: Commit** + +```bash +git add tools/sites/zone1_vigilant_recipe.gd tools/sites/*.uid content/sites tests/contract scripts/quality_gate.sh docs/briefs/captures +git commit -m "feat(content): bake the Banc du Vigilant site and its contract tests + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 11: Zone 1 becomes the default dive, documentation, full gate + +**Files:** +- Modify: `game/composition/vertical_slice/vertical_slice_root.gd` (`DEFAULT_SITE_PATH`) +- Modify: `tests/README.md`, `content/README.md`, `docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`, `docs/briefs/terrain-site-zone1-gameplay.md` + +- [ ] **Step 1: Flip the default** + +```gdscript +const DEFAULT_SITE_PATH := "res://content/sites/zone1_vigilant.tres" +``` + +The foundation test keeps selecting the calibration site explicitly, so it is unaffected. + +- [ ] **Step 2: Run the whole gate** + +```bash +GODOT_BIN="$GODOT_BIN" bash scripts/quality_gate.sh +``` +Expected: ends with `Quality gate: passed.` and no `ERROR`, `SCRIPT ERROR` or `WARNING` lines. Fix causes, never checks. + +- [ ] **Step 3: Play it once** + +```bash +"$GODOT_BIN" --path . +``` +`Nouvelle partie`: the submarine hangs 10 m deep under the beacon, seabed ≈ 15 m below, sand ripples visible in the lights. `F` scans: ripples read as regular waves, no metal contact at the start. Heading south (`W`), the ridge appears to the left-front as a long bright crest on the sonar; the groove entrance is a dark trench to the right-front. Note the frame time in the debugger: 60 FPS at 1280 × 720 on the development machine is the budget; if tiles are too costly, lower `VISIBILITY_RANGE_END_M` before anything else. Take one screenshot of the sonar over the groove and save it as `docs/briefs/captures/zone1_vigilant_sonar_groove.png`. + +- [ ] **Step 4: Documentation** + +`tests/README.md`: add the four suites to "Suites de production actuelles": + +```markdown +- `unit/run_site_domain_tests.gd` : données de terrain, champ de hauteur, + définition de site et ses placements/routes ; +- `unit/run_site_application_tests.gd` : règle de limites jouables ; +- `unit/run_site_baker_tests.gd` : primitives et déterminisme de la cuisson ; +- `contract/run_site_content_tests.gd` : règles de contenu du Banc du Vigilant + (profondeurs, routes, amers) ; +- `integration/run_site_terrain_integration.gd` : collision égale au champ, + matériaux sonar par cellule, scène de site et budget de chargement. +``` + +`content/README.md`: append + +```markdown +## Sites + +`sites/.tres` est une `DiveSiteDefinition` ; `sites/_terrain.res` +est son `SiteTerrainData`. Les deux sont produits par +`tools/bake_site_terrain.gd -- ` depuis `tools/sites/_recipe.gd`, +la recette étant le document d'auteur (ADR 0012). Ne pas éditer les fichiers +produits à la main. +``` + +`docs/VERTICAL_SLICE_TECHNICAL_PLAN.md`: in "Tranche 3 — site et découverte", add before the bullet list: + +```markdown +État au 2026-10-06 : terrain du site implémenté. Le Banc du Vigilant +(`content/sites/zone1_vigilant.tres`) est chargé par `Nouvelle partie` ; ses +placements d'épaves sont encore des volumes graybox métalliques. Voir +`docs/briefs/terrain-site-zone1-gameplay.md` et l'ADR 0012. +``` + +and in section 16 add item 8: `8. le terrain de site en champ de hauteur authoré, formalisé dans docs/adr/0012-authored-heightfield-dive-sites.md.` + +`docs/briefs/terrain-site-zone1-gameplay.md`: in §8 replace the heightmap/PNG wording in the table row and the paragraph after it with: terrain stored as `SiteTerrainData` `.res` (float32 elevations + byte materials) produced by the recipe; 16-bit PNG rejected (ADR 0012); change `content/sites/zone1_vigilant_height.png` + `_mask.png` to `zone1_vigilant_terrain.res`, and the `DiveSiteDefinition` field list to the Task 4 fields. In §4.4 replace "≈ 420 m" / "≈ 260 m" / "Récompense : moitié moins de distance" with the measured lengths (≈ 430 m and ≈ 370 m) and "Récompense : trajet plus court et direct sur la proue". In §4.5 and §4.2 set the pinnacles and the ridge to 6 m high (the 20 m minimum depth of the site caps them). + +- [ ] **Step 5: Commit** + +```bash +git add game/composition/vertical_slice/vertical_slice_root.gd tests/README.md content/README.md docs/VERTICAL_SLICE_TECHNICAL_PLAN.md docs/briefs/terrain-site-zone1-gameplay.md docs/briefs/captures/zone1_vigilant_sonar_groove.png +git commit -m "feat(sites): dive on the Banc du Vigilant by default + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +## Out of scope (next plans) + +- Real wreck meshes, decoy fact points, surface-sonar echo zones (rest of tranche 3). +- Textured/sculpted terrain pass and props (tranche 6). +- Currents in the groove (V0.5). +- The frontal-beam acquisition comparison and its ADR (tranche 2 close-out). diff --git a/docs/superpowers/plans/2026-10-06-terrain-varied-landforms.md b/docs/superpowers/plans/2026-10-06-terrain-varied-landforms.md new file mode 100644 index 0000000..6b9e59a --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-terrain-varied-landforms.md @@ -0,0 +1,56 @@ +# Formes de relief variées — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the bake tool with polyline canyons, noise-edged outcrops, boulder fields, polyline escarpments and a strata modifier, then bake a `terrain_showcase` site that demonstrates all five reference landforms and is covered by geometric contract tests. + +**Architecture:** `tools/site_terrain_baker.gd` gains a `Modifier` phase (after the primitive sum, with the local gradient) and a per-primitive material override hook. New primitives are nested classes next to the existing ones. A new recipe `tools/sites/terrain_showcase_recipe.gd` and its baked `content/sites/terrain_showcase*.{tres,res}` are the demonstration site. Nothing under `game/` changes. + +**Tech Stack:** Godot 4.7.2 headless, GDScript, `bash scripts/quality_gate.sh`. + +**Spec:** `docs/briefs/terrain-varied-landforms.md`. + +## Global Constraints + +- Godot binary (Git Bash): `export GODOT_BIN="/c/Users/olivi/Downloads/Godot_v4.7.2-stable_win64.exe/Godot_v4.7.2-stable_win64_console.exe"`; ripgrep: `export PATH="$PATH:/c/Users/olivi/AppData/Local/Programs/Microsoft VS Code/resources/app/node_modules/@vscode/ripgrep/bin"`. +- `tools/` depends on `game/domain/` only; nothing under `game/` or `content/` references `res://tools/` (architecture check). +- Determinism: every random element is a seeded `FastNoiseLite` or `RandomNumberGenerator`; the same recipe bakes byte-identical arrays. +- `content/sites/zone1_vigilant_terrain.res` must not change (re-bake and `git diff --stat` empty for it). +- Test runners: `TEST_COUNT` equals the number of `_expect` calls. +- Never hand-edit `.tres`/`.res`. +- Commits end with `Co-Authored-By: Claude Fable 5.1 `. + +## Tasks + +### Task 1: Modifier phase and per-primitive material hook +- `Primitive.material_at(x, z) -> int` returning `NO_MATERIAL := -1`. +- `Modifier.modify(x, z, elevation, gradient: Vector2) -> float`. +- `Recipe.modifiers: Array[Modifier]`. +- `bake()` order: sum → modifiers (gradient by central differences on the current array) → floor → slope material → primitive material → patches. +- Unit tests: a modifier that adds 1 m everywhere shifts the field; a primitive forcing ROCK on a flat plane wins over the slope rule; zone 1 recipe bakes identical to the checked-in `.res`. + +### Task 2: `Canyon` and `Escarpment` (polyline primitives) +- Shared static `distance_to_path(point, path) -> float`, `arc_position(point, path) -> Vector2(along_m, total_m)` and `signed_distance_to_path` (sign from the nearest segment's cross product, positive on the right of travel). +- Edge noise: `FastNoiseLite` seeded, `TYPE_SIMPLEX_SMOOTH`, frequency 0.05, amplitude `edge_amplitude_m`. +- Tests: Canyon depth on the flat floor, zero far away, follows a corner, closes over one half-width at the ends; Escarpment right side lower by `drop_m`, left side unchanged. + +### Task 3: `Outcrop` and `BoulderField` +- Outcrop: `e = (sqrt((dx/rx)² + (dz/rz)²) - 1) * mean_radius + noise`, `delta = height * (1 - smoothstep(-wall_width, 0, e))`, rock where `delta > 0.3 * height` and `rock_top`. +- BoulderField: `RandomNumberGenerator` seeded; disc sampling `r = radius * sqrt(u)`, angle `TAU * v`; per boulder `h = r_b * height_ratio`, profile `h * sqrt(max(0, 1 - (d / r_b)²))`; combine by `maxf`; early reject outside `radius + max_r`; rock where profile > 0. +- Tests: plateau height, rock on top, zero at two radii; boulder field deterministic, zero outside, rock on a boulder, two overlapping boulders do not sum. + +### Task 4: `Strata` modifier +- Formula from the brief §4.3. Tests: a 45° plane with 2 m layers and sharpness 0.9 produces at least two flat runs (|gradient| < 0.1) on a transect; a flat plane is unchanged; sharpness 0 is identity. + +### Task 5: `terrain_showcase` recipe, bake, registry +- Recipe per brief §5; `bake_site_terrain.gd` RECIPES gains `terrain_showcase`; bake; preview PNG under `docs/briefs/captures/`. +- Verify zone 1 re-bake identical. + +### Task 6: Contract and integration tests for the showcase +- `tests/contract/run_site_content_tests.gd`: new `_test_showcase_content()` per brief §6. +- `tests/integration/run_site_terrain_integration.gd`: showcase loads < 3 s, canyon ray lower than mesa ray, scan reads rock and sand. +- Foundation test: `select_dive_site` on the showcase is not required. + +### Task 7: Docs +- ADR 0012 amendment, `content/README.md`, `tests/README.md`, technical plan status line, brief status. +- Full gate green. diff --git a/game/application/sites/playable_bounds_rule.gd b/game/application/sites/playable_bounds_rule.gd new file mode 100644 index 0000000..8d7ff7d --- /dev/null +++ b/game/application/sites/playable_bounds_rule.gd @@ -0,0 +1,38 @@ +class_name PlayableBoundsRule +extends RefCounted + +const PlayableBoundsStatus = preload( + "res://game/application/sites/playable_bounds_status.gd" +) + +var _bounds_m: Rect2 +var _warning_margin_m: float + + +func _init(bounds_m: Rect2, warning_margin_m: float = 40.0) -> void: + _bounds_m = bounds_m + _warning_margin_m = maxf(warning_margin_m, 0.0) + + +func evaluate(position_m: Vector3) -> PlayableBoundsStatus: + var point := Vector2(position_m.x, position_m.z) + var to_west := point.x - _bounds_m.position.x + var to_east := _bounds_m.end.x - point.x + var to_north := point.y - _bounds_m.position.y + var to_south := _bounds_m.end.y - point.y + var distance := minf(minf(to_west, to_east), minf(to_north, to_south)) + var state := PlayableBoundsStatus.State.INSIDE + if distance < 0.0: + state = PlayableBoundsStatus.State.OUTSIDE + elif distance < _warning_margin_m: + state = PlayableBoundsStatus.State.NEAR_EDGE + var direction := Vector3.ZERO + if state != PlayableBoundsStatus.State.INSIDE: + var centre := _bounds_m.get_center() + var toward: Vector2 + if distance == to_west or distance == to_east: + toward = Vector2(signf(centre.x - point.x), 0.0) + else: + toward = Vector2(0.0, signf(centre.y - point.y)) + direction = Vector3(toward.x, 0.0, toward.y) + return PlayableBoundsStatus.new(state, distance, direction) diff --git a/game/application/sites/playable_bounds_status.gd b/game/application/sites/playable_bounds_status.gd new file mode 100644 index 0000000..a694601 --- /dev/null +++ b/game/application/sites/playable_bounds_status.gd @@ -0,0 +1,22 @@ +class_name PlayableBoundsStatus +extends RefCounted + +enum State { + INSIDE, + NEAR_EDGE, + OUTSIDE, +} + +var state: State +var distance_to_edge_m: float +var return_direction: Vector3 + + +func _init(p_state: State, p_distance_to_edge_m: float, p_return_direction: Vector3) -> void: + state = p_state + distance_to_edge_m = p_distance_to_edge_m + return_direction = p_return_direction + + +func requires_warning() -> bool: + return state != State.INSIDE diff --git a/game/composition/vertical_slice/vehicle_test_world.tscn b/game/composition/vertical_slice/dive_world.tscn similarity index 72% rename from game/composition/vertical_slice/vehicle_test_world.tscn rename to game/composition/vertical_slice/dive_world.tscn index 74dbbbb..ff5b844 100644 --- a/game/composition/vertical_slice/vehicle_test_world.tscn +++ b/game/composition/vertical_slice/dive_world.tscn @@ -1,17 +1,17 @@ [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/sites/dive_site.tscn" id="1_site"] [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="DiveWorld" type="Node3D"] -[node name="Environment" parent="." instance=ExtResource("1_environment")] +[node name="DiveSite" parent="." instance=ExtResource("1_site")] +unique_name_in_owner = true [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 diff --git a/game/composition/vertical_slice/vertical_slice_root.gd b/game/composition/vertical_slice/vertical_slice_root.gd index 2c2c4e8..eea2df7 100644 --- a/game/composition/vertical_slice/vertical_slice_root.gd +++ b/game/composition/vertical_slice/vertical_slice_root.gd @@ -29,6 +29,12 @@ const SubmarineTelemetryAdapter = preload( const ReferenceBathymetryAcquisition = preload( "res://game/world/sonar/reference_bathymetry_acquisition.gd" ) +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const DiveSite = preload("res://game/world/sites/dive_site.gd") +const PlayableBoundsRule = preload("res://game/application/sites/playable_bounds_rule.gd") +const PlayableBoundsStatus = preload( + "res://game/application/sites/playable_bounds_status.gd" +) const ProductionInputAdapter = preload( "res://game/infrastructure/input/production_input_adapter.gd" ) @@ -51,9 +57,8 @@ const ConfirmationDialogScreen = preload( const RebindResult = preload("res://game/application/input/rebind_result.gd") const MAIN_MENU_SCENE = preload("res://game/presentation/main_menu/main_menu_screen.tscn") -const VEHICLE_TEST_WORLD_SCENE_PATH := ( - "res://game/composition/vertical_slice/vehicle_test_world.tscn" -) +const DIVE_WORLD_SCENE_PATH := "res://game/composition/vertical_slice/dive_world.tscn" +const DEFAULT_SITE_PATH := "res://content/sites/zone1_vigilant.tres" const COCKPIT_HUD_SCENE = preload("res://game/presentation/cockpit/cockpit_hud.tscn") const SONAR_DISPLAY_SCENE = preload( "res://game/presentation/sonar/sonar_display.tscn" @@ -87,6 +92,10 @@ var _sonar_service: SonarService var _sonar_display: SonarDisplay var _latest_update: DiveUpdate var _active_world: Node3D +var _selected_site_path := DEFAULT_SITE_PATH +var _dive_site: DiveSite +var _bounds_rule: PlayableBoundsRule +var _bounds_status: PlayableBoundsStatus var _active_screen: Control var _active_overlay: Control var _vehicle_command_sequence := 0 @@ -105,7 +114,7 @@ func _ready() -> void: _show_main_menu() -func _start_vehicle_test() -> void: +func _start_dive() -> void: var rules = DiveRulesResolver.new().resolve( DiveDifficultyProfile.standard(), DiveUpgradeEffects.new(), @@ -117,12 +126,25 @@ func _start_vehicle_test() -> void: if not session.is_ready(): return - var world_scene := load(VEHICLE_TEST_WORLD_SCENE_PATH) as PackedScene + var definition := load(_selected_site_path) as DiveSiteDefinition + if definition == null: + push_error("dive site definition %s cannot be loaded" % _selected_site_path) + return + var world_scene := load(DIVE_WORLD_SCENE_PATH) as PackedScene if world_scene == null: return var world := world_scene.instantiate() as Node3D _replace_active_world(world) + _dive_site = world.get_node("%DiveSite") as DiveSite + var site_error := _dive_site.configure(definition) + if not site_error.is_empty(): + push_error("dive site %s cannot be built: %s" % [_selected_site_path, site_error]) + _show_main_menu() + return _actor = world.get_node("%SubmarineActor") as SubmarineActor + _actor.global_transform = _dive_site.spawn_transform() + _bounds_rule = PlayableBoundsRule.new(definition.playable_bounds_m) + _bounds_status = _bounds_rule.evaluate(_actor.global_position) _camera_controller = world.get_node("%CockpitCameraRig") as CockpitCameraController _instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel _hud = COCKPIT_HUD_SCENE.instantiate() as CockpitHud @@ -154,6 +176,7 @@ func _start_vehicle_test() -> void: _runner.next_sequence() ) _hud.present(_latest_update, initial_telemetry) + _hud.present_bounds(_bounds_status) _instrument_panel.present(_latest_update, initial_telemetry) _camera_controller.present_telemetry(initial_telemetry) _input_adapter.set_gameplay_enabled(true) @@ -196,6 +219,8 @@ func _physics_process(delta: float) -> void: return_route ) _hud.present(_latest_update, telemetry) + _bounds_status = _bounds_rule.evaluate(_actor.global_position) + _hud.present_bounds(_bounds_status) _instrument_panel.present(_latest_update, telemetry) _sonar_service.advance(delta) _present_sonar() @@ -243,6 +268,18 @@ func sonar_display() -> SonarDisplay: return _sonar_display +func select_dive_site(definition_path: String) -> void: + _selected_site_path = definition_path + + +func dive_site() -> DiveSite: + return _dive_site + + +func latest_bounds_status() -> PlayableBoundsStatus: + return _bounds_status + + func _finish_dive_motion() -> void: _dive_terminal = true _input_adapter.set_gameplay_enabled(false) @@ -444,6 +481,9 @@ func _show_main_menu() -> void: _pilot_use_case = null _telemetry_adapter = null _actor = null + _dive_site = null + _bounds_rule = null + _bounds_status = null _camera_controller = null _instrument_panel = null _hud = null @@ -457,7 +497,7 @@ func _show_main_menu() -> void: _replace_active_world(null) var screen := MAIN_MENU_SCENE.instantiate() as MainMenuScreen _replace_active_screen(screen) - screen.new_game_requested.connect(_start_vehicle_test) + screen.new_game_requested.connect(_start_dive) screen.quit_requested.connect(_quit_game) diff --git a/game/domain/sites/dive_site_definition.gd b/game/domain/sites/dive_site_definition.gd new file mode 100644 index 0000000..3142eda --- /dev/null +++ b/game/domain/sites/dive_site_definition.gd @@ -0,0 +1,96 @@ +class_name DiveSiteDefinition +extends Resource + +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") + +@export var site_id: StringName +@export var display_name: String = "" +@export var terrain: SiteTerrainData +## Metres, world XZ: position = (x_min, z_min), size = (width along +X, depth along +Z). +@export var playable_bounds_m: Rect2 = Rect2() +@export var spawn_position_m: Vector3 = Vector3.ZERO +@export var spawn_yaw_radians: float = 0.0 +@export var placements: Array[SitePlacement] = [] +@export var routes: Array[SiteRoute] = [] + + +func height_field() -> TerrainHeightField: + return TerrainHeightField.from_data(terrain) + + +func spawn_transform() -> Transform3D: + return Transform3D(Basis(Vector3.UP, spawn_yaw_radians), spawn_position_m) + + +func placement(placement_id: StringName) -> SitePlacement: + for candidate: SitePlacement in placements: + if candidate != null and candidate.placement_id == placement_id: + return candidate + return null + + +func placements_of_kind(kind: SitePlacement.Kind) -> Array[SitePlacement]: + var result: Array[SitePlacement] = [] + for candidate: SitePlacement in placements: + if candidate != null and candidate.kind == kind: + result.append(candidate) + return result + + +func route(route_id: StringName) -> SiteRoute: + for candidate: SiteRoute in routes: + if candidate != null and candidate.route_id == route_id: + return candidate + return null + + +func validation_error() -> String: + if site_id.is_empty(): + return "site id is empty" + if terrain == null: + return "site terrain is missing" + var terrain_error := terrain.validation_error() + if not terrain_error.is_empty(): + return terrain_error + var field := TerrainHeightField.from_data(terrain) + if playable_bounds_m.size.x <= 0.0 or playable_bounds_m.size.y <= 0.0: + return "playable bounds must have a positive size" + if not field.bounds_m().encloses(playable_bounds_m): + return "playable bounds must lie inside the terrain" + if not _inside_bounds(spawn_position_m): + return "spawn position must lie inside the playable bounds" + if not is_finite(spawn_yaw_radians): + return "spawn yaw must be finite" + var seen_ids: Dictionary = {} + for candidate: SitePlacement in placements: + if candidate == null: + return "placement entry is null" + var placement_error := candidate.validation_error() + if not placement_error.is_empty(): + return placement_error + if seen_ids.has(candidate.placement_id): + return "placement id %s is duplicated" % candidate.placement_id + seen_ids[candidate.placement_id] = true + if not _inside_bounds(candidate.position_m): + return "placement %s lies outside the playable bounds" % candidate.placement_id + var seen_routes: Dictionary = {} + for candidate_route: SiteRoute in routes: + if candidate_route == null: + return "route entry is null" + var route_error := candidate_route.validation_error() + if not route_error.is_empty(): + return route_error + if seen_routes.has(candidate_route.route_id): + return "route id %s is duplicated" % candidate_route.route_id + seen_routes[candidate_route.route_id] = true + for waypoint: Vector3 in candidate_route.waypoints_m: + if not _inside_bounds(waypoint): + return "route %s leaves the playable bounds" % candidate_route.route_id + return "" + + +func _inside_bounds(position: Vector3) -> bool: + return playable_bounds_m.has_point(Vector2(position.x, position.z)) diff --git a/game/domain/sites/site_placement.gd b/game/domain/sites/site_placement.gd new file mode 100644 index 0000000..1c05afc --- /dev/null +++ b/game/domain/sites/site_placement.gd @@ -0,0 +1,35 @@ +class_name SitePlacement +extends Resource + +enum Kind { + WRECK_TARGET, + WRECK_DECOY, + SECONDARY_ITEM, + DEBRIS, + LANDMARK, + VESSEL_ANCHOR, +} + +@export var placement_id: StringName +@export var kind: Kind = Kind.LANDMARK +@export var position_m: Vector3 = Vector3.ZERO +@export var yaw_radians: float = 0.0 +@export var footprint_m: Vector3 = Vector3.ZERO + + +func is_sonar_metal() -> bool: + return kind in [Kind.WRECK_TARGET, Kind.WRECK_DECOY, Kind.SECONDARY_ITEM, Kind.DEBRIS] + + +func validation_error() -> String: + if placement_id.is_empty(): + return "placement id is empty" + if not (is_finite(position_m.x) and is_finite(position_m.y) and is_finite(position_m.z)): + return "placement position must be finite" + if not is_finite(yaw_radians): + return "placement yaw must be finite" + if footprint_m.x < 0.0 or footprint_m.y < 0.0 or footprint_m.z < 0.0: + return "placement footprint must not be negative" + if is_sonar_metal() and footprint_m.length_squared() == 0.0: + return "sonar bodies need a footprint" + return "" diff --git a/game/domain/sites/site_route.gd b/game/domain/sites/site_route.gd new file mode 100644 index 0000000..a2959e9 --- /dev/null +++ b/game/domain/sites/site_route.gd @@ -0,0 +1,21 @@ +class_name SiteRoute +extends Resource + +@export var route_id: StringName +@export var waypoints_m := PackedVector3Array() +@export var maximum_depth_m: float = 0.0 + + +func validation_error() -> String: + if route_id.is_empty(): + return "route id is empty" + if waypoints_m.size() < 2: + return "route needs at least two waypoints" + if not is_finite(maximum_depth_m) or maximum_depth_m <= 0.0: + return "route maximum depth must be positive" + for waypoint: Vector3 in waypoints_m: + if not (is_finite(waypoint.x) and is_finite(waypoint.y) and is_finite(waypoint.z)): + return "route waypoints must be finite" + if -waypoint.y > maximum_depth_m: + return "route waypoint exceeds the route maximum depth" + return "" diff --git a/game/domain/sites/site_terrain_data.gd b/game/domain/sites/site_terrain_data.gd new file mode 100644 index 0000000..f014c3d --- /dev/null +++ b/game/domain/sites/site_terrain_data.gd @@ -0,0 +1,44 @@ +class_name SiteTerrainData +extends Resource + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") + +## Regular single-surface heightfield. Column grows with world +X (east), +## row grows with world +Z (south). Elevations are world Y in metres. +@export var columns: int = 0 +@export var rows: int = 0 +@export var cell_size_m: float = 1.0 +@export var origin_x_m: float = 0.0 +@export var origin_z_m: float = 0.0 +@export var elevations_m := PackedFloat32Array() +@export var materials := PackedByteArray() +@export var rock_reflectivity: float = 0.72 +@export var sand_reflectivity: float = 0.45 + + +func index_of(column: int, row: int) -> int: + return row * columns + column + + +func validation_error() -> String: + if columns < 2 or rows < 2: + return "terrain needs at least 2 columns and 2 rows" + if not is_finite(cell_size_m) or cell_size_m <= 0.0: + return "terrain cell size must be positive" + if not is_finite(origin_x_m) or not is_finite(origin_z_m): + return "terrain origin must be finite" + var cell_count := columns * rows + if elevations_m.size() != cell_count: + return "terrain elevation count does not match the grid" + if materials.size() != cell_count: + return "terrain material count does not match the grid" + for elevation: float in elevations_m: + if not is_finite(elevation): + return "terrain elevations must be finite" + for material: int in materials: + if not SonarMaterial.is_terrain(material): + return "terrain materials must be rock or sand" + for reflectivity: float in [rock_reflectivity, sand_reflectivity]: + if not is_finite(reflectivity) or reflectivity <= 0.0 or reflectivity > 1.0: + return "terrain reflectivity must be within ]0, 1]" + return "" diff --git a/game/domain/sites/terrain_height_field.gd b/game/domain/sites/terrain_height_field.gd new file mode 100644 index 0000000..4a2c139 --- /dev/null +++ b/game/domain/sites/terrain_height_field.gd @@ -0,0 +1,383 @@ +class_name TerrainHeightField +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") + +var _data: SiteTerrainData +var _minimum_elevation_m := 0.0 +var _maximum_elevation_m := 0.0 + + +static func from_data(data: SiteTerrainData) -> TerrainHeightField: + if data == null or not data.validation_error().is_empty(): + return null + var field := TerrainHeightField.new() + field._data = data + field._minimum_elevation_m = data.elevations_m[0] + field._maximum_elevation_m = data.elevations_m[0] + for elevation: float in data.elevations_m: + field._minimum_elevation_m = minf(field._minimum_elevation_m, elevation) + field._maximum_elevation_m = maxf(field._maximum_elevation_m, elevation) + return field + + +func columns() -> int: + return _data.columns + + +func rows() -> int: + return _data.rows + + +func cell_size_m() -> float: + return _data.cell_size_m + + +func origin_m() -> Vector2: + return Vector2(_data.origin_x_m, _data.origin_z_m) + + +func bounds_m() -> Rect2: + return Rect2( + _data.origin_x_m, + _data.origin_z_m, + float(_data.columns - 1) * _data.cell_size_m, + float(_data.rows - 1) * _data.cell_size_m + ) + + +func contains(x_m: float, z_m: float) -> bool: + return bounds_m().has_point(Vector2(x_m, z_m)) + + +func minimum_elevation_m() -> float: + return _minimum_elevation_m + + +func maximum_elevation_m() -> float: + return _maximum_elevation_m + + +## Row-major elevations (index = row * columns + column), the HeightMapShape3D layout. +func elevations_m() -> PackedFloat32Array: + return _data.elevations_m + + +## Row-major sonar materials, same layout as elevations_m. +func materials() -> PackedByteArray: + return _data.materials + + +func elevation_at_cell(column: int, row: int) -> float: + return _data.elevations_m[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func material_at_cell(column: int, row: int) -> int: + return _data.materials[_data.index_of(_clamp_column(column), _clamp_row(row))] + + +func cell_world_position(column: int, row: int) -> Vector3: + return Vector3( + _data.origin_x_m + float(column) * _data.cell_size_m, + elevation_at_cell(column, row), + _data.origin_z_m + float(row) * _data.cell_size_m + ) + + +func normal_at_cell(column: int, row: int) -> Vector3: + return _normal_at(_data.elevations_m, _clamp_column(column), _clamp_row(row)) + + +## World positions of the cells [start_column..end_column] x [start_row..end_row] +## (bounds included, clamped to the grid), row-major. Same values as cell_world_position. +func cell_block_positions(start_column: int, end_column: int, start_row: int, end_row: int) -> PackedVector3Array: + var first_column := _clamp_column(start_column) + var last_column := _clamp_column(end_column) + var first_row := _clamp_row(start_row) + var last_row := _clamp_row(end_row) + var elevations := _data.elevations_m + var columns := _data.columns + var cell_size := _data.cell_size_m + var positions := PackedVector3Array() + positions.resize((last_column - first_column + 1) * (last_row - first_row + 1)) + var index := 0 + for row in range(first_row, last_row + 1): + var z := _data.origin_z_m + float(row) * cell_size + var row_start := row * columns + for column in range(first_column, last_column + 1): + positions[index] = Vector3( + _data.origin_x_m + float(column) * cell_size, + elevations[row_start + column], + z + ) + index += 1 + return positions + + +## Normals of the same cell block, row-major. Same values as normal_at_cell. +func cell_block_normals(start_column: int, end_column: int, start_row: int, end_row: int) -> PackedVector3Array: + var first_column := _clamp_column(start_column) + var last_column := _clamp_column(end_column) + var first_row := _clamp_row(start_row) + var last_row := _clamp_row(end_row) + var elevations := _data.elevations_m + var normals := PackedVector3Array() + normals.resize((last_column - first_column + 1) * (last_row - first_row + 1)) + var index := 0 + for row in range(first_row, last_row + 1): + for column in range(first_column, last_column + 1): + normals[index] = _normal_at(elevations, column, row) + index += 1 + return normals + + +func elevation_at(x_m: float, z_m: float) -> float: + var column_f := clampf( + (x_m - _data.origin_x_m) / _data.cell_size_m, + 0.0, + float(_data.columns - 1) + ) + var row_f := clampf( + (z_m - _data.origin_z_m) / _data.cell_size_m, + 0.0, + float(_data.rows - 1) + ) + var column := mini(int(floorf(column_f)), _data.columns - 2) + var row := mini(int(floorf(row_f)), _data.rows - 2) + var tx := column_f - float(column) + var tz := row_f - float(row) + var top := lerpf(elevation_at_cell(column, row), elevation_at_cell(column + 1, row), tx) + var bottom := lerpf( + elevation_at_cell(column, row + 1), + elevation_at_cell(column + 1, row + 1), + tx + ) + return lerpf(top, bottom, tz) + + +## Smooth (Catmull-Rom bicubic) elevation: passes through every cell value, so it +## agrees with elevation_at on the grid nodes and bends between them instead of +## creasing. Reproduces planes exactly, including at the grid edges, where the field +## is extrapolated linearly. Each pass is clamped between its two bracketing nodes, +## so the surface never overshoots a step (no dip at the foot of a block, no bump +## above a ledge). Used to render steep tiles at a finer step. +func smooth_elevation_at(x_m: float, z_m: float) -> float: + var column_f := clampf((x_m - _data.origin_x_m) / _data.cell_size_m, 0.0, float(_data.columns - 1)) + var row_f := clampf((z_m - _data.origin_z_m) / _data.cell_size_m, 0.0, float(_data.rows - 1)) + var column := mini(int(floorf(column_f)), _data.columns - 2) + var row := mini(int(floorf(row_f)), _data.rows - 2) + var weights_x := _catmull_rom_weights(column_f - float(column)) + var weights_z := _catmull_rom_weights(row_f - float(row)) + var along := PackedFloat32Array() + along.resize(4) + for offset_z in 4: + var sample_row := row - 1 + offset_z + var p1 := _extrapolated_elevation(column, sample_row) + var p2 := _extrapolated_elevation(column + 1, sample_row) + var along_x := ( + weights_x[0] * _extrapolated_elevation(column - 1, sample_row) + + weights_x[1] * p1 + + weights_x[2] * p2 + + weights_x[3] * _extrapolated_elevation(column + 2, sample_row) + ) + along[offset_z] = clampf(along_x, minf(p1, p2), maxf(p1, p2)) + var result := weights_z[0] * along[0] + weights_z[1] * along[1] + weights_z[2] * along[2] + weights_z[3] * along[3] + return clampf(result, minf(along[1], along[2]), maxf(along[1], along[2])) + + +## Positions and normals of one cell block on the smooth surface. +class SurfaceBlock extends RefCounted: + var positions := PackedVector3Array() + var normals := PackedVector3Array() + + +## The cell block [start_column..end_column] x [start_row..end_row] sampled on the smooth +## surface every 1 / subdivision cell, row-major: (cells * subdivision + 1) points per +## axis. The points on the grid nodes equal cell_world_position; the normals are +## central differences on the smooth surface, continuous across tile borders thanks to +## a one-cell margin. The smooth grid is built once for both arrays. +func cell_block_subdivided( + start_column: int, end_column: int, start_row: int, end_row: int, subdivision: int +) -> SurfaceBlock: + var block := SurfaceBlock.new() + if subdivision <= 1: + block.positions = cell_block_positions(start_column, end_column, start_row, end_row) + block.normals = cell_block_normals(start_column, end_column, start_row, end_row) + return block + var first_column := _clamp_column(start_column) + var last_column := _clamp_column(end_column) + var first_row := _clamp_row(start_row) + var last_row := _clamp_row(end_row) + var fine := _fine_block(first_column, last_column, first_row, last_row, subdivision) + var points_x := (last_column - first_column) * subdivision + 1 + var points_z := (last_row - first_row) * subdivision + 1 + var padded_x := points_x + 2 * subdivision + var step_m := _data.cell_size_m / float(subdivision) + var x0 := _data.origin_x_m + float(first_column) * _data.cell_size_m + var z0 := _data.origin_z_m + float(first_row) * _data.cell_size_m + var twice_step_m := 2.0 * step_m + var positions := PackedVector3Array() + var normals := PackedVector3Array() + positions.resize(points_x * points_z) + normals.resize(points_x * points_z) + var index := 0 + for sub_row in points_z: + var fine_row_start := (sub_row + subdivision) * padded_x + subdivision + var z := z0 + float(sub_row) * step_m + for sub_column in points_x: + var at := fine_row_start + sub_column + positions[index] = Vector3(x0 + float(sub_column) * step_m, fine[at], z) + var slope_x := (fine[at + 1] - fine[at - 1]) / twice_step_m + var slope_z := (fine[at + padded_x] - fine[at - padded_x]) / twice_step_m + normals[index] = Vector3(-slope_x, 1.0, -slope_z).normalized() + index += 1 + block.positions = positions + block.normals = normals + return block + + +func cell_block_positions_subdivided( + start_column: int, end_column: int, start_row: int, end_row: int, subdivision: int +) -> PackedVector3Array: + return cell_block_subdivided(start_column, end_column, start_row, end_row, subdivision).positions + + +func cell_block_normals_subdivided( + start_column: int, end_column: int, start_row: int, end_row: int, subdivision: int +) -> PackedVector3Array: + return cell_block_subdivided(start_column, end_column, start_row, end_row, subdivision).normals + + +## Smooth heights of the block padded by one cell on every side, row-major with +## (cells + 2) * subdivision + 1 points per axis. Separable Catmull-Rom with the +## weights of each sub-cell phase computed once: no per-point function calls. +func _fine_block(first_column: int, last_column: int, first_row: int, last_row: int, subdivision: int) -> PackedFloat32Array: + var cells_x := last_column - first_column + 2 + var cells_z := last_row - first_row + 2 + var source_x := cells_x + 4 + var source_z := cells_z + 4 + var source_column0 := first_column - 2 + var source_row0 := first_row - 2 + var fine_x := cells_x * subdivision + 1 + var fine_z := cells_z * subdivision + 1 + var phase_weights: Array[PackedFloat32Array] = [] + for phase in subdivision: + phase_weights.append(_catmull_rom_weights(float(phase) / float(subdivision))) + # Source samples with linear extrapolation outside the grid. + var source := PackedFloat32Array() + source.resize(source_x * source_z) + for sz in source_z: + for sx in source_x: + source[sz * source_x + sx] = _extrapolated_elevation(source_column0 + sx, source_row0 + sz) + # Pass 1: along x, for every source row. + var along := PackedFloat32Array() + along.resize(fine_x * source_z) + for sz in source_z: + var row_start := sz * source_x + var out_start := sz * fine_x + for cell in cells_x: + var p0 := source[row_start + cell] + var p1 := source[row_start + cell + 1] + var p2 := source[row_start + cell + 2] + var p3 := source[row_start + cell + 3] + var low := minf(p1, p2) + var high := maxf(p1, p2) + for phase in subdivision: + var w := phase_weights[phase] + along[out_start + cell * subdivision + phase] = clampf(w[0] * p0 + w[1] * p1 + w[2] * p2 + w[3] * p3, low, high) + along[out_start + cells_x * subdivision] = source[row_start + cells_x + 1] + # Pass 2: along z, for every fine column. + var fine := PackedFloat32Array() + fine.resize(fine_x * fine_z) + for cell in cells_z: + for phase in subdivision: + var w := phase_weights[phase] + var out_row := (cell * subdivision + phase) * fine_x + var r0 := cell * fine_x + var r1 := r0 + fine_x + var r2 := r1 + fine_x + var r3 := r2 + fine_x + for fx in fine_x: + var a1 := along[r1 + fx] + var a2 := along[r2 + fx] + fine[out_row + fx] = clampf( + w[0] * along[r0 + fx] + w[1] * a1 + w[2] * a2 + w[3] * along[r3 + fx], + minf(a1, a2), + maxf(a1, a2) + ) + var last_out := cells_z * subdivision * fine_x + var last_source := (cells_z + 1) * fine_x + for fx in fine_x: + fine[last_out + fx] = along[last_source + fx] + return fine + + +## Cell elevation, extended past the grid by linear extrapolation so the smooth surface +## keeps reproducing planes at the edges. +func _extrapolated_elevation(column: int, row: int) -> float: + var columns := _data.columns + var rows := _data.rows + var c := clampi(column, 0, columns - 1) + var r := clampi(row, 0, rows - 1) + var value := _data.elevations_m[r * columns + c] + if column < 0: + value += float(column) * (_data.elevations_m[r * columns + 1] - _data.elevations_m[r * columns]) + elif column > columns - 1: + value += float(column - columns + 1) * (_data.elevations_m[r * columns + columns - 1] - _data.elevations_m[r * columns + columns - 2]) + if row < 0: + value += float(row) * (_data.elevations_m[columns + c] - _data.elevations_m[c]) + elif row > rows - 1: + value += float(row - rows + 1) * (_data.elevations_m[(rows - 1) * columns + c] - _data.elevations_m[(rows - 2) * columns + c]) + return value + + +static func _catmull_rom_weights(t: float) -> PackedFloat32Array: + var t2 := t * t + var t3 := t2 * t + return PackedFloat32Array([ + 0.5 * (-t + 2.0 * t2 - t3), + 0.5 * (2.0 - 5.0 * t2 + 3.0 * t3), + 0.5 * (t + 4.0 * t2 - 3.0 * t3), + 0.5 * (-t2 + t3), + ]) + + +func material_at(x_m: float, z_m: float) -> int: + var column := int(roundf((x_m - _data.origin_x_m) / _data.cell_size_m)) + var row := int(roundf((z_m - _data.origin_z_m) / _data.cell_size_m)) + return material_at_cell(column, row) + + +func reflectivity_for(material: int) -> float: + match material: + SonarMaterial.Value.SAND: + return _data.sand_reflectivity + _: + return _data.rock_reflectivity + + +## Central differences, one-sided on the grid edges. column and row must be in range. +func _normal_at(elevations: PackedFloat32Array, column: int, row: int) -> Vector3: + var columns := _data.columns + var left := maxi(column - 1, 0) + var right := mini(column + 1, columns - 1) + var north := maxi(row - 1, 0) + var south := mini(row + 1, _data.rows - 1) + var slope_x := ( + (elevations[row * columns + right] - elevations[row * columns + left]) + / (float(right - left) * _data.cell_size_m) + ) + var slope_z := ( + (elevations[south * columns + column] - elevations[north * columns + column]) + / (float(south - north) * _data.cell_size_m) + ) + return Vector3(-slope_x, 1.0, -slope_z).normalized() + + +func _clamp_column(column: int) -> int: + return clampi(column, 0, _data.columns - 1) + + +func _clamp_row(row: int) -> int: + return clampi(row, 0, _data.rows - 1) diff --git a/game/domain/sonar/sonar_material.gd b/game/domain/sonar/sonar_material.gd index b4ef673..20ed0eb 100644 --- a/game/domain/sonar/sonar_material.gd +++ b/game/domain/sonar/sonar_material.gd @@ -5,8 +5,13 @@ enum Value { UNKNOWN, ROCK, METAL, + SAND, } static func is_valid(value: int) -> bool: - return value >= Value.UNKNOWN and value <= Value.METAL + return value >= Value.UNKNOWN and value <= Value.SAND + + +static func is_terrain(value: int) -> bool: + return value == Value.ROCK or value == Value.SAND diff --git a/game/presentation/cockpit/cockpit_hud.gd b/game/presentation/cockpit/cockpit_hud.gd index f79e3c0..747f533 100644 --- a/game/presentation/cockpit/cockpit_hud.gd +++ b/game/presentation/cockpit/cockpit_hud.gd @@ -5,9 +5,12 @@ 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") +const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd") @onready var _alert_panel: PanelContainer = %AlertPanel @onready var _alert_label: Label = %AlertLabel +@onready var _bounds_panel: PanelContainer = %BoundsPanel +@onready var _bounds_label: Label = %BoundsLabel @onready var _ejection_panel: PanelContainer = %EjectionPanel @onready var _ejection_label: Label = %EjectionLabel @onready var _terminal_panel: PanelContainer = %TerminalPanel @@ -29,6 +32,21 @@ func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void: ] +func present_bounds(status: PlayableBoundsStatus) -> void: + if status == null or not status.requires_warning(): + _bounds_panel.visible = false + return + _bounds_panel.visible = true + if status.state == PlayableBoundsStatus.State.OUTSIDE: + _bounds_label.text = tr("HORS ZONE · FAITES DEMI-TOUR") + else: + _bounds_label.text = "%s · %d m" % [tr("LIMITE DE ZONE"), int(status.distance_to_edge_m)] + + +func bounds_warning_visible() -> bool: + return _bounds_panel.visible + + func alert_overlay_visible() -> bool: return _alert_panel.visible diff --git a/game/presentation/cockpit/cockpit_hud.tscn b/game/presentation/cockpit/cockpit_hud.tscn index ddc071b..c62e728 100644 --- a/game/presentation/cockpit/cockpit_hud.tscn +++ b/game/presentation/cockpit/cockpit_hud.tscn @@ -57,6 +57,27 @@ theme_override_font_sizes/font_size = 20 text = "ALERTE · ATTENTION" horizontal_alignment = 1 +[node name="BoundsPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_left = 560.0 +offset_top = 88.0 +offset_right = -560.0 +offset_bottom = 136.0 +grow_horizontal = 2 +theme_override_styles/panel = SubResource("AlertStyle") + +[node name="BoundsLabel" type="Label" parent="BoundsPanel"] +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 = 18 +text = "LIMITE DE ZONE" +horizontal_alignment = 1 + [node name="EjectionPanel" type="PanelContainer" parent="."] unique_name_in_owner = true visible = false diff --git a/game/world/sites/bathymetric_terrain_actor.gd b/game/world/sites/bathymetric_terrain_actor.gd new file mode 100644 index 0000000..4980e76 --- /dev/null +++ b/game/world/sites/bathymetric_terrain_actor.gd @@ -0,0 +1,200 @@ +class_name BathymetricTerrainActor +extends StaticBody3D + +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SeabedDetailTextures = preload("res://game/world/sites/seabed_detail_textures.gd") +const TERRAIN_SHADER = preload("res://game/world/sites/seabed_terrain.gdshader") + +const TILE_CELLS := 64 +const VISIBILITY_RANGE_END_M := 420.0 +const VISIBILITY_RANGE_MARGIN_M := 60.0 +## Tiles holding a cell steeper than this are rendered on the smooth surface at a finer +## step, which removes the facets of the heightfield grid on cliffs and ledges. The +## collision stays on the grid: the smooth surface passes through every grid node. +const STEEP_TILE_SLOPE_DEGREES := 35.0 +const STEEP_TILE_SUBDIVISION := 2 +const DETAIL_TEXTURE_SEED := 4102 + +var _field: TerrainHeightField +var _material: ShaderMaterial +var _detail_textures: SeabedDetailTextures +var _tiles_root: Node3D +var _collision: CollisionShape3D + + +func configure(field: TerrainHeightField) -> String: + if field == null: + return "terrain field is missing" + _clear() + _field = field + if _detail_textures == null: + _detail_textures = SeabedDetailTextures.build(DETAIL_TEXTURE_SEED) + _material = ShaderMaterial.new() + _material.shader = TERRAIN_SHADER + _material.set_shader_parameter(&"sand_normal_map", _detail_textures.sand_normal) + _material.set_shader_parameter(&"rock_normal_map", _detail_textures.rock_normal) + _material.set_shader_parameter(&"rock_grain_map", _detail_textures.rock_grain) + _build_tiles() + _build_collision() + return "" + + +func height_field() -> TerrainHeightField: + return _field + + +func terrain_material() -> ShaderMaterial: + return _material + + +func tile_count() -> int: + return 0 if _tiles_root == null else _tiles_root.get_child_count() + + +## Mesh points per cell edge of a tile: 1 for a flat tile, STEEP_TILE_SUBDIVISION for a +## steep one. +func tile_subdivision(tile_index: int) -> int: + if _tiles_root == null or tile_index < 0 or tile_index >= _tiles_root.get_child_count(): + return 0 + return int(_tiles_root.get_child(tile_index).get_meta(&"subdivision", 1)) + + +func sonar_material_at(world_position: Vector3) -> int: + return _field.material_at(world_position.x, world_position.z) + + +func sonar_reflectivity_at(world_position: Vector3) -> float: + return _field.reflectivity_for(sonar_material_at(world_position)) + + +func _clear() -> void: + if _tiles_root != null: + remove_child(_tiles_root) + _tiles_root.free() + _tiles_root = null + if _collision != null: + remove_child(_collision) + _collision.free() + _collision = null + + +## The geometry of one tile, computed off the main thread, then turned into a mesh. +class TileGeometry extends RefCounted: + var cells := Rect2i() # position = first column / row, end = last column / row + var subdivision := 1 + var centre := Vector3.ZERO + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + + +func _build_tiles() -> void: + _tiles_root = Node3D.new() + _tiles_root.name = "Tiles" + add_child(_tiles_root) + var tiles: Array[TileGeometry] = [] + var last_column := _field.columns() - 1 + var last_row := _field.rows() - 1 + var start_row := 0 + while start_row < last_row: + var end_row := mini(start_row + TILE_CELLS, last_row) + var start_column := 0 + while start_column < last_column: + var end_column := mini(start_column + TILE_CELLS, last_column) + var tile := TileGeometry.new() + tile.cells = Rect2i(start_column, start_row, end_column - start_column, end_row - start_row) + tiles.append(tile) + start_column = end_column + start_row = end_row + # The per-tile sampling is pure (reads the field only), so the tiles are computed in + # parallel; meshes and nodes are then built on the main thread in tile order. + var group := WorkerThreadPool.add_group_task( + _compute_tile_geometry.bind(tiles), tiles.size(), -1, true, "seabed tiles" + ) + WorkerThreadPool.wait_for_group_task_completion(group) + for tile in tiles: + _tiles_root.add_child(_build_tile(tile)) + + +func _compute_tile_geometry(tile_index: int, tiles: Array[TileGeometry]) -> void: + var tile := tiles[tile_index] + var start_column := tile.cells.position.x + var start_row := tile.cells.position.y + var end_column := tile.cells.end.x + var end_row := tile.cells.end.y + var corner_min := _field.cell_world_position(start_column, start_row) + var corner_max := _field.cell_world_position(end_column, end_row) + tile.centre = Vector3( + (corner_min.x + corner_max.x) * 0.5, + 0.0, + (corner_min.z + corner_max.z) * 0.5 + ) + var normals := _field.cell_block_normals(start_column, end_column, start_row, end_row) + tile.subdivision = STEEP_TILE_SUBDIVISION if _is_steep(normals) else 1 + var vertices: PackedVector3Array + if tile.subdivision > 1: + var block := _field.cell_block_subdivided(start_column, end_column, start_row, end_row, tile.subdivision) + vertices = block.positions + normals = block.normals + else: + vertices = _field.cell_block_positions(start_column, end_column, start_row, end_row) + for index in vertices.size(): + vertices[index] -= tile.centre + tile.vertices = vertices + tile.normals = normals + + +func _build_tile(tile: TileGeometry) -> MeshInstance3D: + var subdivision := tile.subdivision + var indices := PackedInt32Array() + var columns_in_tile := tile.cells.size.x * subdivision + 1 + for row in range(tile.cells.size.y * subdivision): + for column in range(tile.cells.size.x * subdivision): + var top_left := row * columns_in_tile + column + var top_right := top_left + 1 + var bottom_left := top_left + columns_in_tile + var bottom_right := bottom_left + 1 + indices.append_array([top_left, top_right, bottom_left]) + indices.append_array([top_right, bottom_right, bottom_left]) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = tile.vertices + arrays[Mesh.ARRAY_NORMAL] = tile.normals + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var instance := MeshInstance3D.new() + instance.name = "Tile_%d_%d" % [tile.cells.position.x / TILE_CELLS, tile.cells.position.y / TILE_CELLS] + instance.mesh = mesh + instance.set_meta(&"subdivision", subdivision) + instance.material_override = _material + instance.position = tile.centre + instance.visibility_range_end = VISIBILITY_RANGE_END_M + instance.visibility_range_end_margin = VISIBILITY_RANGE_MARGIN_M + instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF + return instance + + +static func _is_steep(normals: PackedVector3Array) -> bool: + var steep_cosine := cos(deg_to_rad(STEEP_TILE_SLOPE_DEGREES)) + for normal in normals: + if normal.y < steep_cosine: + return true + return false + + +func _build_collision() -> void: + var shape := HeightMapShape3D.new() + shape.map_width = _field.columns() + shape.map_depth = _field.rows() + shape.map_data = _field.elevations_m() + _collision = CollisionShape3D.new() + _collision.name = "Collision" + _collision.shape = shape + var bounds := _field.bounds_m() + _collision.position = Vector3( + bounds.position.x + bounds.size.x * 0.5, + 0.0, + bounds.position.y + bounds.size.y * 0.5 + ) + _collision.scale = Vector3(_field.cell_size_m(), 1.0, _field.cell_size_m()) + add_child(_collision) diff --git a/game/world/sites/dive_site.gd b/game/world/sites/dive_site.gd new file mode 100644 index 0000000..2343711 --- /dev/null +++ b/game/world/sites/dive_site.gd @@ -0,0 +1,126 @@ +class_name DiveSite +extends Node3D + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const BathymetricTerrainActor = preload( + "res://game/world/sites/bathymetric_terrain_actor.gd" +) +const SeabedFloraActor = preload("res://game/world/sites/seabed_flora_actor.gd") + +const METAL_REFLECTIVITY := 1.0 +const BEACON_COLOR := Color(0.2, 0.78, 0.9) + +@onready var _terrain: BathymetricTerrainActor = %Terrain +@onready var _flora: SeabedFloraActor = %Flora +@onready var _placements_root: Node3D = %Placements + +var _definition: DiveSiteDefinition +var _placeholder_count := 0 + + +func configure(definition: DiveSiteDefinition) -> String: + if _terrain == null or _placements_root == null: + return "dive site is not in the scene tree" + if definition == null: + return "dive site definition is missing" + var field := definition.height_field() + if field == null: + return "dive site definition is invalid" + var terrain_error := _terrain.configure(field) + if not terrain_error.is_empty(): + return terrain_error + if _flora != null: + # The flora is seeded by the site id, so a site always grows the same meadows. + var flora_error := _flora.configure(field, hash(String(definition.site_id))) + if not flora_error.is_empty(): + return flora_error + _definition = definition + for child in _placements_root.get_children(): + _placements_root.remove_child(child) + child.free() + _placeholder_count = 0 + for placement: SitePlacement in definition.placements: + _place(placement) + return "" + + +func definition() -> DiveSiteDefinition: + return _definition + + +func terrain() -> BathymetricTerrainActor: + return _terrain + + +func flora() -> SeabedFloraActor: + return _flora + + +func spawn_transform() -> Transform3D: + return _definition.spawn_transform() if _definition != null else Transform3D.IDENTITY + + +func placeholder_count() -> int: + return _placeholder_count + + +func _place(placement: SitePlacement) -> void: + if placement.is_sonar_metal(): + _placements_root.add_child(_metal_placeholder(placement)) + _placeholder_count += 1 + elif placement.kind == SitePlacement.Kind.VESSEL_ANCHOR: + _placements_root.add_child(_beacon(placement)) + _placeholder_count += 1 + + +func _metal_placeholder(placement: SitePlacement) -> StaticBody3D: + var body := StaticBody3D.new() + body.name = String(placement.placement_id) + body.position = placement.position_m + body.rotation.y = placement.yaw_radians + body.set_meta("sonar_material", SonarMaterial.Value.METAL) + body.set_meta("sonar_reflectivity", METAL_REFLECTIVITY) + var mesh := BoxMesh.new() + mesh.size = placement.footprint_m + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "Mesh" + mesh_instance.mesh = mesh + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.24, 0.18, 0.08) + material.metallic = 0.88 + material.roughness = 0.27 + mesh_instance.material_override = material + body.add_child(mesh_instance) + var shape := BoxShape3D.new() + shape.size = placement.footprint_m + var collision := CollisionShape3D.new() + collision.name = "Collision" + collision.shape = shape + body.add_child(collision) + return body + + +func _beacon(placement: SitePlacement) -> MeshInstance3D: + var mesh := SphereMesh.new() + mesh.radius = 0.55 + mesh.height = 1.1 + var beacon := MeshInstance3D.new() + beacon.name = String(placement.placement_id) + beacon.mesh = mesh + beacon.position = placement.position_m + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = BEACON_COLOR + material.emission_enabled = true + material.emission = BEACON_COLOR + material.emission_energy_multiplier = 5.0 + beacon.material_override = material + var light := OmniLight3D.new() + light.name = "BeaconLight" + light.omni_range = 12.0 + light.light_color = BEACON_COLOR + light.light_energy = 4.0 + beacon.add_child(light) + return beacon diff --git a/game/world/sites/dive_site.tscn b/game/world/sites/dive_site.tscn new file mode 100644 index 0000000..4849caa --- /dev/null +++ b/game/world/sites/dive_site.tscn @@ -0,0 +1,109 @@ +[gd_scene load_steps=11 format=3] + +[ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"] +[ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"] +[ext_resource type="Shader" path="res://game/world/sites/water_column_fog.gdshader" id="3_water_column"] +[ext_resource type="Script" path="res://game/world/sites/seabed_flora_actor.gd" id="4_flora"] +[ext_resource type="Script" path="res://game/world/sites/marine_snow.gd" id="5_snow"] + +[sub_resource type="ShaderMaterial" id="WaterColumnMaterial"] +shader = ExtResource("3_water_column") + +[sub_resource type="ProceduralSkyMaterial" id="SkyUnderwater"] +sky_top_color = Color(0.22, 0.52, 0.66, 1) +sky_horizon_color = Color(0.09, 0.28, 0.4, 1) +sky_curve = 0.2 +sky_energy_multiplier = 1.0 +ground_horizon_color = Color(0.05, 0.17, 0.27, 1) +ground_bottom_color = Color(0.01, 0.03, 0.07, 1) +ground_curve = 0.1 +ground_energy_multiplier = 0.6 +sun_angle_max = 40.0 +sun_curve = 0.3 +use_debanding = true + +[sub_resource type="Sky" id="SkyResource"] +sky_material = SubResource("SkyUnderwater") +process_mode = 2 +radiance_size = 2 + +[sub_resource type="Environment" id="EnvironmentUnderwater"] +background_mode = 2 +sky = SubResource("SkyResource") +ambient_light_source = 3 +ambient_light_sky_contribution = 1.0 +ambient_light_energy = 1.3 +reflected_light_source = 1 +tonemap_mode = 2 +tonemap_exposure = 1.15 +tonemap_white = 4.0 +ssao_enabled = true +ssao_radius = 2.5 +ssao_intensity = 2.2 +ssao_power = 1.6 +ssao_detail = 0.6 +ssao_horizon = 0.08 +ssao_light_affect = 0.25 +ssao_ao_channel_affect = 0.4 +glow_enabled = true +glow_levels/1 = 1.0 +glow_levels/2 = 1.0 +glow_levels/3 = 0.6 +glow_levels/4 = 0.2 +glow_intensity = 0.8 +glow_blend_mode = 0 +glow_hdr_threshold = 1.2 +fog_enabled = true +fog_light_color = Color(0.05, 0.18, 0.29, 1) +fog_light_energy = 0.38 +fog_sun_scatter = 0.15 +fog_density = 0.0045 +fog_aerial_perspective = 0.5 +fog_height = -4.0 +fog_height_density = 0.025 +volumetric_fog_enabled = true +volumetric_fog_density = 0.0 +volumetric_fog_albedo = Color(0.5, 0.78, 0.92, 1) +volumetric_fog_anisotropy = 0.45 +volumetric_fog_length = 128.0 +volumetric_fog_detail_spread = 1.6 +volumetric_fog_ambient_inject = 0.5 +adjustment_enabled = true +adjustment_contrast = 1.06 +adjustment_saturation = 1.08 + +[node name="DiveSite" type="Node3D"] +script = ExtResource("1_site") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("EnvironmentUnderwater") + +[node name="AmbientDirection" type="DirectionalLight3D" parent="."] +rotation_degrees = Vector3(-62, -28, 0) +light_color = Color(0.5, 0.78, 0.88, 1) +light_energy = 0.9 +light_angular_distance = 3.0 +shadow_enabled = true +shadow_opacity = 0.45 +shadow_blur = 2.0 +directional_shadow_max_distance = 160.0 +directional_shadow_fade_start = 0.75 + +[node name="WaterColumn" type="FogVolume" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -110, 0) +size = Vector3(1800, 260, 1800) +material = SubResource("WaterColumnMaterial") + +[node name="Terrain" type="StaticBody3D" parent="."] +unique_name_in_owner = true +script = ExtResource("2_terrain") + +[node name="Flora" type="Node3D" parent="."] +unique_name_in_owner = true +script = ExtResource("4_flora") + +[node name="MarineSnow" type="GPUParticles3D" parent="."] +script = ExtResource("5_snow") + +[node name="Placements" type="Node3D" parent="."] +unique_name_in_owner = true diff --git a/game/world/sites/marine_snow.gd b/game/world/sites/marine_snow.gd new file mode 100644 index 0000000..5cf4e66 --- /dev/null +++ b/game/world/sites/marine_snow.gd @@ -0,0 +1,76 @@ +class_name MarineSnow +extends GPUParticles3D + +## Suspended particles (marine snow, sediment) around the viewer. The emitter is a +## world-space box re-centred on the active camera every frame, so flakes keep their +## own slow drift while the submarine moves through them instead of travelling with +## it. Sinks a few centimetres per second with a little turbulence, as detritus does. + +const BOX_EXTENTS_M := Vector3(16.0, 10.0, 16.0) +const PARTICLE_COUNT := 2400 +const LIFETIME_S := 16.0 +const SINK_SPEED_M_S := 0.035 +const DRIFT_SPEED_M_S := 0.06 +const FLAKE_SIZE_M := Vector2(0.05, 0.16) + +const SNOW_SHADER = preload("res://game/world/sites/marine_snow.gdshader") + + +func _ready() -> void: + local_coords = false + amount = PARTICLE_COUNT + lifetime = LIFETIME_S + preprocess = LIFETIME_S + fixed_fps = 30 + visibility_aabb = AABB(-BOX_EXTENTS_M * 1.5, BOX_EXTENTS_M * 3.0) + process_material = _process_material() + var quad := QuadMesh.new() + quad.size = Vector2.ONE + var material := ShaderMaterial.new() + material.shader = SNOW_SHADER + quad.material = material + draw_pass_1 = quad + emitting = true + + +## A jump larger than the box (spawn, teleport, first camera) would leave the viewer in +## empty water for a whole lifetime: the emitter restarts and preprocesses in place. +func _process(_delta: float) -> void: + var camera := get_viewport().get_camera_3d() if is_inside_tree() else null + if camera == null: + return + var jumped := global_position.distance_to(camera.global_position) > BOX_EXTENTS_M.y + global_position = camera.global_position + if jumped: + restart() + + +func _process_material() -> ParticleProcessMaterial: + var material := ParticleProcessMaterial.new() + material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + material.emission_box_extents = BOX_EXTENTS_M + material.direction = Vector3(1.0, 0.0, 0.3) + material.spread = 180.0 + material.initial_velocity_min = DRIFT_SPEED_M_S * 0.3 + material.initial_velocity_max = DRIFT_SPEED_M_S + material.gravity = Vector3(0.0, -SINK_SPEED_M_S, 0.0) + material.turbulence_enabled = true + material.turbulence_noise_strength = 0.4 + material.turbulence_noise_scale = 6.0 + material.turbulence_noise_speed_random = 0.2 + material.turbulence_influence_min = 0.02 + material.turbulence_influence_max = 0.06 + material.scale_min = FLAKE_SIZE_M.x + material.scale_max = FLAKE_SIZE_M.y + # Fade in and out through alpha so flakes never pop at the box edge or at respawn. + var alpha := Gradient.new() + alpha.set_offset(0, 0.0) + alpha.set_color(0, Color(1.0, 1.0, 1.0, 0.0)) + alpha.add_point(0.15, Color(1.0, 1.0, 1.0, 1.0)) + alpha.add_point(0.85, Color(1.0, 1.0, 1.0, 1.0)) + alpha.set_offset(alpha.get_point_count() - 1, 1.0) + alpha.set_color(alpha.get_point_count() - 1, Color(1.0, 1.0, 1.0, 0.0)) + var ramp := GradientTexture1D.new() + ramp.gradient = alpha + material.color_ramp = ramp + return material diff --git a/game/world/sites/marine_snow.gdshader b/game/world/sites/marine_snow.gdshader new file mode 100644 index 0000000..a868db7 --- /dev/null +++ b/game/world/sites/marine_snow.gdshader @@ -0,0 +1,36 @@ +shader_type spatial; +render_mode blend_add, depth_draw_never, cull_disabled, unshaded; + +// Marine snow: camera-facing soft discs drawn by GPUParticles3D. Each particle is a +// unit quad; the flake is a radial falloff in UV. Brightness follows the daylight of +// the water column (so flakes dim with depth) and fades in and out over the life of +// the particle through COLOR.a set by the process material. + +#include "res://game/world/sites/water_light.gdshaderinc" + +uniform vec3 flake_color : source_color = vec3(0.78, 0.86, 0.9); +uniform float flake_strength = 1.4; +uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032); +uniform float reference_depth_m = 24.0; +uniform float minimum_daylight = 0.08; + +varying float brightness; + +void vertex() { + // Billboard: keep the particle's world position, face the camera. + vec3 world_origin = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + float size = length(MODEL_MATRIX[0].xyz); + mat3 view_basis = mat3(INV_VIEW_MATRIX); + vec3 world = world_origin + (view_basis[0] * VERTEX.x + view_basis[1] * VERTEX.y) * size; + VERTEX = (VIEW_MATRIX * vec4(world, 1.0)).xyz; + NORMAL = (VIEW_MATRIX * vec4(view_basis[2], 0.0)).xyz; + vec3 transmittance = water_transmittance(-world_origin.y, water_absorption_per_m, reference_depth_m); + brightness = water_daylight(transmittance, minimum_daylight) * COLOR.a; +} + +void fragment() { + float radial = length(UV - vec2(0.5)) * 2.0; + float disc = 1.0 - smoothstep(0.35, 1.0, radial); + ALBEDO = flake_color * disc * brightness * flake_strength; + ALPHA = disc; +} diff --git a/game/world/sites/seabed_coral.gdshader b/game/world/sites/seabed_coral.gdshader new file mode 100644 index 0000000..db0354e --- /dev/null +++ b/game/world/sites/seabed_coral.gdshader @@ -0,0 +1,39 @@ +shader_type spatial; +render_mode depth_draw_opaque; + +// Massive coral heads: opaque, vertex-coloured (mesh shade times the instance tint), +// lit under the same water column as the seabed. INSTANCE_CUSTOM.x is a per-head +// mottling phase; the submarine's projectors are not attenuated. + +#include "res://game/world/sites/water_light.gdshaderinc" + +uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032); +uniform float reference_depth_m = 24.0; +uniform float minimum_daylight = 0.05; +uniform float mottle_strength = 0.18; + +varying float daylight; +varying vec3 chroma; +varying float mottle; + +void vertex() { + vec3 world = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + vec3 transmittance = water_transmittance(-world.y, water_absorption_per_m, reference_depth_m); + daylight = water_daylight(transmittance, minimum_daylight); + chroma = water_chroma(transmittance, water_absorption_per_m, reference_depth_m); + mottle = sin(VERTEX.x * 9.0 + INSTANCE_CUSTOM.x * TAU) * sin(VERTEX.z * 7.0 + VERTEX.y * 5.0); +} + +void fragment() { + ALBEDO = COLOR.rgb * (1.0 + mottle * mottle_strength) * chroma; + AO = daylight; + AO_LIGHT_AFFECT = 0.35; + ROUGHNESS = 0.8; + METALLIC = 0.0; +} + +void light() { + float sun = water_sun_factor(LIGHT_IS_DIRECTIONAL, daylight); + float lambert = clamp(dot(NORMAL, LIGHT), 0.0, 1.0); + DIFFUSE_LIGHT += lambert * ATTENUATION * LIGHT_COLOR * ALBEDO * sun / PI; +} diff --git a/game/world/sites/seabed_detail_textures.gd b/game/world/sites/seabed_detail_textures.gd new file mode 100644 index 0000000..096693c --- /dev/null +++ b/game/world/sites/seabed_detail_textures.gd @@ -0,0 +1,73 @@ +class_name SeabedDetailTextures +extends RefCounted + +## Procedural, seamless detail maps for the seabed shader, built synchronously from +## seeded noise so no texture asset is needed and every build is identical: +## an oriented sand ripple normal map, a rock normal map and the rock grain map +## (height, also read as occlusion and albedo mottling). + +const TEXTURE_SIZE := 256 +const RIPPLES_PER_TILE := 2.0 +const RIPPLE_WARP := 0.22 +const ROCK_BUMP_SCALE := 6.0 +const SAND_BUMP_SCALE := 3.0 + +var sand_normal: ImageTexture +var rock_normal: ImageTexture +var rock_grain: ImageTexture + + +static func build(seed: int) -> SeabedDetailTextures: + var result := SeabedDetailTextures.new() + var rock_height := _rock_height_image(seed) + result.rock_grain = _texture_from(rock_height) + var rock_bumps := rock_height.duplicate() as Image + rock_bumps.bump_map_to_normal_map(ROCK_BUMP_SCALE) + result.rock_normal = _texture_from(rock_bumps) + var sand_height := _sand_height_image(seed + 1) + sand_height.bump_map_to_normal_map(SAND_BUMP_SCALE) + result.sand_normal = _texture_from(sand_height) + return result + + +## Ridged fractal noise: sharp crests and pits, seamless across the tile. +static func _rock_height_image(seed: int) -> Image: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = 0.012 + noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED + noise.fractal_octaves = 5 + return noise.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true) + + +## Ripples across the tile's v axis, warped and grained by noise. Asymmetric profile +## (gentle stoss side, steep lee side) as on a swell-built sand bed. +static func _sand_height_image(seed: int) -> Image: + var warp := FastNoiseLite.new() + warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + warp.seed = seed + warp.frequency = 0.02 + warp.fractal_octaves = 2 + var warp_image := warp.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true) + var grain := FastNoiseLite.new() + grain.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + grain.seed = seed + 7 + grain.frequency = 0.15 + grain.fractal_octaves = 3 + var grain_image := grain.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true) + var image := Image.create(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_L8) + for y in TEXTURE_SIZE: + for x in TEXTURE_SIZE: + var phase := (float(y) / float(TEXTURE_SIZE)) * RIPPLES_PER_TILE + phase += (warp_image.get_pixel(x, y).r - 0.5) * RIPPLE_WARP + var fraction := phase - floorf(phase) + var crest := fraction / 0.7 if fraction < 0.7 else (1.0 - fraction) / 0.3 + var height := crest * 0.8 + grain_image.get_pixel(x, y).r * 0.2 + image.set_pixel(x, y, Color(height, height, height)) + return image + + +static func _texture_from(image: Image) -> ImageTexture: + image.generate_mipmaps() + return ImageTexture.create_from_image(image) diff --git a/game/world/sites/seabed_flora.gdshader b/game/world/sites/seabed_flora.gdshader new file mode 100644 index 0000000..10bc6c4 --- /dev/null +++ b/game/world/sites/seabed_flora.gdshader @@ -0,0 +1,60 @@ +shader_type spatial; +render_mode cull_disabled, depth_draw_opaque; + +// Seagrass tufts and sea fans as alpha-cut billboards instanced by MultiMesh. The mesh +// is unit sized (y from 0 to 1); the instance transform gives size and heading, the +// instance COLOR the species tint, INSTANCE_CUSTOM.x the sway phase and +// INSTANCE_CUSTOM.y the sway weight (grass bends, fans barely). Daylight follows the +// same water column as the seabed; the submarine's projectors are not attenuated. + +#include "res://game/world/sites/water_light.gdshaderinc" + +uniform sampler2D albedo_map : source_color, filter_linear_mipmap; +uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032); +uniform float reference_depth_m = 24.0; +uniform float minimum_daylight = 0.05; +uniform float sway_amplitude = 0.09; +uniform float sway_speed = 0.8; +uniform float alpha_threshold = 0.5; +uniform float translucency = 0.7; + +varying vec3 world_position; +varying float daylight; +varying vec3 chroma; + +void vertex() { + float phase = INSTANCE_CUSTOM.x * TAU; + float weight = INSTANCE_CUSTOM.y * VERTEX.y * VERTEX.y; + float swing = sin(TIME * sway_speed + phase) + 0.35 * sin(TIME * sway_speed * 2.3 + phase * 1.7); + VERTEX.x += swing * sway_amplitude * weight; + VERTEX.z += cos(TIME * sway_speed * 0.7 + phase) * sway_amplitude * 0.5 * weight; + world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + vec3 transmittance = water_transmittance(-world_position.y, water_absorption_per_m, reference_depth_m); + daylight = water_daylight(transmittance, minimum_daylight); + chroma = water_chroma(transmittance, water_absorption_per_m, reference_depth_m); +} + +void fragment() { + vec4 sample = texture(albedo_map, UV); + ALPHA = sample.a; + ALPHA_SCISSOR_THRESHOLD = alpha_threshold; + ALBEDO = sample.rgb * COLOR.rgb * chroma; + if (!FRONT_FACING) { + NORMAL = -NORMAL; + } + AO = daylight; + AO_LIGHT_AFFECT = 0.35; + ROUGHNESS = 0.85; + METALLIC = 0.0; +} + +void light() { + float sun = water_sun_factor(LIGHT_IS_DIRECTIONAL, daylight); + // Thin blades and fan nets let light through and are lit from both faces: the + // lambert term is wrapped around the blade, so a vertical blade under an + // overhead sun is not black. + float facing = dot(NORMAL, LIGHT); + float lambert = clamp(facing, 0.0, 1.0); + float wrapped = clamp(abs(facing) * 0.5 + 0.5, 0.0, 1.0) * translucency; + DIFFUSE_LIGHT += (lambert + wrapped) * ATTENUATION * LIGHT_COLOR * ALBEDO * sun / PI; +} diff --git a/game/world/sites/seabed_flora_actor.gd b/game/world/sites/seabed_flora_actor.gd new file mode 100644 index 0000000..7c4cf91 --- /dev/null +++ b/game/world/sites/seabed_flora_actor.gd @@ -0,0 +1,379 @@ +class_name SeabedFloraActor +extends Node3D + +## Scatters the seabed flora of a site from its height field, deterministically from a +## seed: seagrass tufts on gently sloping sand in the lit band, sea fans on rock, and +## massive coral heads on the gentle rock of patch reefs. One MultiMeshInstance3D per +## terrain tile and per kind (per variant for corals), with a visibility range, no +## collision and no sonar presence. Placement is pure and runs on the worker thread +## pool; the multimeshes are built afterwards on the main thread. + +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SeabedFloraAssets = preload("res://game/world/sites/seabed_flora_assets.gd") +const FLORA_SHADER = preload("res://game/world/sites/seabed_flora.gdshader") +const CORAL_SHADER = preload("res://game/world/sites/seabed_coral.gdshader") + +enum Kind { SEAGRASS, SEA_FAN, CORAL_HEAD } + +const TILE_CELLS := 64 +# Seagrass: meadows on flat sand, dense to 20 m, gone by 36 m (Caribbean Syringodium +# and Halophila thin out with the light). +const GRASS_SPACING_M := 2.0 +const GRASS_DEPTH_FULL_M := 20.0 +const GRASS_DEPTH_NONE_M := 36.0 +const GRASS_SLOPE_COSINE := 0.9659 # cos(15 deg) +const GRASS_MEADOW_THRESHOLD := 0.12 +const GRASS_VISIBILITY_M := 110.0 +# Sea fans: on rock from 15 to 60 m, heading across the swell. +const FAN_SPACING_M := 5.0 +const FAN_DEPTH_MIN_M := 15.0 +const FAN_DEPTH_MAX_M := 60.0 +const FAN_KEEP_PROBABILITY := 0.45 +const FAN_VISIBILITY_M := 170.0 +const FAN_HEADING := Vector2(0.8, 0.6) +# Coral heads: patch reefs are the gentle (< 30 deg) rock of the sunlit band, 12 to +# 40 m, clustered by noise; heads are 0.6 to 2.2 m across. +const CORAL_SPACING_M := 3.0 +const CORAL_DEPTH_MIN_M := 12.0 +const CORAL_DEPTH_FADE_START_M := 30.0 +const CORAL_DEPTH_MAX_M := 40.0 +const CORAL_SLOPE_COSINE := 0.866 # cos(30 deg) +const CORAL_REEF_THRESHOLD := 0.05 +const CORAL_KEEP_PROBABILITY := 0.55 +const CORAL_VISIBILITY_M := 150.0 +const CORAL_COLORS: Array[Color] = [ + Color(0.72, 0.58, 0.3), Color(0.5, 0.52, 0.28), Color(0.68, 0.48, 0.36), Color(0.62, 0.6, 0.42), +] +const FAN_COLORS: Array[Color] = [ + Color(0.55, 0.35, 0.62), Color(0.85, 0.42, 0.2), Color(0.8, 0.7, 0.3), Color(0.6, 0.3, 0.45), +] + + +## Placements of one tile for one kind, computed off the main thread. +class TilePlacements extends RefCounted: + var cells := Rect2i() + var transforms: Array[Transform3D] = [] + var colors := PackedColorArray() + var custom := PackedColorArray() + var variants := PackedInt32Array() + + +var _field: TerrainHeightField +var _seed := 0 +var _assets: SeabedFloraAssets +var _grass_material: ShaderMaterial +var _fan_material: ShaderMaterial +var _coral_material: ShaderMaterial +var _reef_noise: FastNoiseLite +var _meadow_noise: FastNoiseLite +var _fan_noise: FastNoiseLite +var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0} +var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []} + + +func configure(field: TerrainHeightField, seed: int) -> String: + if field == null: + return "flora field is missing" + _clear() + _field = field + _seed = seed + if _assets == null: + _assets = SeabedFloraAssets.build(seed) + _grass_material = _material(_assets.blade_texture) + _fan_material = _material(_assets.fan_texture) + _coral_material = ShaderMaterial.new() + _coral_material.shader = CORAL_SHADER + _meadow_noise = _noise(seed + 11, 0.012) + _fan_noise = _noise(seed + 23, 0.03) + _reef_noise = _noise(seed + 37, 0.02) + var tiles := _tiles() + var grass: Array[TilePlacements] = [] + var fans: Array[TilePlacements] = [] + var corals: Array[TilePlacements] = [] + for tile in tiles: + for list: Array[TilePlacements] in [grass, fans, corals]: + var placements := TilePlacements.new() + placements.cells = tile + list.append(placements) + var group := WorkerThreadPool.add_group_task( + _place_tile.bind(grass, fans, corals), tiles.size(), -1, true, "seabed flora" + ) + WorkerThreadPool.wait_for_group_task_completion(group) + for index in tiles.size(): + _add_multimesh(Kind.SEAGRASS, grass[index]) + _add_multimesh(Kind.SEA_FAN, fans[index]) + _add_coral_multimeshes(corals[index]) + return "" + + +func instance_count(kind: Kind) -> int: + return int(_counts[kind]) + + +## World transforms of the first few instances of a kind, for inspection and tests. +func sample_transforms(kind: Kind) -> Array[Transform3D]: + var result: Array[Transform3D] = [] + for transform in _samples[kind]: + result.append(transform) + return result + + +func _clear() -> void: + for child in get_children(): + remove_child(child) + child.free() + _counts = {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0} + _samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []} + + +func _tiles() -> Array[Rect2i]: + var tiles: Array[Rect2i] = [] + var last_column := _field.columns() - 1 + var last_row := _field.rows() - 1 + var start_row := 0 + while start_row < last_row: + var end_row := mini(start_row + TILE_CELLS, last_row) + var start_column := 0 + while start_column < last_column: + var end_column := mini(start_column + TILE_CELLS, last_column) + tiles.append(Rect2i(start_column, start_row, end_column - start_column, end_row - start_row)) + start_column = end_column + start_row = end_row + return tiles + + +func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements], corals: Array[TilePlacements]) -> void: + var tile := grass[tile_index].cells + var cell := _field.cell_size_m() + var origin := _field.origin_m() + var x_min := origin.x + float(tile.position.x) * cell + var z_min := origin.y + float(tile.position.y) * cell + var x_max := origin.x + float(tile.end.x) * cell + var z_max := origin.y + float(tile.end.y) * cell + var generator := RandomNumberGenerator.new() + generator.seed = _seed * 7919 + tile_index + _place_grass(grass[tile_index], generator, x_min, x_max, z_min, z_max) + _place_fans(fans[tile_index], generator, x_min, x_max, z_min, z_max) + _place_corals(corals[tile_index], generator, x_min, x_max, z_min, z_max) + + +## Cheap rejections first, straight from the field arrays (cell reads, one noise read), +## random draws only for accepted candidates: the draw sequence stays deterministic +## because every decision is. +func _place_grass(out: TilePlacements, generator: RandomNumberGenerator, x_min: float, x_max: float, z_min: float, z_max: float) -> void: + var origin := _field.origin_m() + var cell := _field.cell_size_m() + var columns := _field.columns() + var rows := _field.rows() + var elevations := _field.elevations_m() + var materials := _field.materials() + var sand := SonarMaterial.Value.SAND + var slope_limit := (1.0 / (GRASS_SLOPE_COSINE * GRASS_SLOPE_COSINE) - 1.0) * (2.0 * cell) * (2.0 * cell) + var z := z_min + while z < z_max: + var x := x_min + while x < x_max: + var px := x + generator.randf() * GRASS_SPACING_M + var pz := z + generator.randf() * GRASS_SPACING_M + x += GRASS_SPACING_M + var column := int(roundf((px - origin.x) / cell)) + var row := int(roundf((pz - origin.y) / cell)) + if px >= x_max or pz >= z_max or column < 1 or row < 1 or column >= columns - 1 or row >= rows - 1: + continue + var index := row * columns + column + var depth := -elevations[index] + if depth >= GRASS_DEPTH_NONE_M or materials[index] != sand: + continue + var meadow := _meadow_noise.get_noise_2d(px, pz) + if meadow < GRASS_MEADOW_THRESHOLD: + continue + # Slope by central differences: (dz_dx^2 + dz_dz^2) against tan^2 of the limit. + var rise_x := elevations[index + 1] - elevations[index - 1] + var rise_z := elevations[index + columns] - elevations[index - columns] + if rise_x * rise_x + rise_z * rise_z > slope_limit: + continue + var density := 1.0 - smoothstep(GRASS_DEPTH_FULL_M, GRASS_DEPTH_NONE_M, depth) + if generator.randf() > density * clampf((meadow - GRASS_MEADOW_THRESHOLD) * 4.0, 0.3, 1.0): + continue + var yaw := generator.randf() * TAU + var size := generator.randf_range(0.6, 1.1) + var phase := generator.randf() + var tint := generator.randf_range(0.85, 1.15) + var elevation := _field.elevation_at(px, pz) + var basis := Basis(Vector3.UP, yaw).scaled(Vector3(size, size * 0.9, size)) + out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.03, pz))) + out.colors.append(Color(0.9 * tint, 1.0 * tint, 0.85 * tint)) + out.custom.append(Color(phase, 1.0, 0.0, 0.0)) + z += GRASS_SPACING_M + + +func _place_fans(out: TilePlacements, generator: RandomNumberGenerator, x_min: float, x_max: float, z_min: float, z_max: float) -> void: + var origin := _field.origin_m() + var cell := _field.cell_size_m() + var columns := _field.columns() + var rows := _field.rows() + var elevations := _field.elevations_m() + var materials := _field.materials() + var rock := SonarMaterial.Value.ROCK + var heading := atan2(-FAN_HEADING.y, FAN_HEADING.x) + var z := z_min + while z < z_max: + var x := x_min + while x < x_max: + var px := x + generator.randf() * FAN_SPACING_M + var pz := z + generator.randf() * FAN_SPACING_M + x += FAN_SPACING_M + var column := int(roundf((px - origin.x) / cell)) + var row := int(roundf((pz - origin.y) / cell)) + if px >= x_max or pz >= z_max or column < 1 or row < 1 or column >= columns - 1 or row >= rows - 1: + continue + var index := row * columns + column + if materials[index] != rock: + continue + var depth := -elevations[index] + if depth < FAN_DEPTH_MIN_M or depth > FAN_DEPTH_MAX_M: + continue + if generator.randf() > FAN_KEEP_PROBABILITY or _fan_noise.get_noise_2d(px, pz) < -0.1: + continue + var yaw := heading + generator.randf_range(-0.45, 0.45) + var width := generator.randf_range(1.0, 2.4) + var height := generator.randf_range(0.9, 2.0) + var phase := generator.randf() + var color: Color = FAN_COLORS[generator.randi_range(0, FAN_COLORS.size() - 1)] + var elevation := _field.elevation_at(px, pz) + var normal := _field.normal_at_cell(column, row) + # Stand up, leaning a little with the rock face. + var up := (Vector3.UP * 0.6 + normal * 0.4).normalized() + var forward := Vector3(cos(yaw), 0.0, -sin(yaw)) + var right := forward.cross(up).normalized() + forward = up.cross(right).normalized() + var basis := Basis(right * width, up * height, forward) + out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.05, pz))) + out.colors.append(color) + out.custom.append(Color(phase, 0.12, 0.0, 0.0)) + z += FAN_SPACING_M + + +func _place_corals(out: TilePlacements, generator: RandomNumberGenerator, x_min: float, x_max: float, z_min: float, z_max: float) -> void: + var origin := _field.origin_m() + var cell := _field.cell_size_m() + var columns := _field.columns() + var rows := _field.rows() + var elevations := _field.elevations_m() + var materials := _field.materials() + var rock := SonarMaterial.Value.ROCK + var slope_limit := (1.0 / (CORAL_SLOPE_COSINE * CORAL_SLOPE_COSINE) - 1.0) * (2.0 * cell) * (2.0 * cell) + var z := z_min + while z < z_max: + var x := x_min + while x < x_max: + var px := x + generator.randf() * CORAL_SPACING_M + var pz := z + generator.randf() * CORAL_SPACING_M + x += CORAL_SPACING_M + var column := int(roundf((px - origin.x) / cell)) + var row := int(roundf((pz - origin.y) / cell)) + if px >= x_max or pz >= z_max or column < 1 or row < 1 or column >= columns - 1 or row >= rows - 1: + continue + var index := row * columns + column + if materials[index] != rock: + continue + var depth := -elevations[index] + if depth < CORAL_DEPTH_MIN_M or depth > CORAL_DEPTH_MAX_M: + continue + var rise_x := elevations[index + 1] - elevations[index - 1] + var rise_z := elevations[index + columns] - elevations[index - columns] + if rise_x * rise_x + rise_z * rise_z > slope_limit: + continue + var reef := _reef_noise.get_noise_2d(px, pz) + if reef < CORAL_REEF_THRESHOLD: + continue + var density := (1.0 - smoothstep(CORAL_DEPTH_FADE_START_M, CORAL_DEPTH_MAX_M, depth)) * CORAL_KEEP_PROBABILITY + if generator.randf() > density * clampf((reef - CORAL_REEF_THRESHOLD) * 5.0, 0.4, 1.0): + continue + var yaw := generator.randf() * TAU + var radius := generator.randf_range(0.35, 1.3) + var squash := generator.randf_range(0.7, 1.15) + var phase := generator.randf() + var variant := generator.randi_range(0, _assets.coral_meshes.size() - 1) + var color: Color = CORAL_COLORS[generator.randi_range(0, CORAL_COLORS.size() - 1)] + var elevation := _field.elevation_at(px, pz) + var basis := Basis(Vector3.UP, yaw).scaled(Vector3(radius, radius * squash, radius)) + out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.08, pz))) + out.colors.append(color) + out.custom.append(Color(phase, 0.0, 0.0, 0.0)) + out.variants.append(variant) + z += CORAL_SPACING_M + + +## Coral heads of one tile, one MultiMesh per mesh variant. +func _add_coral_multimeshes(placements: TilePlacements) -> void: + for variant in _assets.coral_meshes.size(): + var subset := TilePlacements.new() + subset.cells = placements.cells + for index in placements.transforms.size(): + if placements.variants[index] != variant: + continue + subset.transforms.append(placements.transforms[index]) + subset.colors.append(placements.colors[index]) + subset.custom.append(placements.custom[index]) + _add_multimesh(Kind.CORAL_HEAD, subset, variant) + + +func _add_multimesh(kind: Kind, placements: TilePlacements, variant: int = 0) -> void: + var count := placements.transforms.size() + if count == 0: + return + var multimesh := MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + multimesh.use_colors = true + multimesh.use_custom_data = true + match kind: + Kind.SEAGRASS: + multimesh.mesh = _assets.tuft_mesh + Kind.SEA_FAN: + multimesh.mesh = _assets.fan_mesh + Kind.CORAL_HEAD: + multimesh.mesh = _assets.coral_meshes[variant] + multimesh.instance_count = count + for index in count: + multimesh.set_instance_transform(index, placements.transforms[index]) + multimesh.set_instance_color(index, placements.colors[index]) + multimesh.set_instance_custom_data(index, placements.custom[index]) + var instance := MultiMeshInstance3D.new() + instance.name = "%s_%d_%d" % [ + ["Seagrass", "SeaFans", "Corals%d" % variant][kind], + placements.cells.position.x / TILE_CELLS, + placements.cells.position.y / TILE_CELLS, + ] + instance.multimesh = multimesh + instance.material_override = [_grass_material, _fan_material, _coral_material][kind] + instance.visibility_range_end = [GRASS_VISIBILITY_M, FAN_VISIBILITY_M, CORAL_VISIBILITY_M][kind] + instance.visibility_range_end_margin = 20.0 + instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF + instance.cast_shadow = ( + GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if kind == Kind.SEAGRASS + else GeometryInstance3D.SHADOW_CASTING_SETTING_ON + ) + add_child(instance) + _counts[kind] = int(_counts[kind]) + count + var samples: Array = _samples[kind] + for index in mini(count, 4): + if samples.size() < 16: + samples.append(placements.transforms[index]) + + +func _material(texture: ImageTexture) -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = FLORA_SHADER + material.set_shader_parameter(&"albedo_map", texture) + return material + + +static func _noise(seed: int, frequency: float) -> FastNoiseLite: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = frequency + noise.fractal_octaves = 3 + return noise diff --git a/game/world/sites/seabed_flora_assets.gd b/game/world/sites/seabed_flora_assets.gd new file mode 100644 index 0000000..ab697c0 --- /dev/null +++ b/game/world/sites/seabed_flora_assets.gd @@ -0,0 +1,196 @@ +class_name SeabedFloraAssets +extends RefCounted + +## Procedural meshes and alpha-cut textures for the seabed flora, built synchronously +## from a seed so no asset is needed and every build is identical: a seagrass tuft +## (three crossed unit quads carrying a strap-blade texture) and a sea fan (one unit +## quad carrying a branching net texture), and three massive coral heads (bumpy +## domes with a flat base: boulder, brain-ridged and lobed). Meshes are unit sized, +## y from 0 to 1. + +const BLADE_TEXTURE_WIDTH := 128 +const BLADE_TEXTURE_HEIGHT := 256 +const BLADE_COUNT := 5 +const FAN_TEXTURE_SIZE := 256 +const CORAL_VARIANT_COUNT := 3 +const CORAL_RINGS := 14 +const CORAL_SEGMENTS := 20 + +var tuft_mesh: ArrayMesh +var fan_mesh: ArrayMesh +var blade_texture: ImageTexture +var fan_texture: ImageTexture +var coral_meshes: Array[ArrayMesh] = [] + + +static func build(seed: int) -> SeabedFloraAssets: + var result := SeabedFloraAssets.new() + result.blade_texture = _texture_from(_blade_image(seed)) + result.fan_texture = _texture_from(_fan_image(seed + 1)) + result.tuft_mesh = _crossed_quads(3) + result.fan_mesh = _crossed_quads(1) + for variant in CORAL_VARIANT_COUNT: + result.coral_meshes.append(_coral_head(seed + 10 + variant, variant)) + return result + + +## Dome of unit radius and unit height on a flat base, displaced by seeded noise: +## variant 0 a boulder coral (broad bumps), 1 a brain coral (fine ridges), 2 a lobed +## head (deep lobes). Vertex colour carries the shade (crevices darker). +static func _coral_head(seed: int, variant: int) -> ArrayMesh: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.fractal_octaves = 3 + var frequency := [0.9, 2.6, 0.6][variant] as float + var amplitude := [0.12, 0.07, 0.3][variant] as float + noise.frequency = frequency + noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED if variant == 1 else FastNoiseLite.FRACTAL_FBM + var vertices := PackedVector3Array() + var colors := PackedColorArray() + var indices := PackedInt32Array() + # Rings from the top (ring 0, a single pole) down to the base rim; the base is a + # flat fan closed on a centre vertex. + var ring_vertex_count := CORAL_SEGMENTS + for ring in range(CORAL_RINGS + 1): + var polar := (PI * 0.5) * float(ring) / float(CORAL_RINGS) # 0 at the pole, pi/2 at the rim + for segment in CORAL_SEGMENTS: + var azimuth := TAU * float(segment) / float(CORAL_SEGMENTS) + var direction := Vector3(sin(polar) * cos(azimuth), cos(polar), sin(polar) * sin(azimuth)) + var bump := noise.get_noise_3d(direction.x * 3.0, direction.y * 3.0, direction.z * 3.0) + # Keep the rim on the ground: the displacement fades towards the base. + var radius := 1.0 + bump * amplitude * (0.35 + 0.65 * cos(polar)) + var position := direction * radius + position.y = maxf(position.y, 0.0) + vertices.append(position) + var shade := clampf(0.78 + bump * 0.9, 0.5, 1.1) + colors.append(Color(shade, shade, shade)) + for ring in CORAL_RINGS: + for segment in CORAL_SEGMENTS: + var next_segment := (segment + 1) % CORAL_SEGMENTS + var a := ring * ring_vertex_count + segment + var b := ring * ring_vertex_count + next_segment + var c := (ring + 1) * ring_vertex_count + segment + var d := (ring + 1) * ring_vertex_count + next_segment + if ring > 0: + indices.append_array([a, b, c]) + indices.append_array([b, d, c]) + var centre := vertices.size() + vertices.append(Vector3.ZERO) + colors.append(Color(0.6, 0.6, 0.6)) + var rim := CORAL_RINGS * ring_vertex_count + for segment in CORAL_SEGMENTS: + indices.append_array([centre, rim + segment, rim + (segment + 1) % CORAL_SEGMENTS]) + var normals := PackedVector3Array() + normals.resize(vertices.size()) + for index in range(0, indices.size(), 3): + var face_normal := (vertices[indices[index + 2]] - vertices[indices[index]]).cross(vertices[indices[index + 1]] - vertices[indices[index]]) + for corner in 3: + normals[indices[index + corner]] += face_normal + for index in normals.size(): + normals[index] = normals[index].normalized() if normals[index].length() > 0.0 else Vector3.UP + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_COLOR] = colors + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return mesh + + +## Vertical unit quads crossed around the y axis, each 1 wide and 1 tall, UV mapped +## so the texture's bottom row sits on the ground. +static func _crossed_quads(count: int) -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var uvs := PackedVector2Array() + var indices := PackedInt32Array() + for quad in count: + var angle := PI * float(quad) / float(count) + var right := Vector3(cos(angle), 0.0, sin(angle)) * 0.5 + var normal := Vector3(-sin(angle), 0.0, cos(angle)) + var base := vertices.size() + vertices.append_array([-right, right, right + Vector3.UP, -right + Vector3.UP]) + for _corner in 4: + normals.append(normal) + uvs.append_array([Vector2(0.0, 1.0), Vector2(1.0, 1.0), Vector2(1.0, 0.0), Vector2(0.0, 0.0)]) + indices.append_array([base, base + 2, base + 1, base, base + 3, base + 2]) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_TEX_UV] = uvs + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return mesh + + +## A few strap-shaped blades rising from the bottom edge, each with its own curve, +## width and length, darker towards the edges and the base. Alpha outside the blades. +static func _blade_image(seed: int) -> Image: + var generator := RandomNumberGenerator.new() + generator.seed = seed + var image := Image.create(BLADE_TEXTURE_WIDTH, BLADE_TEXTURE_HEIGHT, false, Image.FORMAT_RGBA8) + image.fill(Color(0.0, 0.0, 0.0, 0.0)) + for blade in BLADE_COUNT: + var root_x := (float(blade) + 0.5) / float(BLADE_COUNT) * float(BLADE_TEXTURE_WIDTH) + var bend := generator.randf_range(-22.0, 22.0) + var curve := generator.randf_range(0.6, 1.4) + var length := generator.randf_range(0.72, 1.0) + var half_width := generator.randf_range(3.5, 6.0) + var hue_shift := generator.randf_range(-0.05, 0.05) + for y in BLADE_TEXTURE_HEIGHT: + var t := 1.0 - float(y) / float(BLADE_TEXTURE_HEIGHT - 1) # 0 at the base, 1 at the tip + if t > length: + continue + var centre := root_x + bend * pow(t, curve) + var width := half_width * (1.0 - 0.6 * t * t) + for x in range(int(floorf(centre - width)), int(ceilf(centre + width)) + 1): + if x < 0 or x >= BLADE_TEXTURE_WIDTH: + continue + var edge := absf(float(x) - centre) / width + if edge > 1.0: + continue + var shade := (0.7 + 0.3 * (1.0 - edge * edge)) * (0.75 + 0.25 * t) + var color := Color(0.46 + hue_shift, 0.66 + hue_shift * 0.5, 0.3, 1.0) * shade + color.a = 1.0 + image.set_pixel(x, y, color) + return image + + +## A sea fan: branching net inside a half-ellipse rising from a short stem. Ridged +## noise above a threshold gives the branches, denser near the stem. +static func _fan_image(seed: int) -> Image: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = 0.045 + noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED + noise.fractal_octaves = 3 + var image := Image.create(FAN_TEXTURE_SIZE, FAN_TEXTURE_SIZE, false, Image.FORMAT_RGBA8) + image.fill(Color(0.0, 0.0, 0.0, 0.0)) + var centre_x := float(FAN_TEXTURE_SIZE) * 0.5 + for y in FAN_TEXTURE_SIZE: + var height := 1.0 - float(y) / float(FAN_TEXTURE_SIZE - 1) # 0 at the base + for x in FAN_TEXTURE_SIZE: + var dx := (float(x) - centre_x) / (float(FAN_TEXTURE_SIZE) * 0.48) + var dy := (height - 0.1) / 0.9 + var inside := dx * dx + dy * dy + var stem := absf(dx) < 0.03 and height < 0.25 + if inside > 1.0 and not stem: + continue + var ridge := noise.get_noise_2d(float(x), float(y)) * 0.5 + 0.5 + var density_threshold := 0.5 + 0.22 * inside + if not stem and ridge < density_threshold: + continue + var shade := 0.7 + 0.3 * ridge + image.set_pixel(x, y, Color(shade, shade * 0.92, shade * 0.85, 1.0)) + return image + + +static func _texture_from(image: Image) -> ImageTexture: + image.generate_mipmaps() + return ImageTexture.create_from_image(image) diff --git a/game/world/sites/seabed_terrain.gdshader b/game/world/sites/seabed_terrain.gdshader new file mode 100644 index 0000000..4e8628f --- /dev/null +++ b/game/world/sites/seabed_terrain.gdshader @@ -0,0 +1,178 @@ +shader_type spatial; +render_mode depth_draw_opaque; + +// Sand / rock split by slope, matching the sonar material rule (sand under 20 deg, +// rock over 40 deg). Rock is normal-mapped triplanar in world space so cliffs and +// ledges keep their detail; sand carries an oriented ripple normal map on the xz plane. +// The mesh has no tangents: perturbed normals are built in world space and written +// to NORMAL in view space. Detail shading comes from the normal maps, a grain map +// used as ambient occlusion in the pits, and albedo mottling. +// +// Daylight is attenuated spectrally with depth (Beer-Lambert, per channel): red is +// gone first, blue last, and the whole scene darkens by exp(-k * depth). The scalar +// part scales the sun (light() function, directional only) and the sky ambient (AO); +// the chroma part tints the albedo. Artificial lights such as the submarine's +// projectors are not attenuated, so they stay useful on the drop-off. Everything is +// normalised at reference_depth_m, where the look is the authored palette. + +#include "res://game/world/sites/water_light.gdshaderinc" + +uniform vec3 sand_color : source_color = vec3(0.56, 0.52, 0.42); +uniform vec3 rock_color : source_color = vec3(0.22, 0.21, 0.19); +// Encrusting growth on rock: turf algae on sunlit faces, coralline crusts in patches, +// encrusting sponges in the shade. Patches come from the grain map read at large +// scales; all of it thins out with depth as the light goes. +uniform vec3 turf_color : source_color = vec3(0.3, 0.38, 0.2); +uniform vec3 coralline_color : source_color = vec3(0.6, 0.4, 0.46); +uniform vec3 sponge_color : source_color = vec3(0.82, 0.4, 0.18); +uniform float growth_tile_m = 4.2; +uniform float growth_strength = 1.0; +uniform float growth_fade_start_m = 40.0; +uniform float growth_fade_end_m = 75.0; +uniform float sand_slope_cosine = 0.9397; // cos(20 deg) +uniform float rock_slope_cosine = 0.7660; // cos(40 deg) +// Per-channel absorption of clear tropical water, per metre, scaled for readability. +uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032); +uniform float reference_depth_m = 24.0; +// Floor on the daylight factor so the drop-off foot stays readable rather than black. +uniform float minimum_daylight = 0.05; + +uniform sampler2D sand_normal_map : hint_normal, repeat_enable, filter_linear_mipmap; +uniform sampler2D rock_normal_map : hint_normal, repeat_enable, filter_linear_mipmap; +uniform sampler2D rock_grain_map : hint_default_white, repeat_enable, filter_linear_mipmap; +uniform float sand_tile_m = 3.2; +// Second ripple scale (megaripples) and the slow domain warp that hide the tiling. +uniform float sand_macro_tile_m = 11.3; +uniform float sand_macro_strength = 0.45; +uniform float sand_warp_tile_m = 37.0; +uniform float sand_warp_m = 1.4; +// Swell-built ripples fade out with depth as the wave energy at the bed dies. +uniform float ripple_fade_start_m = 40.0; +uniform float ripple_fade_end_m = 60.0; +uniform float rock_tile_m = 7.0; +uniform float rock_grain_tile_m = 1.7; +uniform float sand_normal_strength = 1.0; +uniform float rock_normal_strength = 1.1; +uniform float rock_grain_normal_strength = 0.45; +uniform float grain_ao_strength = 0.5; +uniform float grain_albedo_contrast = 0.45; +uniform vec2 ripple_direction = vec2(0.8, 0.6); + +varying vec3 world_position; +varying vec3 world_normal; +varying float daylight; + +void vertex() { + world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); +} + +vec3 unpack_normal(vec4 sample) { + return sample.xyz * 2.0 - 1.0; +} + +// Triplanar normal perturbation (UDN blend): each plane's tangent-space xy is added +// onto the world normal along that plane's axes, weighted by how much the surface +// faces the plane. +vec3 triplanar_normal(sampler2D map, vec3 position, vec3 normal, float strength) { + vec3 weights = pow(abs(normal), vec3(4.0)); + weights /= weights.x + weights.y + weights.z; + vec3 tx = unpack_normal(texture(map, position.zy)); + vec3 ty = unpack_normal(texture(map, position.xz)); + vec3 tz = unpack_normal(texture(map, position.xy)); + vec3 perturbation = ( + vec3(0.0, tx.y, tx.x) * weights.x + + vec3(ty.x, 0.0, ty.y) * weights.y + + vec3(tz.x, tz.y, 0.0) * weights.z + ) * strength; + return normalize(normal + perturbation); +} + +float triplanar_grain(sampler2D map, vec3 position, vec3 normal) { + vec3 weights = pow(abs(normal), vec3(4.0)); + weights /= weights.x + weights.y + weights.z; + return texture(map, position.zy).r * weights.x + + texture(map, position.xz).r * weights.y + + texture(map, position.xy).r * weights.z; +} + +void fragment() { + vec3 n = normalize(world_normal); + float up = clamp(n.y, 0.0, 1.0); + float rockness = 1.0 - smoothstep(rock_slope_cosine, sand_slope_cosine, up); + + // Sand: ripple normal map rotated to the swell direction, on the xz plane. The + // plane is bent by a slow warp (two decorrelated reads of the seamless grain map) + // and a second, larger ripple scale is blended in, so no period repeats across + // the field. Ripples fade with depth. + vec2 ripple_axis = normalize(ripple_direction); + vec2 warp = vec2( + texture(rock_grain_map, world_position.xz / sand_warp_tile_m).r, + texture(rock_grain_map, world_position.xz / sand_warp_tile_m + vec2(0.37, 0.61)).r + ) - 0.5; + vec2 bent_xz = world_position.xz + warp * sand_warp_m; + vec2 ripple_frame = vec2( + dot(bent_xz, ripple_axis), + dot(bent_xz, vec2(-ripple_axis.y, ripple_axis.x)) + ); + vec3 sand_tangent = unpack_normal(texture(sand_normal_map, ripple_frame / sand_tile_m)); + vec3 macro_tangent = unpack_normal(texture(sand_normal_map, ripple_frame / sand_macro_tile_m + vec2(0.5, 0.25))); + float depth_for_ripples = -world_position.y; + float ripple_strength = sand_normal_strength * (1.0 - smoothstep(ripple_fade_start_m, ripple_fade_end_m, depth_for_ripples)); + vec2 tangent_xy = sand_tangent.xy * ripple_strength + macro_tangent.xy * sand_macro_strength * ripple_strength; + vec3 sand_perturbation = vec3(ripple_axis.x, 0.0, ripple_axis.y) * tangent_xy.x + + vec3(-ripple_axis.y, 0.0, ripple_axis.x) * tangent_xy.y; + vec3 sand_normal = normalize(n + sand_perturbation); + + // Rock: coarse triplanar normal map plus fine grain, both in world space. + vec3 rock_normal = triplanar_normal(rock_normal_map, world_position / rock_tile_m, n, rock_normal_strength); + rock_normal = triplanar_normal(rock_normal_map, world_position / rock_grain_tile_m, rock_normal, rock_grain_normal_strength); + float grain = triplanar_grain(rock_grain_map, world_position / rock_grain_tile_m, n); + + vec3 shading_normal = normalize(mix(sand_normal, rock_normal, rockness)); + NORMAL = normalize((VIEW_MATRIX * vec4(shading_normal, 0.0)).xyz); + + // Albedo: mottled rock, lightly grained sand. + vec3 rock_albedo = rock_color * (1.0 + (grain - 0.5) * 2.0 * grain_albedo_contrast); + vec3 sand_albedo = sand_color * (1.0 + (grain - 0.5) * 0.3); + float depth_m = -world_position.y; + + // Encrusting growth on the rock. + float growth_light = (1.0 - smoothstep(growth_fade_start_m, growth_fade_end_m, depth_m)) * growth_strength; + float sunlit = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + float patch_a = triplanar_grain(rock_grain_map, world_position / growth_tile_m + vec3(0.21, 0.73, 0.37), n); + float patch_b = triplanar_grain(rock_grain_map, world_position / (growth_tile_m * 0.53) + vec3(0.61, 0.11, 0.9), n); + float turf_mask = smoothstep(0.4, 0.58, patch_a) * sunlit * growth_light; + float coralline_mask = smoothstep(0.6, 0.74, patch_b) * growth_light; + float sponge_mask = smoothstep(0.62, 0.74, patch_a * 0.5 + patch_b * 0.5) * (1.0 - 0.6 * sunlit) * growth_light; + float grain_mottle = 1.0 + (grain - 0.5) * 0.6; + rock_albedo = mix(rock_albedo, turf_color * grain_mottle, turf_mask); + rock_albedo = mix(rock_albedo, coralline_color * grain_mottle, coralline_mask); + rock_albedo = mix(rock_albedo, sponge_color * grain_mottle, sponge_mask); + float rock_roughness = 0.78 + 0.12 * grain; + rock_roughness = mix(rock_roughness, 0.6, coralline_mask); + rock_roughness = mix(rock_roughness, 0.9, sponge_mask); + vec3 albedo = mix(sand_albedo, rock_albedo, rockness); + + // Water column: spectral transmittance relative to the reference depth. + vec3 transmittance = water_transmittance(depth_m, water_absorption_per_m, reference_depth_m); + daylight = water_daylight(transmittance, minimum_daylight); + ALBEDO = albedo * water_chroma(transmittance, water_absorption_per_m, reference_depth_m); + + // Detail shading: pits of the grain map and ripple troughs read as occlusion; + // the daylight factor dims the sky ambient with depth. + float rock_ao = mix(1.0, 0.45 + 0.55 * grain, grain_ao_strength); + float sand_ao = mix(1.0, 0.8 + 0.2 * clamp(sand_tangent.z, 0.0, 1.0), grain_ao_strength * 0.6); + AO = mix(sand_ao, rock_ao, rockness) * daylight; + AO_LIGHT_AFFECT = 0.35; + ROUGHNESS = mix(0.95, rock_roughness, rockness); + METALLIC = 0.0; +} + +// Lambert diffuse; the sun (directional) is dimmed by the water column, other lights +// (submarine projectors) are not. Sand and rock are rough enough to skip specular. +void light() { + float sun_factor = water_sun_factor(LIGHT_IS_DIRECTIONAL, daylight); + float lambert = clamp(dot(NORMAL, LIGHT), 0.0, 1.0); + DIFFUSE_LIGHT += lambert * ATTENUATION * LIGHT_COLOR * ALBEDO * sun_factor / PI; +} diff --git a/game/world/sites/vehicle_test_environment.gd b/game/world/sites/vehicle_test_environment.gd deleted file mode 100644 index aeceefd..0000000 --- a/game/world/sites/vehicle_test_environment.gd +++ /dev/null @@ -1,153 +0,0 @@ -class_name VehicleTestEnvironment -extends Node3D - -const TERRAIN_COLUMNS := 61 -const TERRAIN_ROWS := 69 -const TERRAIN_WIDTH_M := 180.0 -const TERRAIN_LENGTH_M := 200.0 -const TERRAIN_NEAR_Z := 28.0 - - -func _ready() -> void: - _add_bathymetric_terrain() - _place_calibration_target() - _add_suspended_particles() - - -func _add_suspended_particles() -> 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) - - -func _add_bathymetric_terrain() -> void: - var surface := SurfaceTool.new() - surface.begin(Mesh.PRIMITIVE_TRIANGLES) - for row in range(TERRAIN_ROWS - 1): - for column in range(TERRAIN_COLUMNS - 1): - var top_left := _terrain_vertex(row, column) - var top_right := _terrain_vertex(row, column + 1) - var bottom_left := _terrain_vertex(row + 1, column) - var bottom_right := _terrain_vertex(row + 1, column + 1) - _add_terrain_triangle(surface, top_left, top_right, bottom_left) - _add_terrain_triangle(surface, top_right, bottom_right, bottom_left) - surface.generate_normals() - var mesh := surface.commit() - - var body := StaticBody3D.new() - body.name = "BathymetricTerrain" - body.set_meta("sonar_material", 1) - body.set_meta("sonar_reflectivity", 0.72) - var mesh_instance := MeshInstance3D.new() - mesh_instance.name = "Mesh" - mesh_instance.mesh = mesh - var material := StandardMaterial3D.new() - material.albedo_color = Color(0.075, 0.19, 0.19) - material.roughness = 0.92 - mesh_instance.material_override = material - body.add_child(mesh_instance) - var collision := CollisionShape3D.new() - collision.name = "Collision" - var height_map := HeightMapShape3D.new() - height_map.map_width = TERRAIN_COLUMNS - height_map.map_depth = TERRAIN_ROWS - var height_data := PackedFloat32Array() - height_data.resize(TERRAIN_COLUMNS * TERRAIN_ROWS) - for row in TERRAIN_ROWS: - for column in TERRAIN_COLUMNS: - var vertex := _terrain_vertex(row, column) - height_data[row * TERRAIN_COLUMNS + column] = vertex.y - height_map.map_data = height_data - collision.shape = height_map - collision.position = Vector3( - 0.0, - 0.0, - TERRAIN_NEAR_Z - TERRAIN_LENGTH_M * 0.5 - ) - collision.scale = Vector3( - TERRAIN_WIDTH_M / float(TERRAIN_COLUMNS - 1), - 1.0, - TERRAIN_LENGTH_M / float(TERRAIN_ROWS - 1) - ) - body.add_child(collision) - add_child(body) - - -func _terrain_vertex(row: int, column: int) -> Vector3: - var x := lerpf( - -TERRAIN_WIDTH_M * 0.5, - TERRAIN_WIDTH_M * 0.5, - float(column) / float(TERRAIN_COLUMNS - 1) - ) - var z := TERRAIN_NEAR_Z - ( - TERRAIN_LENGTH_M * float(row) / float(TERRAIN_ROWS - 1) - ) - return Vector3(x, _terrain_height(x, z), z) - - -func _terrain_height(x: float, z: float) -> float: - var distance_ratio := clampf( - (TERRAIN_NEAR_Z - z) / TERRAIN_LENGTH_M, - 0.0, - 1.0 - ) - var base := lerpf(-40.0, -78.0, distance_ratio) - var left_ridge := 13.0 * exp( - -(pow(x + 24.0, 2.0) / 210.0 + pow(z + 48.0, 2.0) / 980.0) - ) - var right_ridge := 15.0 * exp( - -(pow(x - 25.0, 2.0) / 250.0 + pow(z + 58.0, 2.0) / 1150.0) - ) - var channel := -8.5 * exp( - -(pow(x, 2.0) / 120.0 + pow(z + 66.0, 2.0) / 2500.0) - ) - var rear_rise := 8.0 * exp( - -(pow(x + 5.0, 2.0) / 900.0 + pow(z + 125.0, 2.0) / 620.0) - ) - var undulation := 1.8 * sin(x * 0.075) * cos(z * 0.048) - return base + left_ridge + right_ridge + channel + rear_rise + undulation - - -func _add_terrain_triangle( - surface: SurfaceTool, - a: Vector3, - b: Vector3, - c: Vector3 -) -> void: - surface.set_uv(Vector2(a.x, a.z) * 0.02) - surface.add_vertex(a) - surface.set_uv(Vector2(b.x, b.z) * 0.02) - surface.add_vertex(b) - surface.set_uv(Vector2(c.x, c.z) * 0.02) - surface.add_vertex(c) - - -func _place_calibration_target() -> void: - var target := get_node_or_null("SonarCalibrationTarget") as Node3D - if target != null: - target.position.y = _terrain_height(target.position.x, target.position.z) + 2.5 diff --git a/game/world/sites/vehicle_test_environment.tscn b/game/world/sites/vehicle_test_environment.tscn deleted file mode 100644 index ac53448..0000000 --- a/game/world/sites/vehicle_test_environment.tscn +++ /dev/null @@ -1,75 +0,0 @@ -[gd_scene load_steps=8 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="SphereMesh" id="BeaconMesh"] -radius = 0.55 -height = 1.1 - -[sub_resource type="StandardMaterial3D" id="MetalTargetMaterial"] -albedo_color = Color(0.24, 0.18, 0.08, 1) -metallic = 0.88 -roughness = 0.27 - -[sub_resource type="BoxMesh" id="MetalTargetMesh"] -size = Vector3(8, 5, 7) - -[sub_resource type="BoxShape3D" id="MetalTargetShape"] -size = Vector3(8, 5, 7) - -[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="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 - -[node name="SonarCalibrationTarget" type="StaticBody3D" parent="."] -position = Vector3(0, -60, -58) -metadata/sonar_material = 2 -metadata/sonar_reflectivity = 1.0 - -[node name="Mesh" type="MeshInstance3D" parent="SonarCalibrationTarget"] -mesh = SubResource("MetalTargetMesh") -material_override = SubResource("MetalTargetMaterial") - -[node name="Collision" type="CollisionShape3D" parent="SonarCalibrationTarget"] -shape = SubResource("MetalTargetShape") diff --git a/game/world/sites/water_column_fog.gdshader b/game/world/sites/water_column_fog.gdshader new file mode 100644 index 0000000..99f3f66 --- /dev/null +++ b/game/world/sites/water_column_fog.gdshader @@ -0,0 +1,25 @@ +shader_type fog; + +// Water column for the volumetric fog: the scattering albedo follows the same +// per-channel Beer-Lambert transmittance as the seabed shader, so the haze turns +// from green-blue near the plateau to indigo at the drop-off foot, and the density +// grows slowly with depth (more suspended sediment, less light). It covers every +// object in the volume, not only the terrain. + +uniform vec3 shallow_albedo : source_color = vec3(0.5, 0.78, 0.92); +uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032); +uniform float reference_depth_m = 24.0; +uniform float minimum_albedo = 0.03; +uniform float base_density = 0.012; +uniform float density_per_m = 0.00012; +uniform float emission_strength = 0.0; + +void fog() { + // Above the reference depth the haze keeps its shallow colour: brightening it + // further would wash the upper water column towards white. + float excess_depth_m = max(-WORLD_POSITION.y - reference_depth_m, 0.0); + vec3 transmittance = exp(-water_absorption_per_m * excess_depth_m); + ALBEDO = clamp(shallow_albedo * transmittance, vec3(minimum_albedo), vec3(1.0)); + DENSITY = base_density + density_per_m * excess_depth_m; + EMISSION = vec3(emission_strength); +} diff --git a/game/world/sites/water_light.gdshaderinc b/game/world/sites/water_light.gdshaderinc new file mode 100644 index 0000000..c715a75 --- /dev/null +++ b/game/world/sites/water_light.gdshaderinc @@ -0,0 +1,27 @@ +// Shared water-column optics for the seabed and flora shaders: per-channel +// Beer-Lambert transmittance relative to a reference depth, split into a scalar +// daylight factor (dims the sun and the sky ambient) and a chroma (tints albedo). +// Artificial lights are never attenuated: see water_sun_factor. + +const vec3 WATER_LUMINANCE_WEIGHTS = vec3(0.2126, 0.7152, 0.0722); + +vec3 water_transmittance(float depth_m, vec3 absorption_per_m, float reference_depth_m) { + return exp(-absorption_per_m * (depth_m - reference_depth_m)); +} + +float water_daylight(vec3 transmittance, float minimum_daylight) { + return max(dot(transmittance, WATER_LUMINANCE_WEIGHTS), minimum_daylight); +} + +// Chroma of the transmittance, normalised so the authored palette holds at the +// reference depth once the reference transmittance (red already filtered) is applied. +vec3 water_chroma(vec3 transmittance, vec3 absorption_per_m, float reference_depth_m) { + float brightness = max(dot(transmittance, WATER_LUMINANCE_WEIGHTS), 0.001); + vec3 reference = exp(-absorption_per_m * reference_depth_m); + reference /= dot(reference, WATER_LUMINANCE_WEIGHTS); + return transmittance / brightness * reference; +} + +float water_sun_factor(bool light_is_directional, float daylight) { + return light_is_directional ? daylight : 1.0; +} diff --git a/game/world/sonar/reference_bathymetry_acquisition.gd b/game/world/sonar/reference_bathymetry_acquisition.gd index 13267ab..fb3d5f3 100644 --- a/game/world/sonar/reference_bathymetry_acquisition.gd +++ b/game/world/sonar/reference_bathymetry_acquisition.gd @@ -68,10 +68,16 @@ func capture(request: SonarCaptureRequest) -> SonarCaptureResult: var material := SonarMaterial.Value.ROCK var reflectivity := 0.72 if collider != null: - material = int(collider.get_meta("sonar_material", material)) - reflectivity = float( - collider.get_meta("sonar_reflectivity", reflectivity) - ) + if collider.has_method("sonar_material_at"): + material = int(collider.call("sonar_material_at", world_position)) + reflectivity = float( + collider.call("sonar_reflectivity_at", world_position) + ) + else: + material = int(collider.get_meta("sonar_material", material)) + reflectivity = float( + collider.get_meta("sonar_reflectivity", reflectivity) + ) var incidence := clampf(absf(world_normal.dot(Vector3.UP)), 0.18, 1.0) var attenuation := lerpf(1.0, 0.55, distance_ratio) var intensity := clampf(incidence * reflectivity * attenuation, 0.0, 1.0) diff --git a/scripts/check_architecture.sh b/scripts/check_architecture.sh index 6f2c38d..47689ad 100755 --- a/scripts/check_architecture.sh +++ b/scripts/check_architecture.sh @@ -38,6 +38,9 @@ source_directories="src tests" if [ -d game ]; then source_directories="$source_directories game" fi +if [ -d tools ]; then + source_directories="$source_directories tools" +fi duplicate_classes="$( rg --no-filename -o \ @@ -57,6 +60,11 @@ if [ -d game ]; then test_imports="$(rg -n 'res://tests/' game --glob '*.gd' || true)" fail_with_matches "production imports test code" "$test_imports" + tool_imports="$( + rg -n 'res://tools/' game content --glob '*.gd' --glob '*.tscn' --glob '*.tres' 2>/dev/null || true + )" + fail_with_matches "production imports authoring tools" "$tool_imports" + poc_references="$(rg -n 'poc_[ab]|POC_[AB]' game --glob '*.gd' --glob '*.tscn' || true)" fail_with_matches "production contains a prototype dependency" "$poc_references" @@ -108,6 +116,7 @@ if [ -d game ]; then input_violations="$( rg -n '\bInput\.' game --glob '*.gd' \ + | sed 's#\\#/#g' \ | rg -v '^game/infrastructure/input/' \ || true )" diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index c14fb7e..bbf8b54 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -103,6 +103,42 @@ check_godot_log \ "production sonar application unit tests" \ "$QUALITY_TEMP_DIRECTORY/sonar-application-unit-console.log" +printf 'Quality gate: production site domain unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-domain-unit.log" \ + --path . \ + --script tests/unit/run_site_domain_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log" +check_godot_log \ + "production site domain unit tests" \ + "$QUALITY_TEMP_DIRECTORY/site-domain-unit-console.log" + +printf 'Quality gate: production site application unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-application-unit.log" \ + --path . \ + --script tests/unit/run_site_application_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" +check_godot_log \ + "production site application unit tests" \ + "$QUALITY_TEMP_DIRECTORY/site-application-unit-console.log" + +printf 'Quality gate: site baker unit tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-baker-unit.log" \ + --path . \ + --script tests/unit/run_site_baker_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" +check_godot_log "site baker unit tests" "$QUALITY_TEMP_DIRECTORY/site-baker-unit-console.log" + +printf 'Quality gate: site content contract tests\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-content-contract.log" \ + --path . \ + --script tests/contract/run_site_content_tests.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" +check_godot_log "site content contract tests" "$QUALITY_TEMP_DIRECTORY/site-content-contract-console.log" + printf 'Quality gate: production foundation integration\n' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/foundation-integration.log" \ @@ -113,6 +149,16 @@ check_godot_log \ "production foundation integration" \ "$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log" +printf 'Quality gate: production site terrain integration\n' +"$GODOT_EXECUTABLE" --headless \ + --log-file "$QUALITY_TEMP_DIRECTORY/site-terrain-integration.log" \ + --path . \ + --script tests/integration/run_site_terrain_integration.gd \ + 2>&1 | tee "$QUALITY_TEMP_DIRECTORY/site-terrain-integration-console.log" +check_godot_log \ + "production site terrain integration" \ + "$QUALITY_TEMP_DIRECTORY/site-terrain-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 cbab9ff..b468f3c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -20,4 +20,19 @@ Suites de production actuelles : - `integration/run_vertical_slice_foundation.gd` : composition, mouvement inertiel, caméra indépendante, cockpit 3D fixe, instruments physiques, couche d'alerte, sonar bathymétrique partagé, pause des ticks et remappage avec - conflit. + conflit ; +- `unit/run_site_domain_tests.gd` : données de terrain, champ de hauteur, + définition de site et ses placements/routes ; +- `unit/run_site_application_tests.gd` : règle de limites jouables ; +- `unit/run_site_baker_tests.gd` : primitives (lisses et de relief marqué : + canyon, falaise, butte, chaos de blocs), modificateur de strates, matériaux + imposés, déterminisme de la cuisson et stabilité octet par octet du Banc du + Vigilant ; +- `contract/run_site_content_tests.gd` : règles de contenu du Banc du Vigilant + (profondeurs, routes, amers) et géométrie du Jardin des Formes (profondeur et + gradins du canyon, largeur de la crevasse, fraction de roche du chaos, chenal + entre les éperons, chute de la falaise) ; +- `integration/run_site_terrain_integration.gd` : collision et maillage égaux au + champ, matériaux sonar par cellule, puis scène de site chargée trois fois — le + site de calibration, le Banc du Vigilant et le Jardin des Formes (budget de + chargement de 3 s, sillon, coque, canyon, scans de référence). diff --git a/tests/contract/run_site_content_tests.gd b/tests/contract/run_site_content_tests.gd new file mode 100644 index 0000000..c466dd2 --- /dev/null +++ b/tests/contract/run_site_content_tests.gd @@ -0,0 +1,297 @@ +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") +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 SITE_PATH := "res://content/sites/zone1_vigilant.tres" +const SHOWCASE_PATH := "res://content/sites/terrain_showcase.tres" +# Showcase probe positions (docs/briefs/terrain-varied-landforms.md §5). The canyon +# transect crosses its second leg at right angles; the crevasse transect is the +# z = -120 row between the two buttes, compared with the open plateau on the same row; +# the escarpment is probed across its step band at x = 0, where the crest is at z = 217. +const SHOWCASE_MESA_TOP := Vector2(-80.0, -90.0) +const SHOWCASE_CANYON_MID := Vector2(-130.0, -135.0) +const SHOWCASE_CANYON_AXIS := Vector2(40.0, -30.0) +const SHOWCASE_CREVASSE_ROW_Z := -120.0 +const SHOWCASE_CREVASSE_PLAIN_X := 0.0 +const SHOWCASE_CREVASSE_FLOOR_X := 124.0 +const SHOWCASE_BOULDER_FIELD_CENTRE := Vector2(-120.0, 110.0) +const SHOWCASE_BOULDER_FIELD_RADIUS_M := 75.0 +const SHOWCASE_WEST_SPUR := Vector2(80.0, 120.0) +const SHOWCASE_CHANNEL := Vector2(125.0, 120.0) +const SHOWCASE_EAST_SPUR := Vector2(170.0, 125.0) +const SHOWCASE_ESCARPMENT_CREST_Z := 217.0 +const SHOWCASE_ESCARPMENT_BAND_M := 14.0 # softness 6 m plus 8 m of outline noise +const STEEP_WALL_RISE_PER_M := 1.73 # tan(60 degrees) +const LEDGE_RISE_PER_M := 0.35 +const ROUTE_CLEARANCE_M := 3.0 +const MINIMUM_LANDMARK_SPACING_M := 60.0 +const MAXIMUM_ANCHOR_TO_WRECK_M := 320.0 +const MAXIMUM_WRECK_SPACING_M := 260.0 +const HULL_HALF_LENGTH_M := 14.0 +const HULL_SEABED_TOLERANCE_M := 2.0 +const TEST_COUNT := 54 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_zone1_content() + _test_showcase_content() + + if _failures.is_empty(): + print("Site content contract tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site content contract tests: %d failed." % _failures.size()) + quit(1) + + +func _test_zone1_content() -> void: + var definition := load(SITE_PATH) as DiveSiteDefinition + _expect(definition != null, "zone 1 definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "zone 1 definition is valid: %s" % definition.validation_error()) + var field := definition.height_field() + if field == null: + return + var rules = DiveRulesResolver.new().resolve( + DiveDifficultyProfile.standard(), + DiveUpgradeEffects.new(), + DiveAccessibilityAdjustments.new() + ) + var certified_m: float = rules.certified_depth_m + var critical_m: float = rules.critical_depth_m + + _expect(definition.playable_bounds_m.is_equal_approx(Rect2(-300.0, -300.0, 600.0, 600.0)), "playable zone is 600 x 600 m") + _expect(field.minimum_elevation_m() >= -88.5 and field.maximum_elevation_m() <= -19.5, "terrain stays between 20 m and 88 m deep") + _expect(field.cell_size_m() == 1.5 and field.columns() == 513 and field.rows() == 513, "terrain grid is 513 x 513 cells of 1.5 m") + + var targets := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET) + var decoys := definition.placements_of_kind(SitePlacement.Kind.WRECK_DECOY) + var items := definition.placements_of_kind(SitePlacement.Kind.SECONDARY_ITEM) + var anchors := definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR) + var debris := definition.placements_of_kind(SitePlacement.Kind.DEBRIS) + var landmarks := definition.placements_of_kind(SitePlacement.Kind.LANDMARK) + _expect(targets.size() == 1, "exactly one target wreck") + _expect(decoys.size() == 2, "exactly two decoy wrecks") + _expect(items.size() == 1, "exactly one secondary item") + _expect(anchors.size() == 1, "exactly one vessel anchor") + _expect(debris.size() >= 3, "a debris trail of at least three pieces") + _expect(landmarks.size() >= 4, "at least four named landmarks") + + for wreck: SitePlacement in targets + decoys: + _expect(-wreck.position_m.y <= certified_m, "%s lies above the certified depth" % wreck.placement_id) + _expect(_rests_on_terrain(field, wreck), "%s rests on or in the seabed" % wreck.placement_id) + if items.size() == 1: + var item_depth := -items[0].position_m.y + _expect(item_depth > certified_m and item_depth <= critical_m, "secondary item is between 100 %% and 115 %% of the certified depth (%.1f m)" % item_depth) + _expect(_rests_on_terrain(field, items[0]), "secondary item rests on the seabed") + for piece: SitePlacement in debris: + if -piece.position_m.y < 45.0 or -piece.position_m.y > 66.0 or not _rests_on_terrain(field, piece): + _failures.append("debris %s is off the slope or floating" % piece.placement_id) + _expect(true, "debris pieces checked individually") + + if anchors.size() == 1: + var seabed_under_anchor := -field.elevation_at(anchors[0].position_m.x, anchors[0].position_m.z) + _expect(seabed_under_anchor >= 20.0 and seabed_under_anchor <= 30.0, "vessel anchors over 20-30 m of seabed") + var spawn := definition.spawn_position_m + _expect(spawn.y - field.elevation_at(spawn.x, spawn.z) >= 8.0, "spawn has at least 8 m of water under the keel") + _expect(is_equal_approx(-spawn.y, 10.0), "spawn is 10 m deep") + + var safe := definition.route(&"safe_ridge") + var risky := definition.route(&"risky_groove") + _expect(safe != null and safe.maximum_depth_m == 32.0, "safe route is declared with a 32 m ceiling") + _expect(risky != null and risky.maximum_depth_m == 48.0, "risky route is declared with a 48 m ceiling") + if safe != null and risky != null and targets.size() == 1: + _expect(_route_clears_terrain(field, safe), "safe route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + _expect(_route_clears_terrain(field, risky), "risky route clears the terrain by %.0f m" % ROUTE_CLEARANCE_M) + var target := targets[0].position_m + _expect( + _xz_distance(safe.waypoints_m[-1], target) <= 25.0 and _xz_distance(risky.waypoints_m[-1], target) <= 25.0, + "both routes end within 25 m of the target" + ) + _expect(_route_length(risky) < _route_length(safe) * 0.9, "risky route is shorter than the safe route") + + var spaced := true + for i in landmarks.size(): + for j in range(i + 1, landmarks.size()): + if _xz_distance(landmarks[i].position_m, landmarks[j].position_m) < MINIMUM_LANDMARK_SPACING_M: + spaced = false + _expect(spaced, "landmarks are at least 60 m apart") + + var wrecks := targets + decoys + if anchors.size() == 1: + var anchor := anchors[0].position_m + var farthest := 0.0 + for wreck: SitePlacement in wrecks: + farthest = maxf(farthest, _xz_distance(anchor, wreck.position_m)) + _expect(farthest <= MAXIMUM_ANCHOR_TO_WRECK_M, "every wreck lies within 320 m of the mooring (farthest %.0f m)" % farthest) + var widest := 0.0 + for i in wrecks.size(): + for j in range(i + 1, wrecks.size()): + widest = maxf(widest, _xz_distance(wrecks[i].position_m, wrecks[j].position_m)) + _expect(widest <= MAXIMUM_WRECK_SPACING_M, "every pair of wrecks lies within 260 m (widest %.0f m)" % widest) + if targets.size() == 1: + var target_xz := Vector2(targets[0].position_m.x, targets[0].position_m.z) + var spawn_xz := Vector2(spawn.x, spawn.z) + var yaw := definition.spawn_yaw_radians + var spawn_forward := Vector2(-sin(yaw), -cos(yaw)) + _expect(spawn_forward.dot(target_xz - spawn_xz) > 0.0, "the spawn faces the target wreck") + _expect(_hull_lies_along_seabed(field, targets[0]), "the target hull lies along the groove floor") + + +func _test_showcase_content() -> void: + var definition := load(SHOWCASE_PATH) as DiveSiteDefinition + _expect(definition != null, "showcase definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "showcase definition is valid: %s" % definition.validation_error()) + var field := definition.height_field() + if field == null: + return + _expect(field.cell_size_m() == 1.0 and field.columns() == 513 and field.rows() == 513, "showcase grid is 513 x 513 cells of 1 m") + _expect(definition.playable_bounds_m.is_equal_approx(Rect2(-220.0, -220.0, 440.0, 440.0)), "showcase playable zone is 440 x 440 m") + _expect(field.minimum_elevation_m() >= -80.5 and field.maximum_elevation_m() <= -19.0, "showcase terrain stays between 19 m and 80 m deep") + var anchors := definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR) + _expect(anchors.size() == 1 and definition.placements.size() == 1, "showcase has a single placement, the mooring beacon") + var spawn := definition.spawn_position_m + _expect(spawn.y - field.elevation_at(spawn.x, spawn.z) >= 10.0, "showcase spawn has at least 10 m of water under the keel") + + # NW: stratified canyon through the mesa. + var mesa_top := field.elevation_at(SHOWCASE_MESA_TOP.x, SHOWCASE_MESA_TOP.y) + var canyon_floor := field.elevation_at(SHOWCASE_CANYON_MID.x, SHOWCASE_CANYON_MID.y) + _expect(mesa_top - canyon_floor >= 18.0, "canyon floor is at least 18 m below the mesa top (%.1f m)" % (mesa_top - canyon_floor)) + _expect(field.material_at(SHOWCASE_CANYON_MID.x, SHOWCASE_CANYON_MID.y) == SonarMaterial.Value.SAND, "canyon floor is sand through the rock mesa") + var axis := SHOWCASE_CANYON_AXIS.normalized() + var lowest := INF + var highest := -INF + for along in range(-5, 6): + var point := SHOWCASE_CANYON_MID + axis * float(along) + var elevation := field.elevation_at(point.x, point.y) + lowest = minf(lowest, elevation) + highest = maxf(highest, elevation) + _expect(highest - lowest <= 1.0, "canyon floor is flat within 1 m over 10 m along the axis") + var normal := Vector2(-axis.y, axis.x) + var transect := PackedFloat32Array() + for offset in range(-30, 31): + var point := SHOWCASE_CANYON_MID + normal * float(offset) + transect.append(field.elevation_at(point.x, point.y)) + var steepest := 0.0 + var ledges := 0 + var on_ledge := false + for index in range(transect.size() - 1): + var rise := absf(transect[index + 1] - transect[index]) + steepest = maxf(steepest, rise) + var in_wall := transect[index] > canyon_floor + 1.0 and transect[index] < mesa_top - 1.0 + # A ledge is a run of flat metres inside the wall, separated from the next by a riser. + if in_wall and rise < LEDGE_RISE_PER_M: + if not on_ledge: + ledges += 1 + on_ledge = true + else: + on_ledge = false + _expect(steepest >= STEEP_WALL_RISE_PER_M, "canyon wall is steeper than 60 degrees somewhere (%.2f m per m)" % steepest) + _expect(ledges >= 2, "canyon walls carry at least two distinct ledges (%d)" % ledges) + + # NE: crevasse between the buttes. + var plain := field.elevation_at(SHOWCASE_CREVASSE_PLAIN_X, SHOWCASE_CREVASSE_ROW_Z) + var crevasse_floor := field.elevation_at(SHOWCASE_CREVASSE_FLOOR_X, SHOWCASE_CREVASSE_ROW_Z) + _expect(plain - crevasse_floor >= 16.0, "crevasse floor is at least 16 m below the open plateau (%.1f m)" % (plain - crevasse_floor)) + _expect(field.material_at(SHOWCASE_CREVASSE_FLOOR_X, SHOWCASE_CREVASSE_ROW_Z) == SonarMaterial.Value.SAND, "crevasse floor is sand") + var deep_cells := 0 + for x in range(100, 146): + if field.elevation_at(float(x), SHOWCASE_CREVASSE_ROW_Z) < plain - 10.0: + deep_cells += 1 + _expect(deep_cells >= 3 and deep_cells <= 8, "crevasse is 3 to 8 m wide at 10 m down (%d m)" % deep_cells) + + # SW: boulder field on sand. + var rock_cells := 0 + var disc_cells := 0 + var radius := int(SHOWCASE_BOULDER_FIELD_RADIUS_M) + for z in range(-radius, radius + 1): + for x in range(-radius, radius + 1): + var point := SHOWCASE_BOULDER_FIELD_CENTRE + Vector2(x, z) + if point.distance_to(SHOWCASE_BOULDER_FIELD_CENTRE) > SHOWCASE_BOULDER_FIELD_RADIUS_M: + continue + disc_cells += 1 + if field.material_at(point.x, point.y) == SonarMaterial.Value.ROCK: + rock_cells += 1 + var rock_fraction := float(rock_cells) / float(disc_cells) + _expect(rock_fraction >= 0.25 and rock_fraction <= 0.6, "boulder field is 25 to 60 %% rock (%.0f %%)" % (rock_fraction * 100.0)) + _expect(field.material_at(SHOWCASE_BOULDER_FIELD_CENTRE.x + 100.0, SHOWCASE_BOULDER_FIELD_CENTRE.y) == SonarMaterial.Value.SAND, "the sand plain beside the boulder field is sand") + + # SE: two spurs and a sand channel. + var west_spur := field.elevation_at(SHOWCASE_WEST_SPUR.x, SHOWCASE_WEST_SPUR.y) + var east_spur := field.elevation_at(SHOWCASE_EAST_SPUR.x, SHOWCASE_EAST_SPUR.y) + var channel := field.elevation_at(SHOWCASE_CHANNEL.x, SHOWCASE_CHANNEL.y) + _expect(west_spur - channel >= 5.0 and east_spur - channel >= 5.0, "the channel runs at least 5 m below both spurs") + _expect( + field.material_at(SHOWCASE_WEST_SPUR.x, SHOWCASE_WEST_SPUR.y) == SonarMaterial.Value.ROCK + and field.material_at(SHOWCASE_EAST_SPUR.x, SHOWCASE_EAST_SPUR.y) == SonarMaterial.Value.ROCK, + "both spur summits are rock" + ) + _expect(field.material_at(SHOWCASE_CHANNEL.x, SHOWCASE_CHANNEL.y + 30.0) == SonarMaterial.Value.SAND, "the channel floor is sand") + + # S: escarpment, measured across its step band only so the slope below cannot + # stand in for a missing step (the slope alone would give about 7 m here). + var above := field.elevation_at(0.0, SHOWCASE_ESCARPMENT_CREST_Z - SHOWCASE_ESCARPMENT_BAND_M) + var below := field.elevation_at(0.0, SHOWCASE_ESCARPMENT_CREST_Z + SHOWCASE_ESCARPMENT_BAND_M) + _expect(above - below >= 18.0, "the escarpment steps down at least 18 m across its crest band (%.1f m)" % (above - below)) + + +func _rests_on_terrain(field: TerrainHeightField, placement: SitePlacement) -> bool: + var seabed := field.elevation_at(placement.position_m.x, placement.position_m.z) + var bottom := placement.position_m.y - placement.footprint_m.y * 0.5 + return bottom >= seabed - placement.footprint_m.y and bottom <= seabed + 1.0 + + +func _hull_lies_along_seabed(field: TerrainHeightField, hull: SitePlacement) -> bool: + var centre := Vector2(hull.position_m.x, hull.position_m.z) + var long_axis := Vector2(cos(hull.yaw_radians), -sin(hull.yaw_radians)) + var centre_seabed := field.elevation_at(centre.x, centre.y) + for side: float in [-1.0, 1.0]: + var end := centre + long_axis * HULL_HALF_LENGTH_M * side + if absf(field.elevation_at(end.x, end.y) - centre_seabed) > HULL_SEABED_TOLERANCE_M: + return false + return true + + +func _route_clears_terrain(field: TerrainHeightField, route: SiteRoute) -> bool: + for i in range(route.waypoints_m.size() - 1): + var a := route.waypoints_m[i] + var b := route.waypoints_m[i + 1] + var steps := maxi(int(ceilf(a.distance_to(b) / 2.0)), 1) + for step in range(steps + 1): + var point := a.lerp(b, float(step) / float(steps)) + if point.y - field.elevation_at(point.x, point.z) < ROUTE_CLEARANCE_M: + return false + return true + + +func _route_length(route: SiteRoute) -> float: + var length := 0.0 + for i in range(route.waypoints_m.size() - 1): + length += route.waypoints_m[i].distance_to(route.waypoints_m[i + 1]) + return length + + +func _xz_distance(a: Vector3, b: Vector3) -> float: + return Vector2(a.x, a.z).distance_to(Vector2(b.x, b.z)) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd new file mode 100644 index 0000000..253ac12 --- /dev/null +++ b/tests/integration/run_site_terrain_integration.gd @@ -0,0 +1,357 @@ +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const BathymetricTerrainActor = preload( + "res://game/world/sites/bathymetric_terrain_actor.gd" +) +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const DiveSite = preload("res://game/world/sites/dive_site.gd") +const SeabedDetailTextures = preload("res://game/world/sites/seabed_detail_textures.gd") +const SeabedFloraActor = preload("res://game/world/sites/seabed_flora_actor.gd") +const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd") +const ReferenceBathymetryAcquisition = preload( + "res://game/world/sonar/reference_bathymetry_acquisition.gd" +) +const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn") + +const ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres" +const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres" +const GROOVE_PROBE_OFFSET_M := 25.0 +const TEST_COUNT := 52 + +var _failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _test_actor_matches_field() + await _test_dive_site_scene() + await _test_zone1_site() + await _test_showcase_site() + + var exit_code := 0 + if _failures.is_empty(): + print("Site terrain integration: %d passed, 0 failed." % TEST_COUNT) + else: + for failure: String in _failures: + push_error(failure) + print("Site terrain integration: %d failed." % _failures.size()) + exit_code = 1 + await process_frame + quit(exit_code) + + +func _asymmetric_field() -> TerrainHeightField: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 81 + recipe.rows = 65 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -80.0 + recipe.origin_z_m = -80.0 + recipe.floor_elevation_m = -90.0 + recipe.primitives = [ + SiteTerrainBaker.SlopedPlane.new(40.0, 0.0, 0.0, 0.0, 0.0), + # Tall bump off the diagonal on a non-square grid: any front/back flip or + # row/column transposition shows up. + SiteTerrainBaker.Blob.new(Vector2(-60.0, 20.0), 10.0, 10.0, 18.0), + ] + return TerrainHeightField.from_data(SiteTerrainBaker.bake(recipe)) + + +func _test_actor_matches_field() -> void: + var field := _asymmetric_field() + _expect(field != null, "asymmetric test field bakes") + if field == null: + return + var actor := BathymetricTerrainActor.new() + root.add_child(actor) + _expect(actor.configure(field).is_empty(), "actor accepts a valid field") + await physics_frame + await physics_frame + + _expect(actor.tile_count() == 2, "80 x 64 cells split into 2 x 1 tiles of at most 64 cells") + var material := actor.terrain_material() + _expect( + material != null + and material.get_shader_parameter(&"sand_normal_map") is ImageTexture + and material.get_shader_parameter(&"rock_normal_map") is ImageTexture + and material.get_shader_parameter(&"rock_grain_map") is ImageTexture, + "terrain material carries the three procedural detail maps" + ) + var first_textures := SeabedDetailTextures.build(7) + var second_textures := SeabedDetailTextures.build(7) + _expect( + first_textures.rock_normal.get_image().get_data() == second_textures.rock_normal.get_image().get_data() + and first_textures.sand_normal.get_image().get_data() == second_textures.sand_normal.get_image().get_data(), + "detail maps are deterministic for a seed" + ) + _expect( + first_textures.rock_normal.get_width() == SeabedDetailTextures.TEXTURE_SIZE + and first_textures.rock_normal.get_image().has_mipmaps(), + "detail maps are tile-sized with mipmaps" + ) + var sand_image := first_textures.sand_normal.get_image() + var left := sand_image.get_pixel(0, 100) + var right := sand_image.get_pixel(SeabedDetailTextures.TEXTURE_SIZE - 1, 100) + _expect(absf(left.r - right.r) < 0.25 and absf(left.g - right.g) < 0.25, "sand ripple map wraps across its seam") + var first_tile := actor.get_node("Tiles").get_child(0) as MeshInstance3D + var arrays: Array = first_tile.mesh.surface_get_arrays(0) + var tile_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var tile_indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + var first_face := Plane( + tile_vertices[tile_indices[0]], + tile_vertices[tile_indices[1]], + tile_vertices[tile_indices[2]] + ) + _expect(first_face.normal.y > 0.0, "tile triangles are wound clockwise from above (front face up)") + # The first tile holds the steep bump, so it is rendered subdivided; the second tile + # (cells 64..80) is flat and stays on the grid. + var subdivision := actor.tile_subdivision(0) + _expect(subdivision == BathymetricTerrainActor.STEEP_TILE_SUBDIVISION, "the steep tile is subdivided (%d)" % subdivision) + _expect(actor.tile_subdivision(1) == 1, "the flat tile keeps one vertex per cell") + # The first tile spans cells 0..64 on both axes: vertex index = (row * n) * (64 n + 1) + column * n. + var points_per_row := 64 * subdivision + 1 + var worst_vertex_error := 0.0 + for cell: Vector2i in [Vector2i(0, 0), Vector2i(10, 50), Vector2i(64, 64)]: + var vertex := first_tile.global_transform * tile_vertices[(cell.y * subdivision) * points_per_row + cell.x * subdivision] + worst_vertex_error = maxf(worst_vertex_error, vertex.distance_to(field.cell_world_position(cell.x, cell.y))) + _expect(worst_vertex_error < 0.01, "tile vertices on grid nodes sit on their field cells (worst %.4f m)" % worst_vertex_error) + var mid_vertex := first_tile.global_transform * tile_vertices[(50 * subdivision + 1) * points_per_row + 10 * subdivision + 1] + var mid_expected := field.smooth_elevation_at(mid_vertex.x, mid_vertex.z) + _expect(absf(mid_vertex.y - mid_expected) < 0.01, "subdivided vertices sit on the smooth surface (%.3f vs %.3f)" % [mid_vertex.y, mid_expected]) + var space := actor.get_world_3d().direct_space_state + var worst_error := 0.0 + for cell: Vector2i in [ + Vector2i(0, 0), Vector2i(80, 0), Vector2i(0, 64), Vector2i(80, 64), + Vector2i(10, 50), Vector2i(40, 40), Vector2i(64, 20), + ]: + var expected := field.cell_world_position(cell.x, cell.y) + var query := PhysicsRayQueryParameters3D.create( + Vector3(expected.x, 50.0, expected.z), + Vector3(expected.x, -200.0, expected.z) + ) + var hit := space.intersect_ray(query) + if hit.is_empty(): + _failures.append("collision ray at cell %s hits nothing" % cell) + continue + worst_error = maxf(worst_error, absf(float(hit["position"].y) - expected.y)) + _expect(worst_error < 0.05, "collision height equals the field height at vertices (worst %.3f m)" % worst_error) + + var bump := _collision_height(space, -60.0, 20.0) + var north_east := _collision_height(space, 60.0, -60.0) + _expect(bump > -30.0, "south-west bump is where the field puts it (collision)") + _expect(north_east < -39.0, "north-east stays flat (collision)") + _expect(absf(bump - field.elevation_at(-60.0, 20.0)) < 0.05, "bump height matches the field") + var mid_cell := _collision_height(space, -59.0, 21.0) + _expect(absf(mid_cell - field.elevation_at(-59.0, 21.0)) < 0.3, "collision between vertices follows the field (%.2f m)" % absf(mid_cell - field.elevation_at(-59.0, 21.0))) + + _expect(actor.sonar_material_at(Vector3(-52.0, -30.0, 20.0)) == SonarMaterial.Value.ROCK, "steep flank reports rock") + _expect(actor.sonar_material_at(Vector3(40.0, -40.0, 40.0)) == SonarMaterial.Value.SAND, "flat plain reports sand") + _expect(is_equal_approx(actor.sonar_reflectivity_at(Vector3(40.0, -40.0, 40.0)), 0.45), "sand reflectivity from the data") + + _expect(actor.configure(field).is_empty() and actor.tile_count() == 2, "reconfiguring rebuilds without duplicating tiles") + _expect(not actor.configure(null).is_empty(), "null field is refused") + actor.queue_free() + + +func _collision_height(space: PhysicsDirectSpaceState3D, x: float, z: float) -> float: + var hit := space.intersect_ray( + PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z)) + ) + if hit.is_empty(): + return INF + return float(hit["position"].y) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) + + +func _test_dive_site_scene() -> void: + var definition := load("res://content/sites/vehicle_test.tres") as DiveSiteDefinition + _expect(definition != null, "calibration site definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "calibration site definition is valid") + var started_usec := Time.get_ticks_usec() + var site := DIVE_SITE_SCENE.instantiate() as DiveSite + root.add_child(site) + _expect(site.configure(definition).is_empty(), "dive site scene accepts the definition") + var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0 + _expect(configure_ms < 3000.0, "site configures under the 3 s load budget (%.0f ms)" % configure_ms) + await physics_frame + await physics_frame + _expect(site.terrain().tile_count() > 0, "site builds its terrain tiles") + var flora := site.flora() + var grass_count := flora.instance_count(SeabedFloraActor.Kind.SEAGRASS) + var fan_count := flora.instance_count(SeabedFloraActor.Kind.SEA_FAN) + _expect(grass_count > 0 and fan_count > 0, "calibration site grows seagrass and sea fans (%d, %d)" % [grass_count, fan_count]) + var field_for_flora := definition.height_field() + var grass_on_sand := true + var grass_in_band := true + for transform in flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS): + var at := transform.origin + grass_on_sand = grass_on_sand and field_for_flora.material_at(at.x, at.z) == SonarMaterial.Value.SAND + grass_in_band = grass_in_band and -at.y < SeabedFloraActor.GRASS_DEPTH_NONE_M and absf(at.y - field_for_flora.elevation_at(at.x, at.z)) < 0.1 + _expect(grass_on_sand, "seagrass stands on sand") + _expect(grass_in_band, "seagrass stays in the lit band and on the seabed") + var fans_on_rock := true + for transform in flora.sample_transforms(SeabedFloraActor.Kind.SEA_FAN): + var at := transform.origin + fans_on_rock = fans_on_rock and field_for_flora.material_at(at.x, at.z) == SonarMaterial.Value.ROCK + _expect(fans_on_rock, "sea fans stand on rock") + var first_pass := flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS) + flora.configure(field_for_flora, hash(String(definition.site_id))) + _expect(first_pass == flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS), "flora placement is deterministic for a seed") + var snow := site.get_node_or_null("MarineSnow") as GPUParticles3D + _expect(snow != null and snow.emitting and snow.amount > 0 and not snow.local_coords, "site carries world-space marine snow around the viewer") + var water_column := site.get_node_or_null("WaterColumn") as FogVolume + _expect( + water_column != null and water_column.material is ShaderMaterial + and (water_column.material as ShaderMaterial).shader != null, + "site carries a water-column fog volume with its depth shader" + ) + _expect(site.placeholder_count() == 2, "calibration target and beacon are placed") + var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D + _expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body") + _expect(site.spawn_transform().origin.is_equal_approx(Vector3(0.0, -12.0, 12.0)), "spawn comes from the definition") + + var sensor := Transform3D(Basis(Vector3.UP, 0.0), Vector3(0.0, -12.0, 10.0)) + sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z) + var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d()) + var request := SonarCaptureRequest.new( + &"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31 + ) + var result = acquisition.capture(request) + _expect(result.accepted, "reference acquisition scans the baked terrain") + var materials: Dictionary = {} + for sample in result.samples: + materials[sample.material] = true + _expect(materials.has(SonarMaterial.Value.SAND) and materials.has(SonarMaterial.Value.ROCK), "acquisition reports per-cell sand and rock") + _expect(materials.has(SonarMaterial.Value.METAL), "acquisition still reports the metal placeholder") + site.queue_free() + + +func _test_zone1_site() -> void: + # Let the calibration site queued for deletion leave the physics space first. + await process_frame + var definition := load(ZONE1_SITE_PATH) as DiveSiteDefinition + if definition == null: + _failures.append("zone 1 site definition loads") + return + var started_usec := Time.get_ticks_usec() + var site := DIVE_SITE_SCENE.instantiate() as DiveSite + root.add_child(site) + _expect(site.configure(definition).is_empty(), "dive site scene accepts the Banc du Vigilant") + var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0 + _expect(configure_ms < 3000.0, "zone 1 configures under the 3 s load budget (%.0f ms)" % configure_ms) + await physics_frame + await physics_frame + var space := site.get_world_3d().direct_space_state + var terrain := site.terrain() + + var hull := definition.placements_of_kind(SitePlacement.Kind.WRECK_TARGET)[0] + var hull_body := site.get_node_or_null("%%Placements/%s" % hull.placement_id) as StaticBody3D + var excluded: Array[RID] = [] + if hull_body != null: + excluded.append(hull_body.get_rid()) + var under_hull := _terrain_height(space, terrain, hull.position_m.x, hull.position_m.z, excluded) + _expect(under_hull < hull.position_m.y, "a ray at the target hits the seabed under the hull centre (%.1f m)" % under_hull) + + var entrance := definition.placement(&"saignee_entrance").position_m + var groove_floor := _terrain_height(space, terrain, entrance.x, entrance.z, []) + var beside_groove := _terrain_height(space, terrain, entrance.x + GROOVE_PROBE_OFFSET_M, entrance.z, []) + _expect(groove_floor < beside_groove, "la Saignee is a trench at its entrance (%.1f m vs %.1f m)" % [groove_floor, beside_groove]) + + var sensor := definition.spawn_transform() + sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z) + var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d()) + var request := SonarCaptureRequest.new( + &"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31 + ) + var result = acquisition.capture(request) + var has_sand := false + for sample in result.samples: + has_sand = has_sand or sample.material == SonarMaterial.Value.SAND + _expect(result.accepted and has_sand, "a reference scan from the zone 1 spawn reads the sand plateau") + + # The calibration site lies below 40 m, so coral heads are checked on the patch reef here. + var flora := site.flora() + var coral_count := flora.instance_count(SeabedFloraActor.Kind.CORAL_HEAD) + _expect(coral_count > 0, "the Banc du Vigilant grows coral heads on its patch reef (%d)" % coral_count) + var field_for_corals := definition.height_field() + var corals_on_gentle_rock := true + for transform in flora.sample_transforms(SeabedFloraActor.Kind.CORAL_HEAD): + var at := transform.origin + var column := int(roundf((at.x - field_for_corals.origin_m().x) / field_for_corals.cell_size_m())) + var row := int(roundf((at.z - field_for_corals.origin_m().y) / field_for_corals.cell_size_m())) + corals_on_gentle_rock = ( + corals_on_gentle_rock + and field_for_corals.material_at(at.x, at.z) == SonarMaterial.Value.ROCK + and field_for_corals.normal_at_cell(column, row).y >= SeabedFloraActor.CORAL_SLOPE_COSINE - 0.05 + and -at.y >= SeabedFloraActor.CORAL_DEPTH_MIN_M and -at.y <= SeabedFloraActor.CORAL_DEPTH_MAX_M + ) + _expect(corals_on_gentle_rock, "coral heads sit on gentle rock in the lit band") + site.queue_free() + + +func _test_showcase_site() -> void: + await process_frame + var definition := load(SHOWCASE_SITE_PATH) as DiveSiteDefinition + _expect(definition != null, "showcase site definition loads") + if definition == null: + return + var started_usec := Time.get_ticks_usec() + var site := DIVE_SITE_SCENE.instantiate() as DiveSite + root.add_child(site) + _expect(site.configure(definition).is_empty(), "dive site scene accepts the Jardin des Formes") + var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0 + _expect(configure_ms < 3000.0, "showcase configures under the 3 s load budget (%.0f ms)" % configure_ms) + await physics_frame + await physics_frame + var space := site.get_world_3d().direct_space_state + var terrain := site.terrain() + var canyon_floor := _terrain_height(space, terrain, -130.0, -135.0, []) + var mesa_top := _terrain_height(space, terrain, -80.0, -90.0, []) + _expect(mesa_top - canyon_floor >= 18.0, "collision rays find the canyon under the mesa (%.1f m vs %.1f m)" % [canyon_floor, mesa_top]) + + # Sensor above the canyon looking along its second leg (the acquisition probes along + # local +Z): rock walls and sand floor. + var sensor := Transform3D(Basis.IDENTITY, Vector3(-130.0, -15.0, -135.0)) + sensor.basis = Basis.looking_at(-Vector3(40.0, 0.0, -30.0).normalized(), Vector3.UP) + var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d()) + var request := SonarCaptureRequest.new( + &"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31 + ) + var result = acquisition.capture(request) + var materials: Dictionary = {} + for sample in result.samples: + materials[sample.material] = true + _expect( + result.accepted and materials.has(SonarMaterial.Value.ROCK) and materials.has(SonarMaterial.Value.SAND), + "a scan along the canyon reads rock walls and a sand floor" + ) + site.queue_free() + + +## Seabed height under (x, z), or NAN (every comparison fails) when the ray misses the terrain actor. +func _terrain_height( + space: PhysicsDirectSpaceState3D, + terrain: Node, + x: float, + z: float, + excluded: Array[RID] +) -> float: + var query := PhysicsRayQueryParameters3D.create(Vector3(x, 50.0, z), Vector3(x, -200.0, z)) + query.exclude = excluded + var hit := space.intersect_ray(query) + if hit.is_empty() or hit["collider"] != terrain: + return NAN + return float(hit["position"].y) diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd index adbf33b..e1a5dae 100644 --- a/tests/integration/run_vertical_slice_foundation.gd +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -5,6 +5,7 @@ const VerticalSliceRoot = preload( ) const MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd") const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd") +const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd") const CockpitDetailing = preload( "res://game/presentation/cockpit/cockpit_detailing.gd" ) @@ -19,7 +20,7 @@ const ProductionInputAdapter = preload( "res://game/infrastructure/input/production_input_adapter.gd" ) -const TEST_COUNT := 78 +const TEST_COUNT := 83 var _failures: Array[String] = [] @@ -75,6 +76,7 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: 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 + composition.select_dive_site("res://content/sites/vehicle_test.tres") new_game_button.pressed.emit() await process_frame await physics_frame @@ -110,6 +112,22 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void: _expect(sonar_service != null, "production sonar service is assembled") _expect(sonar_display != null, "shared sonar presentation is assembled") _expect(composition.latest_update() != null, "dive session starts with a typed update") + _expect(composition.dive_site() != null, "composition instantiates the selected dive site") + _expect( + composition.dive_site() != null + and composition.dive_site().definition().site_id == &"vehicle_test", + "selected site definition drives the world" + ) + _expect( + actor != null and actor.global_position.is_equal_approx(Vector3(0.0, -12.0, 12.0)), + "submarine spawns at the site spawn point" + ) + _expect( + composition.latest_bounds_status() != null + and composition.latest_bounds_status().state == PlayableBoundsStatus.State.INSIDE, + "spawn is inside the playable bounds" + ) + _expect(hud != null and not hud.bounds_warning_visible(), "no bounds warning inside the zone") if ( actor == null or camera == null diff --git a/tests/run_load_checks.gd b/tests/run_load_checks.gd index 26f868c..672bd65 100644 --- a/tests/run_load_checks.gd +++ b/tests/run_load_checks.gd @@ -4,6 +4,7 @@ const SEARCH_ROOTS := [ "res://src", "res://game", "res://content", + "res://tools", "res://scenes", "res://tests", ] diff --git a/tests/unit/run_site_application_tests.gd b/tests/unit/run_site_application_tests.gd new file mode 100644 index 0000000..b6a31d2 --- /dev/null +++ b/tests/unit/run_site_application_tests.gd @@ -0,0 +1,42 @@ +extends SceneTree + +const PlayableBoundsRule = preload("res://game/application/sites/playable_bounds_rule.gd") +const PlayableBoundsStatus = preload("res://game/application/sites/playable_bounds_status.gd") + +const TEST_COUNT := 9 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_bounds_rule() + + if _failures.is_empty(): + print("Site application unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site application unit tests: %d failed." % _failures.size()) + quit(1) + + +func _test_bounds_rule() -> void: + var rule := PlayableBoundsRule.new(Rect2(-300.0, -300.0, 600.0, 600.0), 40.0) + var centre := rule.evaluate(Vector3(0.0, -20.0, 0.0)) + _expect(centre.state == PlayableBoundsStatus.State.INSIDE, "centre is inside") + _expect(is_equal_approx(centre.distance_to_edge_m, 300.0), "distance to the nearest edge") + _expect(not centre.requires_warning(), "inside needs no warning") + var near := rule.evaluate(Vector3(275.0, -20.0, 10.0)) + _expect(near.state == PlayableBoundsStatus.State.NEAR_EDGE, "within the margin is near the edge") + _expect(is_equal_approx(near.distance_to_edge_m, 25.0), "near edge distance is measured to the east edge") + _expect(near.return_direction.is_equal_approx(Vector3.LEFT), "return direction points back toward the centre") + var outside := rule.evaluate(Vector3(0.0, -20.0, -340.0)) + _expect(outside.state == PlayableBoundsStatus.State.OUTSIDE, "beyond the edge is outside") + _expect(is_equal_approx(outside.distance_to_edge_m, -40.0), "outside distance is negative") + _expect(outside.return_direction.is_equal_approx(Vector3.BACK), "outside north returns south") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd new file mode 100644 index 0000000..f61b28b --- /dev/null +++ b/tests/unit/run_site_baker_tests.gd @@ -0,0 +1,331 @@ +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const VehicleTestRecipe = preload("res://tools/sites/vehicle_test_terrain_recipe.gd") +const Zone1Recipe = preload("res://tools/sites/zone1_vigilant_recipe.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") + +const ZONE1_TERRAIN_PATH := "res://content/sites/zone1_vigilant_terrain.res" + +const TEST_COUNT := 53 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_plane_and_blob() + _test_materials_and_determinism() + _test_modifiers_and_primitive_materials() + _test_zone1_artefact_is_stable() + _test_canyon() + _test_escarpment() + _test_outcrop() + _test_boulder_field() + _test_strata() + _test_slope_roughness() + _test_irregular_strata() + _test_angular_boulders() + + if _failures.is_empty(): + print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site baker unit tests: %d failed." % _failures.size()) + quit(1) + + +func _small_recipe() -> SiteTerrainBaker.Recipe: + var recipe := SiteTerrainBaker.Recipe.new() + recipe.columns = 21 + recipe.rows = 21 + recipe.cell_size_m = 2.0 + recipe.origin_x_m = -20.0 + recipe.origin_z_m = -20.0 + recipe.floor_elevation_m = -80.0 + recipe.rock_slope_degrees = 20.0 + return recipe + + +func _test_plane_and_blob() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.1, 0.0, 0.0)) + var data := SiteTerrainBaker.bake(recipe) + _expect(data != null and data.validation_error().is_empty(), "plane recipe bakes valid data") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -30.0), "plane depth at the reference point") + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 20)], -28.0), "plane slopes along +Z") + _expect(data.materials[data.index_of(10, 10)] == SonarMaterial.Value.SAND, "gentle plane is sand") + + recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(0.0, 0.0), 4.0, 4.0, 10.0)) + data = SiteTerrainBaker.bake(recipe) + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -20.0), "blob adds its amplitude at the centre") + _expect(data.materials[data.index_of(12, 10)] == SonarMaterial.Value.ROCK, "steep blob flank is rock") + + +func _test_materials_and_determinism() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + recipe.primitives.append(SiteTerrainBaker.Roughness.new(1933, 0.05, 0.5)) + recipe.material_patches.append( + SiteTerrainBaker.MaterialPatch.new(Vector2(10.0, 10.0), 3.0, SonarMaterial.Value.ROCK) + ) + var first := SiteTerrainBaker.bake(recipe) + var second := SiteTerrainBaker.bake(recipe) + _expect(first.elevations_m == second.elevations_m, "same recipe bakes identical elevations") + _expect(first.materials == second.materials, "same recipe bakes identical materials") + _expect(first.materials[first.index_of(15, 15)] == SonarMaterial.Value.ROCK, "material patch overrides the slope rule") + var calibration := SiteTerrainBaker.bake(VehicleTestRecipe.recipe()) + _expect(calibration != null and calibration.validation_error().is_empty(), "calibration recipe bakes valid data") + + +## Test doubles for the extension points: a modifier that lifts the field by one metre +## and a primitive that forces rock on a flat plane. +class Lift extends SiteTerrainBaker.Modifier: + func modify(_x_m: float, _z_m: float, elevation_m: float, _gradient: Vector2) -> float: + return elevation_m + 1.0 + + +class GradientProbe extends SiteTerrainBaker.Modifier: + var gradient_at_centre := Vector2.ZERO + + func modify(x_m: float, z_m: float, elevation_m: float, gradient: Vector2) -> float: + if is_zero_approx(x_m) and is_zero_approx(z_m): + gradient_at_centre = gradient + return elevation_m + + +class RockDisc extends SiteTerrainBaker.Primitive: + func material_at(x_m: float, z_m: float) -> int: + if Vector2(x_m, z_m).length() <= 5.0: + return SonarMaterial.Value.ROCK + return SiteTerrainBaker.NO_MATERIAL + + +func _test_modifiers_and_primitive_materials() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.2, 0.0, 0.0, 0.0)) + var probe := GradientProbe.new() + recipe.modifiers.append(probe) + recipe.modifiers.append(Lift.new()) + var data := SiteTerrainBaker.bake(recipe) + _expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -29.0), "modifier runs after the primitive sum") + _expect(probe.gradient_at_centre.is_equal_approx(Vector2(0.2, 0.0)), "modifier receives the local gradient (%s)" % probe.gradient_at_centre) + + var flat := _small_recipe() + flat.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + flat.primitives.append(RockDisc.new()) + var flat_data := SiteTerrainBaker.bake(flat) + _expect(flat_data.materials[flat_data.index_of(10, 10)] == SonarMaterial.Value.ROCK, "primitive material wins over the slope rule") + _expect(flat_data.materials[flat_data.index_of(0, 0)] == SonarMaterial.Value.SAND, "primitive material is local") + + +func _test_canyon() -> void: + var canyon := SiteTerrainBaker.Canyon.new( + PackedVector2Array([Vector2(-20.0, 0.0), Vector2(0.0, 0.0), Vector2(0.0, 20.0)]), + 4.0, 10.0, 1.0, 0, 0.0, SonarMaterial.Value.SAND + ) + _expect(is_equal_approx(canyon.elevation_delta(-10.0, 0.0), -10.0), "canyon floor sits at depth on the first leg") + _expect(canyon.material_at(-10.0, 0.0) == SonarMaterial.Value.SAND, "canyon imposes its floor material on the flat floor") + _expect(canyon.material_at(-10.0, 4.0) == SiteTerrainBaker.NO_MATERIAL, "canyon leaves its walls to the slope rule") + _expect(is_equal_approx(canyon.elevation_delta(1.5, 10.0), -10.0), "canyon floor is flat across its width on the second leg") + _expect(is_equal_approx(canyon.elevation_delta(-10.0, 6.0), 0.0), "canyon stops past half width plus softness") + _expect(is_equal_approx(canyon.elevation_delta(15.0, -15.0), 0.0), "canyon is zero far from the path") + _expect(is_equal_approx(canyon.elevation_delta(0.0, 20.0), 0.0), "canyon closes at its last vertex") + _expect(canyon.elevation_delta(-10.0, 4.0) < -4.0 and canyon.elevation_delta(-10.0, 4.0) > -6.0, "canyon wall is half way down at half width") + var rough := SiteTerrainBaker.Canyon.new( + PackedVector2Array([Vector2(-200.0, 0.0), Vector2(200.0, 0.0)]), 8.0, 10.0, 2.0, 7, 3.0, SiteTerrainBaker.NO_MATERIAL + ) + var wall_values: Dictionary = {} + for x in range(-100, 101, 10): + wall_values[snappedf(rough.elevation_delta(float(x), 8.0), 0.01)] = true + _expect(wall_values.size() > 3, "outline noise makes the canyon wall irregular along the path") + + +func _test_escarpment() -> void: + var cliff := SiteTerrainBaker.Escarpment.new( + PackedVector2Array([Vector2(-50.0, 0.0), Vector2(50.0, 0.0)]), 10.0, 2.0, 0.5, 0, 0.0 + ) + _expect(is_equal_approx(cliff.elevation_delta(0.0, -10.0), 0.0), "escarpment leaves the left (north) side alone") + _expect(is_equal_approx(cliff.elevation_delta(0.0, 2.0), -10.0), "escarpment drops by drop_m on the right (south) side") + _expect(is_equal_approx(cliff.elevation_delta(0.0, 12.0), -15.0), "escarpment keeps falling at slope_beyond") + + +func _test_outcrop() -> void: + var mesa := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 10.0, 6.0, 4.0, 0, 0.0, true) + _expect(is_equal_approx(mesa.elevation_delta(0.0, 0.0), 6.0), "outcrop top is flat at height_m") + _expect(is_equal_approx(mesa.elevation_delta(0.0, 8.0), 3.0), "outcrop flank is half way down in the middle of the wall") + _expect(is_equal_approx(mesa.elevation_delta(0.0, 25.0), 0.0), "outcrop is zero past its outline") + _expect(mesa.material_at(0.0, 0.0) == SonarMaterial.Value.ROCK, "rock_top makes the flat summit rock") + _expect(mesa.material_at(0.0, 25.0) == SiteTerrainBaker.NO_MATERIAL, "outcrop leaves the surroundings to the slope rule") + var sand_top := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 10.0, 6.0, 4.0, 0, 0.0, false) + _expect(sand_top.material_at(0.0, 0.0) == SiteTerrainBaker.NO_MATERIAL, "without rock_top the summit follows the slope rule") + var pit := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 20.0, -5.0, 3.0, 0, 0.0, false) + _expect(is_equal_approx(pit.elevation_delta(0.0, 0.0), -5.0), "negative height digs a flat-bottomed pit") + + +func _test_boulder_field() -> void: + var first := SiteTerrainBaker.BoulderField.new(Vector2(50.0, -50.0), 30.0, 11, 20, 2.0, 4.0, 0.8) + var second := SiteTerrainBaker.BoulderField.new(Vector2(50.0, -50.0), 30.0, 11, 20, 2.0, 4.0, 0.8) + _expect(first.boulders() == second.boulders(), "same seed scatters the same boulders") + var all_inside := true + for boulder in first.boulders(): + all_inside = all_inside and Vector2(boulder.x, boulder.y).distance_to(Vector2(50.0, -50.0)) <= 30.0 + _expect(all_inside and first.boulders().size() == 20, "boulders stay inside the field disc") + var top := first.boulders()[0] + _expect(first.elevation_delta(top.x, top.y) >= top.z * 0.8 - 0.001, "a boulder centre is at least its own height") + _expect(first.material_at(top.x, top.y) == SonarMaterial.Value.ROCK and first.material_at(200.0, 200.0) == SiteTerrainBaker.NO_MATERIAL, "boulders are rock, the sand around is not forced") + var stacked := SiteTerrainBaker.BoulderField.new(Vector2.ZERO, 0.0, 3, 5, 2.0, 4.0, 1.0) + _expect(stacked.elevation_delta(0.0, 0.0) <= 4.0 + 0.001, "overlapping boulders combine by maximum, not by sum") + + +func _test_strata() -> void: + # A 45 degree plane sampled every 0.5 m: four cells per 2 m layer. + var recipe := _small_recipe() + recipe.columns = 41 + recipe.rows = 41 + recipe.cell_size_m = 0.5 + recipe.origin_x_m = -10.0 + recipe.origin_z_m = -10.0 + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 1.0, 0.0, 0.0, 0.0)) + var plain := SiteTerrainBaker.bake(recipe) + recipe.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2())) + var terraced := SiteTerrainBaker.bake(recipe) + var flat_cells := 0 + var riser_cells := 0 + for column in range(5, 35): + var rise := terraced.elevations_m[terraced.index_of(column + 1, 20)] - terraced.elevations_m[terraced.index_of(column, 20)] + if rise < 0.1: + flat_cells += 1 + elif rise > 0.9: + riser_cells += 1 + _expect(flat_cells >= 8 and riser_cells >= 4, "strata turn a steep slope into ledges and risers (%d flat, %d risers)" % [flat_cells, riser_cells]) + _expect(is_equal_approx(terraced.elevations_m[terraced.index_of(20, 20)], plain.elevations_m[plain.index_of(20, 20)]), "strata keep the layer boundaries where they were") + + var flat := _small_recipe() + flat.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.3, 0.0, 0.0, 0.0, 0.0)) + flat.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2())) + var flat_data := SiteTerrainBaker.bake(flat) + _expect(is_equal_approx(flat_data.elevations_m[flat_data.index_of(10, 10)], -30.3), "strata leave flat ground alone") + + var identity := _small_recipe() + identity.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 1.0, 0.0, 0.0, 0.0)) + var before := SiteTerrainBaker.bake(identity) + identity.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.0, 20.0, 35.0, Rect2())) + var after := SiteTerrainBaker.bake(identity) + _expect(before.elevations_m == after.elevations_m, "sharpness 0 is the identity") + + # Depth 30.7 keeps the plane off the layer boundaries, where quantisation is the identity. + var banded := _small_recipe() + banded.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.7, 1.0, 0.0, 0.0, 0.0)) + var untouched := SiteTerrainBaker.bake(banded) + banded.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2(-20.0, 0.0, 40.0, 20.0))) + var half := SiteTerrainBaker.bake(banded) + var north_same := half.elevations_m[half.index_of(13, 5)] == untouched.elevations_m[untouched.index_of(13, 5)] + var south_changed := half.elevations_m[half.index_of(13, 15)] != untouched.elevations_m[untouched.index_of(13, 15)] + _expect(north_same and south_changed, "strata stay inside their region") + + +func _test_irregular_strata() -> void: + # 45 degree plane at 0.5 m per cell, beds of 2 m with dip, warp and thickness variation. + var recipe := _small_recipe() + recipe.columns = 81 + recipe.rows = 81 + recipe.cell_size_m = 0.5 + recipe.origin_x_m = -20.0 + recipe.origin_z_m = -20.0 + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.3, 1.0, 0.0, 0.0, 0.0)) + var regular_recipe := _small_recipe() + regular_recipe.columns = 81 + regular_recipe.rows = 81 + regular_recipe.cell_size_m = 0.5 + regular_recipe.origin_x_m = -20.0 + regular_recipe.origin_z_m = -20.0 + regular_recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.3, 1.0, 0.0, 0.0, 0.0)) + regular_recipe.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2())) + var regular := SiteTerrainBaker.bake(regular_recipe) + recipe.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2(), Vector2(0.0, 0.05), 11, 1.0, 0.6)) + var irregular := SiteTerrainBaker.bake(recipe) + var monotonic := true + var ledges := 0 + var on_ledge := false + var off_grid_ledges := 0 + for column in range(10, 70): + var here := irregular.elevations_m[irregular.index_of(column, 40)] + var next := irregular.elevations_m[irregular.index_of(column + 1, 40)] + # Ledges follow the bent beds, so a ledge may tilt back a little; the wall + # itself must never step down. + monotonic = monotonic and next >= here - 0.3 + if next - here < 0.1: + if not on_ledge: + ledges += 1 + var nearest_regular := roundf(here / 2.0) * 2.0 + if absf(here - nearest_regular) > 0.2: + off_grid_ledges += 1 + on_ledge = true + else: + on_ledge = false + _expect(monotonic, "irregular strata never cut a back-step deeper than 0.3 m") + _expect(ledges >= 4, "irregular strata still cut distinct ledges (%d)" % ledges) + _expect(off_grid_ledges >= 2, "irregular bed boundaries leave the regular 2 m grid (%d of %d)" % [off_grid_ledges, ledges]) + var north := irregular.elevations_m[irregular.index_of(40, 10)] + var south := irregular.elevations_m[irregular.index_of(40, 70)] + var regular_north := regular.elevations_m[regular.index_of(40, 10)] + var regular_south := regular.elevations_m[regular.index_of(40, 70)] + _expect(absf((south - north) - (regular_south - regular_north)) > 0.3, "dip and warp move the ledges along the wall") + + +func _test_angular_boulders() -> void: + var round := SiteTerrainBaker.BoulderField.new(Vector2.ZERO, 0.0, 21, 1, 5.0, 5.0, 1.0, 0.0) + var angular := SiteTerrainBaker.BoulderField.new(Vector2.ZERO, 0.0, 21, 1, 5.0, 5.0, 1.0, 1.0) + _expect(round.boulders() == angular.boulders(), "angularity changes the shape, not the placement") + var round_ring := 0.0 + var angular_ring := 0.0 + var bounded := true + for step in 16: + var angle := TAU * float(step) / 16.0 + round_ring += round.elevation_delta(1.5 * cos(angle), 1.5 * sin(angle)) + var height := angular.elevation_delta(1.5 * cos(angle), 1.5 * sin(angle)) + angular_ring += height + bounded = bounded and height <= 5.0 * (1.0 + SiteTerrainBaker.BoulderField.MAXIMUM_TILT) + 0.001 + _expect(angular_ring > round_ring, "angular blocks have flatter tops than half-ellipsoids") + _expect(bounded, "angular block height stays within the tilt bound") + + +func _test_slope_roughness() -> void: + var recipe := _small_recipe() + recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0)) + recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(10.0, 10.0), 5.0, 5.0, 12.0)) + var smooth := SiteTerrainBaker.bake(recipe) + recipe.modifiers.append(SiteTerrainBaker.SlopeRoughness.new(5, 0.2, 1.0, 20.0, 40.0)) + var rough := SiteTerrainBaker.bake(recipe) + _expect(rough.elevations_m[rough.index_of(0, 0)] == smooth.elevations_m[smooth.index_of(0, 0)], "slope roughness leaves flat sand untouched") + var flank_changed := false + for column in range(13, 18): + flank_changed = flank_changed or rough.elevations_m[rough.index_of(column, 15)] != smooth.elevations_m[smooth.index_of(column, 15)] + _expect(flank_changed, "slope roughness roughens the steep flank") + var octaves := _small_recipe() + octaves.primitives.append(SiteTerrainBaker.Roughness.new(7, 0.01, 2.0, 5, FastNoiseLite.FRACTAL_RIDGED)) + var ridged := SiteTerrainBaker.bake(octaves) + var within := true + for elevation: float in ridged.elevations_m: + within = within and absf(elevation) <= 2.0 + 0.001 + _expect(within, "roughness amplitude bounds the ridged multi-octave noise") + + +func _test_zone1_artefact_is_stable() -> void: + var versioned := load(ZONE1_TERRAIN_PATH) as SiteTerrainData + _expect(versioned != null, "versioned zone 1 terrain loads") + if versioned == null: + return + var baked := SiteTerrainBaker.bake(Zone1Recipe.recipe()) + _expect( + baked.elevations_m == versioned.elevations_m and baked.materials == versioned.materials, + "zone 1 recipe still bakes the versioned artefact byte for byte" + ) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tests/unit/run_site_domain_tests.gd b/tests/unit/run_site_domain_tests.gd new file mode 100644 index 0000000..2a2abc3 --- /dev/null +++ b/tests/unit/run_site_domain_tests.gd @@ -0,0 +1,247 @@ +extends SceneTree + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") + +const TEST_COUNT := 46 + +var _failures: Array[String] = [] + + +func _init() -> void: + _test_terrain_data_validation() + _test_height_field_sampling() + _test_site_definition_validation() + + if _failures.is_empty(): + print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT) + quit(0) + else: + for failure: String in _failures: + push_error(failure) + print("Site domain unit tests: %d failed." % _failures.size()) + quit(1) + + +func _make_terrain(columns: int, rows: int) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = columns + data.rows = rows + data.cell_size_m = 2.0 + data.origin_x_m = -10.0 + data.origin_z_m = 100.0 + # Packed arrays are passed by value in GDScript: build locally, then assign. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(columns * rows) + materials.resize(columns * rows) + for row in rows: + for column in columns: + var index := row * columns + column + # Plane sloping down toward +Z (south): -20 at row 0, -2 m per row. + elevations[index] = -20.0 - 2.0 * float(row) + materials[index] = ( + SonarMaterial.Value.ROCK if column == 0 else SonarMaterial.Value.SAND + ) + data.elevations_m = elevations + data.materials = materials + return data + + +func _test_terrain_data_validation() -> void: + var data := _make_terrain(3, 4) + _expect(data.validation_error().is_empty(), "regular terrain data is valid") + _expect(data.index_of(2, 3) == 11, "index is row-major with columns along X") + + var tiny := _make_terrain(1, 4) + _expect(not tiny.validation_error().is_empty(), "terrain needs at least two columns") + + var mismatched := _make_terrain(3, 4) + mismatched.elevations_m.resize(5) + _expect(not mismatched.validation_error().is_empty(), "elevation array must match the grid") + + var non_finite := _make_terrain(3, 4) + non_finite.elevations_m[0] = INF + _expect(not non_finite.validation_error().is_empty(), "elevations must be finite") + + var metal_terrain := _make_terrain(3, 4) + metal_terrain.materials[1] = SonarMaterial.Value.METAL + _expect(not metal_terrain.validation_error().is_empty(), "terrain cells are rock or sand only") + + var bad_cell := _make_terrain(3, 4) + bad_cell.cell_size_m = 0.0 + _expect(not bad_cell.validation_error().is_empty(), "cell size must be positive") + + var bad_reflectivity := _make_terrain(3, 4) + bad_reflectivity.sand_reflectivity = 1.5 + _expect(not bad_reflectivity.validation_error().is_empty(), "reflectivity stays within 0..1") + _expect(TerrainHeightField.from_data(bad_reflectivity) == null, "invalid data yields no field") + + +func _test_height_field_sampling() -> void: + var data := _make_terrain(3, 4) + var field := TerrainHeightField.from_data(data) + _expect(field != null, "valid data yields a field") + if field == null: + return + _expect(field.elevations_m() == data.elevations_m, "elevations are exposed in row-major data order") + _expect(field.bounds_m().is_equal_approx(Rect2(-10.0, 100.0, 4.0, 6.0)), "bounds cover (columns-1) by (rows-1) cells") + _expect(field.cell_world_position(2, 3).is_equal_approx(Vector3(-6.0, -26.0, 106.0)), "cell position follows origin and cell size") + _expect(is_equal_approx(field.elevation_at(-10.0, 100.0), -20.0), "elevation at a vertex is exact") + _expect(is_equal_approx(field.elevation_at(-9.0, 101.0), -21.0), "elevation is bilinear between vertices") + _expect(is_equal_approx(field.elevation_at(-50.0, 1000.0), -26.0), "sampling outside clamps to the nearest edge") + _expect(field.material_at(-10.4, 100.0) == SonarMaterial.Value.ROCK, "material is the nearest cell") + _expect(field.material_at(-6.0, 103.0) == SonarMaterial.Value.SAND, "material of an interior cell") + _expect(field.normal_at_cell(1, 1).is_equal_approx(Vector3(0.0, 1.0, 1.0).normalized()), "normal leans against the downhill direction") + _expect(is_equal_approx(field.smooth_elevation_at(-10.0, 100.0), field.elevation_at(-10.0, 100.0)), "smooth elevation passes through the grid nodes") + var plane_data := SiteTerrainData.new() + plane_data.columns = 7 + plane_data.rows = 6 + plane_data.cell_size_m = 2.0 + plane_data.origin_x_m = 0.0 + plane_data.origin_z_m = 0.0 + var plane_elevations := PackedFloat32Array() + var plane_materials := PackedByteArray() + for row in 6: + for column in 7: + plane_elevations.append(-20.0 + 0.5 * float(column) * 2.0 - 0.25 * float(row) * 2.0) + plane_materials.append(SonarMaterial.Value.SAND) + plane_data.elevations_m = plane_elevations + plane_data.materials = plane_materials + var plane := TerrainHeightField.from_data(plane_data) + _expect( + is_equal_approx(plane.smooth_elevation_at(5.0, 3.0), -20.0 + 2.5 - 0.75) + and is_equal_approx(plane.smooth_elevation_at(0.5, 0.5), -20.0 + 0.25 - 0.125), + "smooth elevation reproduces a plane between nodes, edges included" + ) + var fine := field.cell_block_positions_subdivided(1, 2, 0, 3, 2) + var fine_normals := field.cell_block_normals_subdivided(1, 2, 0, 3, 2) + _expect(fine.size() == 3 * 7 and fine_normals.size() == 3 * 7, "a subdivided block has cells * 2 + 1 points per axis") + var nodes_match := fine.size() == 21 + for row in 4: + for column in range(1, 3): + var index := (row * 2) * 3 + (column - 1) * 2 + if nodes_match and not fine[index].is_equal_approx(field.cell_world_position(column, row)): + nodes_match = false + _expect(nodes_match, "subdivided block points on the grid nodes equal the cell positions") + _expect(fine_normals[0].is_normalized() and fine_normals[10].y > 0.0, "subdivided normals are unit length and point up") + var block_positions := field.cell_block_positions(1, 2, 0, 3) + var block_normals := field.cell_block_normals(1, 2, 0, 3) + var block_matches := block_positions.size() == 8 and block_normals.size() == 8 + var normals_match := block_matches + for row in 4: + for column in range(1, 3): + var index := row * 2 + column - 1 + if block_matches and not block_positions[index].is_equal_approx(field.cell_world_position(column, row)): + block_matches = false + if normals_match and not block_normals[index].is_equal_approx(field.normal_at_cell(column, row)): + normals_match = false + _expect(block_matches, "cell block positions match cell_world_position, row-major") + _expect(normals_match, "cell block normals match normal_at_cell, edges included") + _expect(not field.contains(-11.0, 100.0) and field.contains(-8.0, 103.0), "contains follows the bounds") + _expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data") + + +func _make_placement(id: String, kind: int, position: Vector3) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = StringName(id) + placement.kind = kind + placement.position_m = position + placement.footprint_m = Vector3(4.0, 2.0, 3.0) + return placement + + +func _make_route(id: String, waypoints: PackedVector3Array, maximum_depth: float) -> SiteRoute: + var route := SiteRoute.new() + route.route_id = StringName(id) + route.waypoints_m = waypoints + route.maximum_depth_m = maximum_depth + return route + + +func _make_definition() -> DiveSiteDefinition: + var definition := DiveSiteDefinition.new() + definition.site_id = &"test_site" + definition.display_name = "Site de test" + definition.terrain = _make_terrain(3, 4) + definition.playable_bounds_m = Rect2(-10.0, 100.0, 4.0, 6.0) + definition.spawn_position_m = Vector3(-8.0, -10.0, 102.0) + definition.spawn_yaw_radians = PI * 0.5 + var placements_list: Array[SitePlacement] = [ + _make_placement("target", SitePlacement.Kind.WRECK_TARGET, Vector3(-8.0, -23.0, 103.0)), + _make_placement("anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(-8.0, -20.0, 100.5)), + ] + definition.placements = placements_list + var routes_list: Array[SiteRoute] = [ + _make_route( + "safe", + PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -14.0, 103.0)]), + 20.0 + ), + ] + definition.routes = routes_list + return definition + + +func _test_site_definition_validation() -> void: + var definition := _make_definition() + _expect(definition.validation_error().is_empty(), "complete definition is valid") + _expect(definition.height_field() != null, "definition exposes its height field") + _expect(definition.placement(&"target").kind == SitePlacement.Kind.WRECK_TARGET, "placement lookup by id") + _expect(definition.placement(&"missing") == null, "unknown placement is null") + _expect(definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR).size() == 1, "placements filter by kind") + _expect(definition.route(&"safe").waypoints_m.size() == 2, "route lookup by id") + _expect(definition.spawn_transform().origin.is_equal_approx(Vector3(-8.0, -10.0, 102.0)), "spawn transform origin") + _expect( + (definition.spawn_transform().basis * Vector3.FORWARD).is_equal_approx(Vector3.LEFT), + "spawn yaw rotates the forward axis about Y" + ) + _expect(definition.placement(&"target").is_sonar_metal(), "wrecks read as metal on sonar") + _expect(not definition.placement(&"anchor").is_sonar_metal(), "anchor is not a sonar body") + + var no_id := _make_definition() + no_id.site_id = &"" + _expect(not no_id.validation_error().is_empty(), "site id is required") + + var no_terrain := _make_definition() + no_terrain.terrain = null + _expect(not no_terrain.validation_error().is_empty(), "terrain is required") + + var bounds_outside := _make_definition() + bounds_outside.playable_bounds_m = Rect2(-20.0, 100.0, 4.0, 6.0) + _expect(not bounds_outside.validation_error().is_empty(), "playable bounds must lie inside the terrain") + + var spawn_outside := _make_definition() + spawn_outside.spawn_position_m = Vector3(50.0, -10.0, 102.0) + _expect(not spawn_outside.validation_error().is_empty(), "spawn must lie inside the bounds") + + var duplicate_ids := _make_definition() + duplicate_ids.placements.append( + _make_placement("target", SitePlacement.Kind.WRECK_DECOY, Vector3(-7.0, -23.0, 104.0)) + ) + _expect(not duplicate_ids.validation_error().is_empty(), "placement ids are unique") + + var placement_outside := _make_definition() + placement_outside.placements.append( + _make_placement("far", SitePlacement.Kind.DEBRIS, Vector3(80.0, -23.0, 104.0)) + ) + _expect(not placement_outside.validation_error().is_empty(), "placements must lie inside the bounds") + + var empty_route := _make_definition() + empty_route.routes.append(_make_route("empty", PackedVector3Array(), 20.0)) + _expect(not empty_route.validation_error().is_empty(), "routes need at least two waypoints") + + var deep_route := _make_definition() + deep_route.routes.append( + _make_route("deep", PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -30.0, 103.0)]), 20.0) + ) + _expect(not deep_route.validation_error().is_empty(), "route waypoints respect the route maximum depth") + + +func _expect(condition: bool, label: String) -> void: + if not condition: + _failures.append(label) diff --git a/tests/unit/run_sonar_application_tests.gd b/tests/unit/run_sonar_application_tests.gd index ee4f31d..06ac5e9 100644 --- a/tests/unit/run_sonar_application_tests.gd +++ b/tests/unit/run_sonar_application_tests.gd @@ -20,7 +20,7 @@ const SonarAcquisitionPort = preload( ) const SonarService = preload("res://game/application/sonar/sonar_service.gd") -const TEST_COUNT := 32 +const TEST_COUNT := 34 var _failures: Array[String] = [] @@ -76,6 +76,16 @@ func _test_sample_buffer_contract() -> void: true ) _expect(sample.validation_error().is_empty(), "observed typed sample is valid") + var sand_sample := SonarSample.new( + Vector3(2.0, -4.0, 9.0), + Vector3.UP, + 0.4, + -4.0, + SonarMaterial.Value.SAND, + true + ) + _expect(sand_sample.validation_error().is_empty(), "sand is a valid terrain material") + _expect(SonarMaterial.is_valid(SonarMaterial.Value.SAND), "sand is inside the material range") var buffer := SonarSampleBuffer.new(3, 2) _expect(buffer.set_sample(1, 2, sample).is_empty(), "sample writes inside buffer bounds") _expect(buffer.observed_count() == 1, "buffer counts observed cells") diff --git a/tools/bake_site_terrain.gd b/tools/bake_site_terrain.gd new file mode 100644 index 0000000..c0b91f5 --- /dev/null +++ b/tools/bake_site_terrain.gd @@ -0,0 +1,74 @@ +extends SceneTree + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") + +const RECIPES := { + "vehicle_test": "res://tools/sites/vehicle_test_terrain_recipe.gd", + "zone1_vigilant": "res://tools/sites/zone1_vigilant_recipe.gd", + "terrain_showcase": "res://tools/sites/terrain_showcase_recipe.gd", +} +const OUTPUT_DIRECTORY := "res://content/sites" +const PREVIEW_DIRECTORY := "res://docs/briefs/captures" + + +func _init() -> void: + var arguments := OS.get_cmdline_user_args() + if arguments.is_empty() or not RECIPES.has(arguments[0]): + push_error("usage: -- <%s>" % " | ".join(RECIPES.keys())) + quit(1) + return + var name: String = arguments[0] + var recipe_script := load(RECIPES[name]) as GDScript + if recipe_script == null: + push_error("recipe script %s is missing" % RECIPES[name]) + quit(1) + return + var recipe: SiteTerrainBaker.Recipe = recipe_script.recipe() + var data := SiteTerrainBaker.bake(recipe) + if data == null: + push_error("recipe %s did not bake valid terrain" % name) + quit(1) + return + DirAccess.make_dir_recursive_absolute(OUTPUT_DIRECTORY) + DirAccess.make_dir_recursive_absolute(PREVIEW_DIRECTORY) + var output_path := "%s/%s_terrain.res" % [OUTPUT_DIRECTORY, name] + var save_error := ResourceSaver.save(data, output_path, ResourceSaver.FLAG_CHANGE_PATH) + if save_error != OK: + push_error("cannot save %s (%d)" % [output_path, save_error]) + quit(1) + return + # ResourceSaver restores the previous path after saving, so the data must + # take over the baked path explicitly for the definition to reference it. + data.take_over_path(output_path) + var preview_path := "%s/%s_terrain_preview.png" % [PREVIEW_DIRECTORY, name] + var preview_error := SiteTerrainBaker.write_preview_png(data, preview_path) + if preview_error != OK: + push_error("cannot write %s (%d)" % [preview_path, preview_error]) + quit(1) + return + var definition = recipe_script.definition(data) + var definition_error: String = definition.validation_error() + if not definition_error.is_empty(): + push_error("recipe %s definition is invalid: %s" % [name, definition_error]) + quit(1) + return + var definition_path := "%s/%s.tres" % [OUTPUT_DIRECTORY, name] + var definition_save_error := ResourceSaver.save(definition, definition_path) + if definition_save_error != OK: + push_error("cannot save %s (%d)" % [definition_path, definition_save_error]) + quit(1) + return + var rock_cells := 0 + for material: int in data.materials: + if material == SiteTerrainBaker.SonarMaterial.Value.ROCK: + rock_cells += 1 + var minimum := data.elevations_m[0] + var maximum := data.elevations_m[0] + for elevation: float in data.elevations_m: + minimum = minf(minimum, elevation) + maximum = maxf(maximum, elevation) + print("Baked %s: %d x %d cells, elevation %.1f .. %.1f m, rock %.0f %%" % [ + name, data.columns, data.rows, minimum, maximum, + 100.0 * float(rock_cells) / float(data.materials.size()) + ]) + quit(0) diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd new file mode 100644 index 0000000..36dec12 --- /dev/null +++ b/tools/site_terrain_baker.gd @@ -0,0 +1,756 @@ +extends RefCounted + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") + + +## A primitive returning this from material_at leaves the slope rule in charge. +const NO_MATERIAL := -1 +## Outline noise wavelength of about 20 m: visible irregularity, no cell-scale jitter. +const EDGE_NOISE_FREQUENCY := 0.05 + + +class Primitive extends RefCounted: + func elevation_delta(_x_m: float, _z_m: float) -> float: + return 0.0 + + ## Material imposed by this primitive at (x, z), or NO_MATERIAL. Applied after the + ## slope rule, in recipe order, before the material patches. + func material_at(_x_m: float, _z_m: float) -> int: + return NO_MATERIAL + + static func distance_to_segment(point: Vector2, start: Vector2, end: Vector2) -> float: + var segment := end - start + var length_squared := segment.length_squared() + if length_squared == 0.0: + return point.distance_to(start) + var t := clampf((point - start).dot(segment) / length_squared, 0.0, 1.0) + return point.distance_to(start + segment * t) + + static func end_taper(point: Vector2, start: Vector2, end: Vector2, taper_m: float) -> float: + if taper_m <= 0.0: + return 1.0 + var segment := end - start + var length := segment.length() + if length == 0.0: + return 1.0 + var along := clampf((point - start).dot(segment) / length, 0.0, length) + return smoothstep(0.0, taper_m, along) * smoothstep(0.0, taper_m, length - along) + + static func path_length(path: PackedVector2Array) -> float: + var total := 0.0 + for index in range(1, path.size()): + total += path[index - 1].distance_to(path[index]) + return total + + ## Nearest point of a polyline: x = distance to the path, y = arc length of the nearest + ## point from the first vertex, z = +1 when the point lies to the right of the travel + ## direction (south of an eastbound path, with +X east and +Z south), -1 to the left. + static func path_sample(point: Vector2, path: PackedVector2Array) -> Vector3: + if path.size() == 1: + return Vector3(point.distance_to(path[0]), 0.0, 1.0) + var best := Vector3(INF, 0.0, 1.0) + var arc_start := 0.0 + for index in range(1, path.size()): + var start := path[index - 1] + var end := path[index] + var segment := end - start + var length := segment.length() + var t := 0.0 + if length > 0.0: + t = clampf((point - start).dot(segment) / (length * length), 0.0, 1.0) + var distance := point.distance_to(start + segment * t) + if distance < best.x: + var cross := segment.x * (point.y - start.y) - segment.y * (point.x - start.x) + best = Vector3(distance, arc_start + t * length, 1.0 if cross >= 0.0 else -1.0) + arc_start += length + return best + + ## Deterministic low-frequency noise that roughens an outline; null when amplitude is 0. + static func edge_noise(seed: int, amplitude_m: float) -> FastNoiseLite: + if amplitude_m <= 0.0: + return null + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = EDGE_NOISE_FREQUENCY + noise.fractal_type = FastNoiseLite.FRACTAL_FBM + noise.fractal_octaves = 3 + return noise + + static func edge_offset(noise: FastNoiseLite, amplitude_m: float, x_m: float, z_m: float) -> float: + if noise == null: + return 0.0 + return noise.get_noise_2d(x_m, z_m) * amplitude_m + + +class SlopedPlane extends Primitive: + var depth_m: float + var slope_x: float + var slope_z: float + var reference_x_m: float + var reference_z_m: float + + func _init(p_depth_m: float, p_slope_x: float, p_slope_z: float, p_reference_x_m: float, p_reference_z_m: float) -> void: + depth_m = p_depth_m + slope_x = p_slope_x + slope_z = p_slope_z + reference_x_m = p_reference_x_m + reference_z_m = p_reference_z_m + + func elevation_delta(x_m: float, z_m: float) -> float: + return -depth_m + slope_x * (x_m - reference_x_m) + slope_z * (z_m - reference_z_m) + + +class Blob extends Primitive: + var centre: Vector2 + var sigma_x_m: float + var sigma_z_m: float + var amplitude_m: float + + func _init(p_centre: Vector2, p_sigma_x_m: float, p_sigma_z_m: float, p_amplitude_m: float) -> void: + centre = p_centre + sigma_x_m = p_sigma_x_m + sigma_z_m = p_sigma_z_m + amplitude_m = p_amplitude_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var dx := (x_m - centre.x) / sigma_x_m + var dz := (z_m - centre.y) / sigma_z_m + return amplitude_m * exp(-(dx * dx + dz * dz)) + + +class Ridge extends Primitive: + var start: Vector2 + var end: Vector2 + var sigma_m: float + var amplitude_m: float + var taper_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_sigma_m: float, p_amplitude_m: float, p_taper_m: float) -> void: + start = p_start + end = p_end + sigma_m = p_sigma_m + amplitude_m = p_amplitude_m + taper_m = p_taper_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := distance_to_segment(point, start, end) + var profile := exp(-pow(distance / sigma_m, 2.0)) + return amplitude_m * profile * end_taper(point, start, end, taper_m) + + +class Groove extends Primitive: + var start: Vector2 + var end: Vector2 + var half_width_m: float + var depth_m: float + var wall_softness_m: float + + func _init(p_start: Vector2, p_end: Vector2, p_half_width_m: float, p_depth_m: float, p_wall_softness_m: float) -> void: + start = p_start + end = p_end + half_width_m = p_half_width_m + depth_m = p_depth_m + wall_softness_m = p_wall_softness_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var distance := distance_to_segment(point, start, end) + var inside := 1.0 - smoothstep( + half_width_m - wall_softness_m, + half_width_m + wall_softness_m, + distance + ) + return -depth_m * inside * end_taper(point, start, end, half_width_m) + + +## Flat-floored trench along a polyline. The floor sits depth_m below the surroundings +## wherever the (noise-perturbed) distance to the path is under half_width_m minus +## wall_softness_m; the walls climb over 2 * wall_softness_m; the trench closes over one +## half-width at each end. floor_material (a SonarMaterial value, or NO_MATERIAL) is +## imposed on the flat floor, so a canyon cut through a rock outcrop keeps a sand bed. +class Canyon extends Primitive: + var path: PackedVector2Array + var half_width_m: float + var depth_m: float + var wall_softness_m: float + var edge_amplitude_m: float + var floor_material: int + var _noise: FastNoiseLite + var _length_m: float + + func _init( + p_path: PackedVector2Array, + p_half_width_m: float, + p_depth_m: float, + p_wall_softness_m: float, + p_edge_seed: int, + p_edge_amplitude_m: float, + p_floor_material: int + ) -> void: + assert(p_path.size() >= 2, "a canyon path needs at least two vertices") + assert(p_half_width_m > 0.0, "a canyon needs a positive half width") + path = p_path + half_width_m = p_half_width_m + depth_m = p_depth_m + wall_softness_m = p_wall_softness_m + edge_amplitude_m = p_edge_amplitude_m + floor_material = p_floor_material + _noise = edge_noise(p_edge_seed, p_edge_amplitude_m) + _length_m = path_length(p_path) + + func elevation_delta(x_m: float, z_m: float) -> float: + return -depth_m * _presence(x_m, z_m) + + func material_at(x_m: float, z_m: float) -> int: + # Full presence means the flat floor: inside the walls and clear of the end tapers. + if floor_material != NO_MATERIAL and _presence(x_m, z_m) >= 0.999: + return floor_material + return NO_MATERIAL + + ## 1 on the flat floor, 0 outside the trench, in between on the walls and end tapers. + func _presence(x_m: float, z_m: float) -> float: + var sample := path_sample(Vector2(x_m, z_m), path) + var distance := sample.x + edge_offset(_noise, edge_amplitude_m, x_m, z_m) + var inside := 1.0 - smoothstep( + half_width_m - wall_softness_m, + half_width_m + wall_softness_m, + distance + ) + var taper := smoothstep(0.0, half_width_m, sample.y) * smoothstep(0.0, half_width_m, _length_m - sample.y) + return inside * taper + + +## Step along a polyline: the terrain drops by drop_m on the right of the travel direction +## (south of an eastbound path), over 2 * softness_m, then keeps falling at slope_beyond. +## The crest line is roughened by outline noise. The path must extend past the field +## edges: beyond an end vertex the drop is decided by the half-plane of the last segment, +## which leaves a seam along the path's extension. +class Escarpment extends Primitive: + var path: PackedVector2Array + var drop_m: float + var softness_m: float + var slope_beyond: float + var edge_amplitude_m: float + var _noise: FastNoiseLite + + func _init( + p_path: PackedVector2Array, + p_drop_m: float, + p_softness_m: float, + p_slope_beyond: float, + p_edge_seed: int, + p_edge_amplitude_m: float + ) -> void: + assert(p_path.size() >= 2, "an escarpment path needs at least two vertices") + path = p_path + drop_m = p_drop_m + softness_m = p_softness_m + slope_beyond = p_slope_beyond + edge_amplitude_m = p_edge_amplitude_m + _noise = edge_noise(p_edge_seed, p_edge_amplitude_m) + + func elevation_delta(x_m: float, z_m: float) -> float: + var sample := path_sample(Vector2(x_m, z_m), path) + var signed_distance := sample.x * sample.z + edge_offset(_noise, edge_amplitude_m, x_m, z_m) + var step := drop_m * smoothstep(-softness_m, softness_m, signed_distance) + var slope := maxf(signed_distance - softness_m, 0.0) * slope_beyond + return -(step + slope) + + +## Flat-topped elliptical mound (or, with a negative height, a flat-bottomed pit) whose +## flank spans wall_width_m inside a noise-roughened outline. With rock_top the whole +## mound above 30 % of its height is rock, sommet plat compris. +class Outcrop extends Primitive: + var centre: Vector2 + var radius_x_m: float + var radius_z_m: float + var height_m: float + var wall_width_m: float + var edge_amplitude_m: float + var rock_top: bool + var _noise: FastNoiseLite + var _mean_radius_m: float + + func _init( + p_centre: Vector2, + p_radius_x_m: float, + p_radius_z_m: float, + p_height_m: float, + p_wall_width_m: float, + p_edge_seed: int, + p_edge_amplitude_m: float, + p_rock_top: bool + ) -> void: + assert(p_radius_x_m > 0.0 and p_radius_z_m > 0.0, "an outcrop needs positive radii") + centre = p_centre + radius_x_m = p_radius_x_m + radius_z_m = p_radius_z_m + height_m = p_height_m + wall_width_m = p_wall_width_m + edge_amplitude_m = p_edge_amplitude_m + rock_top = p_rock_top + _noise = edge_noise(p_edge_seed, p_edge_amplitude_m) + _mean_radius_m = (p_radius_x_m + p_radius_z_m) * 0.5 + + func elevation_delta(x_m: float, z_m: float) -> float: + var dx := (x_m - centre.x) / radius_x_m + var dz := (z_m - centre.y) / radius_z_m + var edge_distance := (sqrt(dx * dx + dz * dz) - 1.0) * _mean_radius_m + edge_distance += edge_offset(_noise, edge_amplitude_m, x_m, z_m) + return height_m * (1.0 - smoothstep(-wall_width_m, 0.0, edge_distance)) + + func material_at(x_m: float, z_m: float) -> int: + if rock_top and absf(elevation_delta(x_m, z_m)) > 0.3 * absf(height_m): + return SonarMaterial.Value.ROCK + return NO_MATERIAL + + +## count boulders scattered uniformly in a disc by a seeded generator. Boulders combine +## by maximum, so overlapping blocks never pile into one mound, and every cell they cover +## is rock. angularity 0 gives half-ellipsoids; towards 1 the blocks become elongated +## super-ellipses with flat tops, sharp shoulders, a random heading and a tilt, like +## fallen slabs. The footprint area is preserved whatever the elongation. +class BoulderField extends Primitive: + const MAXIMUM_ASPECT := 2.2 + const MAXIMUM_TILT := 0.35 + + ## One block: centre, radius, heading, elongation, outline exponent, shoulder + ## exponent and tilt across its long axis. + class Block extends RefCounted: + var centre: Vector2 + var radius_m: float + var yaw: float + var aspect: float + var outline_power: float + var shoulder_power: float + var tilt: float + + var centre: Vector2 + var radius_m: float + var height_ratio: float + var angularity: float + var _blocks: Array[Block] = [] + var _reach_m: float + + func _init( + p_centre: Vector2, + p_radius_m: float, + p_seed: int, + p_count: int, + p_boulder_radius_min_m: float, + p_boulder_radius_max_m: float, + p_height_ratio: float, + p_angularity: float = 0.0 + ) -> void: + centre = p_centre + radius_m = p_radius_m + height_ratio = p_height_ratio + angularity = clampf(p_angularity, 0.0, 1.0) + _reach_m = p_radius_m + p_boulder_radius_max_m * sqrt(lerpf(1.0, MAXIMUM_ASPECT, angularity)) + var generator := RandomNumberGenerator.new() + generator.seed = p_seed + for _index in p_count: + var distance := p_radius_m * sqrt(generator.randf()) + var angle := TAU * generator.randf() + var block := Block.new() + block.centre = p_centre + Vector2(distance * cos(angle), distance * sin(angle)) + block.radius_m = generator.randf_range(p_boulder_radius_min_m, p_boulder_radius_max_m) + # Draw every shape number even at angularity 0 so a field keeps the same + # positions and radii when its angularity is changed. + var yaw := TAU * generator.randf() + var aspect := generator.randf_range(1.0, MAXIMUM_ASPECT) + var outline := generator.randf_range(3.0, 6.0) + var shoulder := generator.randf_range(3.0, 6.0) + var tilt := generator.randf_range(-MAXIMUM_TILT, MAXIMUM_TILT) + block.yaw = yaw + block.aspect = lerpf(1.0, aspect, angularity) + block.outline_power = lerpf(2.0, outline, angularity) + block.shoulder_power = lerpf(2.0, shoulder, angularity) + block.tilt = tilt * angularity + _blocks.append(block) + + ## Centre x, centre z and radius of every block, in generation order. + func boulders() -> PackedVector3Array: + var result := PackedVector3Array() + for block in _blocks: + result.append(Vector3(block.centre.x, block.centre.y, block.radius_m)) + return result + + func elevation_delta(x_m: float, z_m: float) -> float: + if Vector2(x_m, z_m).distance_to(centre) > _reach_m: + return 0.0 + var highest := 0.0 + for block in _blocks: + highest = maxf(highest, _block_height(block, x_m, z_m)) + return highest + + func _block_height(block: Block, x_m: float, z_m: float) -> float: + var dx := x_m - block.centre.x + var dz := z_m - block.centre.y + var long_axis_m := block.radius_m * sqrt(block.aspect) + if dx * dx + dz * dz >= long_axis_m * long_axis_m: + return 0.0 + var short_axis_m := block.radius_m / sqrt(block.aspect) + var along := (cos(block.yaw) * dx + sin(block.yaw) * dz) / long_axis_m + var across := (-sin(block.yaw) * dx + cos(block.yaw) * dz) / short_axis_m + var outline := pow( + pow(absf(along), block.outline_power) + pow(absf(across), block.outline_power), + 1.0 / block.outline_power + ) + if outline >= 1.0: + return 0.0 + var profile := pow(1.0 - pow(outline, block.shoulder_power), 1.0 / block.shoulder_power) + return maxf(block.radius_m * height_ratio * profile * (1.0 + block.tilt * along), 0.0) + + func material_at(x_m: float, z_m: float) -> int: + if elevation_delta(x_m, z_m) > 0.0: + return SonarMaterial.Value.ROCK + return NO_MATERIAL + + +class Step extends Primitive: + var point: Vector2 + var normal: Vector2 + var drop_m: float + var softness_m: float + var slope_beyond: float + + func _init(p_point: Vector2, p_normal: Vector2, p_drop_m: float, p_softness_m: float, p_slope_beyond: float) -> void: + point = p_point + normal = p_normal.normalized() + drop_m = p_drop_m + softness_m = p_softness_m + slope_beyond = p_slope_beyond + + func elevation_delta(x_m: float, z_m: float) -> float: + var signed_distance := (Vector2(x_m, z_m) - point).dot(normal) + var step := drop_m * smoothstep(-softness_m, softness_m, signed_distance) + var slope := maxf(signed_distance - softness_m, 0.0) * slope_beyond + return -(step + slope) + + +class Ripples extends Primitive: + var direction: Vector2 + var wavelength_m: float + var amplitude_m: float + var centre: Vector2 + var radius_m: float + + func _init(p_direction: Vector2, p_wavelength_m: float, p_amplitude_m: float, p_centre: Vector2, p_radius_m: float) -> void: + direction = p_direction.normalized() + wavelength_m = p_wavelength_m + amplitude_m = p_amplitude_m + centre = p_centre + radius_m = p_radius_m + + func elevation_delta(x_m: float, z_m: float) -> float: + var point := Vector2(x_m, z_m) + var phase := point.dot(direction) * TAU / wavelength_m + var falloff := 1.0 + if radius_m > 0.0: + falloff = 1.0 - smoothstep(radius_m * 0.6, radius_m, point.distance_to(centre)) + return amplitude_m * sin(phase) * falloff + + +## Seeded fractal noise added everywhere. frequency is 1 / wavelength of the largest +## octave; each further octave halves the wavelength and the amplitude, so a low +## frequency with five octaves gives hummocks, swales and ripple-scale detail at once. +## FRACTAL_RIDGED makes sharp crests (rock), FRACTAL_FBM rolling ground (sediment). +class Roughness extends Primitive: + var amplitude_m: float + var _noise: FastNoiseLite + + func _init( + p_seed: int, + p_frequency: float, + p_amplitude_m: float, + p_octaves: int = 3, + p_fractal_type: FastNoiseLite.FractalType = FastNoiseLite.FRACTAL_FBM + ) -> void: + amplitude_m = p_amplitude_m + _noise = FastNoiseLite.new() + _noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _noise.seed = p_seed + _noise.frequency = p_frequency + _noise.fractal_type = p_fractal_type + _noise.fractal_octaves = p_octaves + + func elevation_delta(x_m: float, z_m: float) -> float: + return _noise.get_noise_2d(x_m, z_m) * amplitude_m + + +## Reshapes the summed elevation once every primitive has been added. gradient is the +## local slope Vector2(dE/dx, dE/dz) of the field being modified, by central differences. +class Modifier extends RefCounted: + func modify(_x_m: float, _z_m: float, elevation_m: float, _gradient: Vector2) -> float: + return elevation_m + + +## Quantises steep ground into ledges about layer_height_m apart: the stratified walls +## of a canyon or cliff. Flat floors and summits are below minimum_slope_degrees and stay +## untouched; the effect is full from full_slope_degrees. sharpness 0 leaves the field +## unchanged, 1 cuts hard steps. region_m is a world-space (x, z) rectangle with a hard +## edge; an empty rectangle applies everywhere. +## +## Real beds are neither horizontal nor equal: dip tilts the whole stack (metres of +## elevation per metre along x and z), warp_amplitude_m bends the beds with slow seeded +## noise so ledges pinch, swell and notch, and thickness_variation (0 to under 1) gives +## every bed its own thickness and hardness, constant along the bed. Bed boundaries stay +## ordered, so the quantised wall never inverts. All three default to the regular case. +## Ledges need two to three cells of wall per layer to be resolved: at 1.5 m per cell, +## 4 m layers on a 50 degree wall. +class Strata extends Modifier: + const BED_SHARPNESS_SPREAD := 0.3 + + var layer_height_m: float + var sharpness: float + var region_m: Rect2 + var dip: Vector2 + var warp_amplitude_m: float + var thickness_variation: float + var _minimum_tangent: float + var _full_tangent: float + var _slow_warp: FastNoiseLite + var _notch_warp: FastNoiseLite + var _bed_noise: FastNoiseLite + + func _init( + p_layer_height_m: float, + p_sharpness: float, + p_minimum_slope_degrees: float, + p_full_slope_degrees: float, + p_region_m: Rect2, + p_dip: Vector2 = Vector2.ZERO, + p_warp_seed: int = 0, + p_warp_amplitude_m: float = 0.0, + p_thickness_variation: float = 0.0 + ) -> void: + assert(p_layer_height_m > 0.0, "strata need a positive layer height") + layer_height_m = p_layer_height_m + sharpness = clampf(p_sharpness, 0.0, 0.999) + region_m = p_region_m + dip = p_dip + warp_amplitude_m = p_warp_amplitude_m + thickness_variation = clampf(p_thickness_variation, 0.0, 0.9) + _minimum_tangent = tan(deg_to_rad(p_minimum_slope_degrees)) + _full_tangent = tan(deg_to_rad(p_full_slope_degrees)) + if p_warp_amplitude_m > 0.0: + _slow_warp = _make_noise(p_warp_seed, 0.016, 2) + _notch_warp = _make_noise(p_warp_seed + 1, 0.09, 2) + if thickness_variation > 0.0: + _bed_noise = _make_noise(p_warp_seed + 2, 1.0, 1) + + static func _make_noise(seed: int, frequency: float, octaves: int) -> FastNoiseLite: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = frequency + noise.fractal_octaves = octaves + return noise + + func modify(x_m: float, z_m: float, elevation_m: float, gradient: Vector2) -> float: + if region_m.has_area() and not region_m.has_point(Vector2(x_m, z_m)): + return elevation_m + var weight := smoothstep(_minimum_tangent, _full_tangent, gradient.length()) + if weight <= 0.0: + return elevation_m + # Work in "bed space": the stack tilted by the dip and bent by the warp. + var bed_elevation := elevation_m - dip.x * x_m - dip.y * z_m + if _slow_warp != null: + bed_elevation += _slow_warp.get_noise_2d(x_m, z_m) * warp_amplitude_m + bed_elevation += _notch_warp.get_noise_2d(x_m, z_m) * warp_amplitude_m * 0.25 + var bed := floorf(bed_elevation / layer_height_m) + var floor_m := _bed_boundary(bed) + var ceiling_m := _bed_boundary(bed + 1.0) + # Jittered boundaries can leave the point just outside its nominal bed. + if bed_elevation < floor_m: + bed -= 1.0 + ceiling_m = floor_m + floor_m = _bed_boundary(bed) + elif bed_elevation >= ceiling_m: + bed += 1.0 + floor_m = ceiling_m + ceiling_m = _bed_boundary(bed + 1.0) + var fraction := (bed_elevation - floor_m) / (ceiling_m - floor_m) + var bed_sharpness := sharpness + if _bed_noise != null: + bed_sharpness = clampf( + sharpness + _bed_noise.get_noise_1d(bed * 17.0 + 5.0) * BED_SHARPNESS_SPREAD, + 0.0, + 0.999 + ) + var sharpened := clampf((fraction - 0.5) / (1.0 - bed_sharpness) + 0.5, 0.0, 1.0) + var quantised := floor_m + sharpened * (ceiling_m - floor_m) + # Apply the change in bed space back to the real elevation. + return elevation_m + (quantised - bed_elevation) * weight + + ## Lower boundary of a bed in bed space. The jitter is under half a layer, so the + ## boundaries keep their order and every bed keeps a positive thickness. + func _bed_boundary(bed: float) -> float: + var boundary := bed * layer_height_m + if _bed_noise != null: + boundary += _bed_noise.get_noise_1d(bed * 17.0) * 0.5 * thickness_variation * layer_height_m + return boundary + + +## Seeded noise weighted by the local slope: rock faces and ridge flanks get crumbly +## while flat sand stays smooth. Zero below minimum_slope_degrees, full amplitude from +## full_slope_degrees. +class SlopeRoughness extends Modifier: + var amplitude_m: float + var _noise: FastNoiseLite + var _minimum_tangent: float + var _full_tangent: float + + func _init( + p_seed: int, + p_frequency: float, + p_amplitude_m: float, + p_minimum_slope_degrees: float, + p_full_slope_degrees: float + ) -> void: + amplitude_m = p_amplitude_m + _noise = FastNoiseLite.new() + _noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _noise.seed = p_seed + _noise.frequency = p_frequency + _noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED + _noise.fractal_octaves = 3 + _minimum_tangent = tan(deg_to_rad(p_minimum_slope_degrees)) + _full_tangent = tan(deg_to_rad(p_full_slope_degrees)) + + func modify(x_m: float, z_m: float, elevation_m: float, gradient: Vector2) -> float: + var weight := smoothstep(_minimum_tangent, _full_tangent, gradient.length()) + if weight <= 0.0: + return elevation_m + return elevation_m + _noise.get_noise_2d(x_m, z_m) * amplitude_m * weight + + +class MaterialPatch extends RefCounted: + var centre: Vector2 + var radius_m: float + var material: int + + func _init(p_centre: Vector2, p_radius_m: float, p_material: int) -> void: + centre = p_centre + radius_m = p_radius_m + material = p_material + + +class Recipe extends RefCounted: + var columns: int = 2 + var rows: int = 2 + var cell_size_m: float = 1.0 + var origin_x_m: float = 0.0 + var origin_z_m: float = 0.0 + var floor_elevation_m: float = -1000.0 + var rock_slope_degrees: float = 20.0 + var rock_reflectivity: float = 0.72 + var sand_reflectivity: float = 0.45 + var primitives: Array[Primitive] = [] + var modifiers: Array[Modifier] = [] + var material_patches: Array[MaterialPatch] = [] + + +## Bake order: primitive sum, modifiers in order, floor clamp, slope material, +## primitive materials in order, material patches. +static func bake(recipe: Recipe) -> SiteTerrainData: + var data := SiteTerrainData.new() + data.columns = recipe.columns + data.rows = recipe.rows + data.cell_size_m = recipe.cell_size_m + data.origin_x_m = recipe.origin_x_m + data.origin_z_m = recipe.origin_z_m + data.rock_reflectivity = recipe.rock_reflectivity + data.sand_reflectivity = recipe.sand_reflectivity + var cell_count := recipe.columns * recipe.rows + # Packed arrays are passed by value in GDScript: fill locals, assign once. + var elevations := PackedFloat32Array() + var materials := PackedByteArray() + elevations.resize(cell_count) + materials.resize(cell_count) + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + var elevation := 0.0 + for primitive: Primitive in recipe.primitives: + elevation += primitive.elevation_delta(x_m, z_m) + elevations[data.index_of(column, row)] = elevation + for modifier: Modifier in recipe.modifiers: + elevations = _apply_modifier(recipe, modifier, elevations) + for index in cell_count: + elevations[index] = maxf(elevations[index], recipe.floor_elevation_m) + materials[index] = SonarMaterial.Value.SAND + data.elevations_m = elevations + data.materials = materials + var field := TerrainHeightField.from_data(data) + if field == null: + return null + var rock_cosine := cos(deg_to_rad(recipe.rock_slope_degrees)) + for row in recipe.rows: + for column in recipe.columns: + if field.normal_at_cell(column, row).y < rock_cosine: + materials[data.index_of(column, row)] = SonarMaterial.Value.ROCK + for primitive: Primitive in recipe.primitives: + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + var material := primitive.material_at(x_m, z_m) + if material != NO_MATERIAL: + materials[data.index_of(column, row)] = material + for patch: MaterialPatch in recipe.material_patches: + for row in recipe.rows: + var z_m := recipe.origin_z_m + float(row) * recipe.cell_size_m + for column in recipe.columns: + var x_m := recipe.origin_x_m + float(column) * recipe.cell_size_m + if Vector2(x_m, z_m).distance_to(patch.centre) <= patch.radius_m: + materials[data.index_of(column, row)] = patch.material + data.materials = materials + return data + + +## Returns the modified copy of elevations; the gradient of each cell is read from the +## unmodified input so the modifier sees one consistent field. +static func _apply_modifier(recipe: Recipe, modifier: Modifier, source: PackedFloat32Array) -> PackedFloat32Array: + var columns := recipe.columns + var rows := recipe.rows + var cell_size := recipe.cell_size_m + var result := PackedFloat32Array() + result.resize(source.size()) + for row in rows: + var z_m := recipe.origin_z_m + float(row) * cell_size + var north := maxi(row - 1, 0) + var south := mini(row + 1, rows - 1) + for column in columns: + var x_m := recipe.origin_x_m + float(column) * cell_size + var west := maxi(column - 1, 0) + var east := mini(column + 1, columns - 1) + var gradient := Vector2( + (source[row * columns + east] - source[row * columns + west]) / (float(east - west) * cell_size), + (source[south * columns + column] - source[north * columns + column]) / (float(south - north) * cell_size) + ) + var index := row * columns + column + result[index] = modifier.modify(x_m, z_m, source[index], gradient) + return result + + +static func write_preview_png(data: SiteTerrainData, path: String) -> Error: + var field := TerrainHeightField.from_data(data) + if field == null: + return ERR_INVALID_DATA + var image := Image.create(data.columns, data.rows, false, Image.FORMAT_RGB8) + var span := maxf(field.maximum_elevation_m() - field.minimum_elevation_m(), 0.001) + for row in data.rows: + for column in data.columns: + var ratio := (field.elevation_at_cell(column, row) - field.minimum_elevation_m()) / span + var color := Color(ratio, ratio, ratio) + if field.material_at_cell(column, row) == SonarMaterial.Value.ROCK: + color.r = minf(color.r + 0.16, 1.0) + image.set_pixel(column, row, color) + return image.save_png(path) diff --git a/tools/sites/terrain_showcase_recipe.gd b/tools/sites/terrain_showcase_recipe.gd new file mode 100644 index 0000000..9033c90 --- /dev/null +++ b/tools/sites/terrain_showcase_recipe.gd @@ -0,0 +1,125 @@ +extends RefCounted + +## Catalogue site « Jardin des Formes »: one quarter per reference landform +## (docs/briefs/terrain-varied-landforms.md §5). No wreck, no route; a single +## mooring beacon at the centre. Field 512 x 512 m at 1 m, +X east, +Z south. + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") + +# North-west: stratified canyon cut through a mesa. +const MESA_CENTRE := Vector2(-120.0, -130.0) +const CANYON_PATH: Array[Vector2] = [ + Vector2(-205.0, -85.0), Vector2(-150.0, -120.0), Vector2(-110.0, -150.0), Vector2(-60.0, -200.0), +] +const CANYON_HALF_WIDTH_M := 14.0 +const CANYON_DEPTH_M := 20.0 +# North-east: crevasse between two buttes. +const WEST_BUTTE_CENTRE := Vector2(80.0, -120.0) +const EAST_BUTTE_CENTRE := Vector2(165.0, -125.0) +const CREVASSE_PATH: Array[Vector2] = [ + Vector2(122.0, -200.0), Vector2(118.0, -160.0), Vector2(124.0, -120.0), Vector2(119.0, -80.0), Vector2(123.0, -40.0), +] +const CREVASSE_HALF_WIDTH_M := 3.0 +const CREVASSE_DEPTH_M := 20.0 +# South-west: boulder field on rippled sand. +const BOULDER_FIELD_CENTRE := Vector2(-120.0, 110.0) +const BOULDER_FIELD_RADIUS_M := 75.0 +# South-east: two rock spurs framing a sand channel. +const WEST_SPUR_CENTRE := Vector2(80.0, 120.0) +const EAST_SPUR_CENTRE := Vector2(170.0, 125.0) +const CHANNEL_CENTRE := Vector2(125.0, 120.0) +# South: sinuous escarpment with rounded knolls on the slope below. +const ESCARPMENT_PATH: Array[Vector2] = [ + Vector2(-270.0, 215.0), Vector2(-150.0, 205.0), Vector2(-40.0, 225.0), Vector2(60.0, 205.0), + Vector2(170.0, 228.0), Vector2(270.0, 210.0), +] +const ESCARPMENT_DROP_M := 22.0 + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 513 + result.rows = 513 + result.cell_size_m = 1.0 + result.origin_x_m = -256.0 + result.origin_z_m = -256.0 + result.floor_elevation_m = -80.0 + result.rock_slope_degrees = 20.0 + result.rock_reflectivity = 0.72 + result.sand_reflectivity = 0.45 + result.primitives = [ + # Base plateau: 30 m deep at the centre, 4 m deeper over the 500 m southward. + SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, -0.008, 0.0, 0.0), + + # NW — la Mesa fendue: flat rock plateau 6 m high, 20 m deep canyon with a + # 14 m wide flat floor, 55 degree walls cut into 4 m ledges by the Strata + # modifier (one ledge every 2.8 m across, resolvable at 1 m per cell), rippled + # sand on the floor. + SiteTerrainBaker.Outcrop.new(MESA_CENTRE, 95.0, 85.0, 6.0, 10.0, 101, 6.0, true), + SiteTerrainBaker.Canyon.new(PackedVector2Array(CANYON_PATH), CANYON_HALF_WIDTH_M, CANYON_DEPTH_M, 7.0, 102, 2.0, SonarMaterial.Value.SAND), + SiteTerrainBaker.Ripples.new(Vector2(0.8, -0.6), 4.0, 0.25, Vector2(-130.0, -140.0), 70.0), + + # NE — la Fente: two buttes 8-9 m high with a 6 m wide, 20 m deep slot between them. + SiteTerrainBaker.Outcrop.new(WEST_BUTTE_CENTRE, 38.0, 70.0, 8.0, 8.0, 201, 5.0, true), + SiteTerrainBaker.Outcrop.new(EAST_BUTTE_CENTRE, 38.0, 75.0, 9.0, 8.0, 202, 5.0, true), + SiteTerrainBaker.Canyon.new(PackedVector2Array(CREVASSE_PATH), CREVASSE_HALF_WIDTH_M, CREVASSE_DEPTH_M, 1.0, 203, 1.0, SonarMaterial.Value.SAND), + + # SW — le Chaos: ninety angular blocks of 2-6 m radius plus six rounded coral heads, + # on rippled sand. + SiteTerrainBaker.BoulderField.new(BOULDER_FIELD_CENTRE, BOULDER_FIELD_RADIUS_M, 301, 90, 2.0, 6.0, 0.7, 0.85), + SiteTerrainBaker.BoulderField.new(Vector2(-100.0, 130.0), 40.0, 302, 6, 6.0, 9.0, 0.6, 0.0), + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.3), 3.5, 0.3, BOULDER_FIELD_CENTRE, 110.0), + + # SE — les Éperons: two pitted rock spurs 7-8 m high framing a 20 m sand channel + # littered with small blocks. + SiteTerrainBaker.Outcrop.new(WEST_SPUR_CENTRE, 30.0, 60.0, 8.0, 5.0, 401, 7.0, true), + SiteTerrainBaker.Outcrop.new(EAST_SPUR_CENTRE, 30.0, 55.0, 7.0, 5.0, 402, 7.0, true), + SiteTerrainBaker.BoulderField.new(CHANNEL_CENTRE, 50.0, 403, 12, 1.5, 3.0, 0.8, 1.0), + + # S — la Corniche: sinuous 22 m escarpment, then a 42 degree slope to the 80 m floor, + # with rounded knolls on the slope. + SiteTerrainBaker.Escarpment.new(PackedVector2Array(ESCARPMENT_PATH), ESCARPMENT_DROP_M, 6.0, 0.9, 501, 8.0), + SiteTerrainBaker.Outcrop.new(Vector2(-60.0, 240.0), 12.0, 10.0, 5.0, 10.0, 502, 2.0, true), + SiteTerrainBaker.Outcrop.new(Vector2(40.0, 236.0), 10.0, 9.0, 4.0, 9.0, 503, 2.0, true), + SiteTerrainBaker.Outcrop.new(Vector2(130.0, 245.0), 14.0, 11.0, 6.0, 11.0, 504, 2.0, true), + + # Roughness: hummocks and swales from 250 m down to 15 m, the 50 m texture, + # and cell-scale pitting. + SiteTerrainBaker.Roughness.new(2026, 0.004, 2.2, 5), + SiteTerrainBaker.Roughness.new(2029, 0.012, 1.2, 4), + SiteTerrainBaker.Roughness.new(2030, 0.05, 0.6, 3), + SiteTerrainBaker.Roughness.new(1933, 0.02, 0.6), + SiteTerrainBaker.Roughness.new(2027, 0.15, 0.2), + ] + result.modifiers = [ + # Crumbly rock faces, smooth sand. Before the strata so the ledges stay flat. + SiteTerrainBaker.SlopeRoughness.new(2028, 0.12, 0.7, 20.0, 40.0), + # Strata on the north-west quarter only (mesa flanks and canyon walls): beds of + # about 4 m, dipping 1.3 degrees to the south-east, bent by 2.4 m of slow warp, + # each bed with its own thickness and hardness. + SiteTerrainBaker.Strata.new( + 4.0, 0.85, 30.0, 50.0, Rect2(-256.0, -256.0, 256.0, 256.0), + Vector2(0.015, 0.018), 105, 2.4, 0.5 + ), + ] + return result + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var result := DiveSiteDefinition.new() + result.site_id = &"terrain_showcase" + result.display_name = "Jardin des Formes" + result.terrain = terrain + result.playable_bounds_m = Rect2(-220.0, -220.0, 440.0, 440.0) + result.spawn_position_m = Vector3(0.0, -12.0, 0.0) + result.spawn_yaw_radians = 0.0 + var anchor := SitePlacement.new() + anchor.placement_id = &"vessel_anchor" + anchor.kind = SitePlacement.Kind.VESSEL_ANCHOR + anchor.position_m = Vector3(0.0, -16.0, 0.0) + result.placements = [anchor] + return result diff --git a/tools/sites/vehicle_test_terrain_recipe.gd b/tools/sites/vehicle_test_terrain_recipe.gd new file mode 100644 index 0000000..7626305 --- /dev/null +++ b/tools/sites/vehicle_test_terrain_recipe.gd @@ -0,0 +1,50 @@ +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 61 + result.rows = 83 + result.cell_size_m = 3.0 + result.origin_x_m = -90.0 + result.origin_z_m = -176.0 + result.floor_elevation_m = -95.0 + result.rock_slope_degrees = 20.0 + result.primitives = [ + SiteTerrainBaker.SlopedPlane.new(40.0, 0.0, 0.19, 0.0, 28.0), + SiteTerrainBaker.Blob.new(Vector2(-24.0, -48.0), sqrt(210.0), sqrt(980.0), 13.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, -58.0), sqrt(250.0), sqrt(1150.0), 15.0), + SiteTerrainBaker.Blob.new(Vector2(0.0, -66.0), sqrt(120.0), sqrt(2500.0), -8.5), + SiteTerrainBaker.Blob.new(Vector2(-5.0, -125.0), sqrt(900.0), sqrt(620.0), 8.0), + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.0), TAU / 0.075, 0.9, Vector2.ZERO, 0.0), + SiteTerrainBaker.Ripples.new(Vector2(0.0, 1.0), TAU / 0.048, 0.9, Vector2.ZERO, 0.0), + ] + return result + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + var result := DiveSiteDefinition.new() + result.site_id = &"vehicle_test" + result.display_name = "Site de calibration du véhicule" + result.terrain = terrain + result.playable_bounds_m = Rect2(-90.0, -176.0, 180.0, 246.0) + result.spawn_position_m = Vector3(0.0, -12.0, 12.0) + result.spawn_yaw_radians = 0.0 + var target := SitePlacement.new() + target.placement_id = &"calibration_target" + target.kind = SitePlacement.Kind.WRECK_DECOY + target.position_m = Vector3(0.0, field.elevation_at(0.0, -58.0) + 2.5, -58.0) + target.footprint_m = Vector3(8.0, 5.0, 7.0) + var anchor := SitePlacement.new() + anchor.placement_id = &"navigation_beacon" + anchor.kind = SitePlacement.Kind.VESSEL_ANCHOR + anchor.position_m = Vector3(0.0, -15.0, -22.0) + result.placements = [target, anchor] + return result diff --git a/tools/sites/zone1_vigilant_recipe.gd b/tools/sites/zone1_vigilant_recipe.gd new file mode 100644 index 0000000..a295ee9 --- /dev/null +++ b/tools/sites/zone1_vigilant_recipe.gd @@ -0,0 +1,175 @@ +extends RefCounted + +const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const SiteRoute = preload("res://game/domain/sites/site_route.gd") + +# Groove axis (la Saignée): north entrance to the shelf break. +const GROOVE_START := Vector2(-60.0, -140.0) +const GROOVE_END := Vector2(-10.0, 120.0) +# Ridge axis (l'Échine). Starts south of the vessel so its crest never rises above 20 m. +const RIDGE_START := Vector2(70.0, -150.0) +const RIDGE_END := Vector2(80.0, 90.0) +const BREAK_Z := 125.0 +# Strata apply from here south: past the groove's end cap (120 m) and still 3 m before +# the break's smoothstep has dropped half a metre. +const STRATA_START_Z := 118.0 +# The target's centre sits this far up the groove from GROOVE_END, so its 28 m hull +# stops one groove half-width short of the end cap and rests on the flat groove floor. +const TARGET_SET_BACK_M := 22.0 +# The groove tapers to nothing over one half-width at each end: the entrance landmark +# sits two half-widths inside, where the groove has its full depth. +const ENTRANCE_INSET_M := 16.0 + + +static func recipe() -> SiteTerrainBaker.Recipe: + var result := SiteTerrainBaker.Recipe.new() + result.columns = 513 + result.rows = 513 + result.cell_size_m = 1.5 + result.origin_x_m = -384.0 + result.origin_z_m = -384.0 + result.floor_elevation_m = -88.0 + result.rock_slope_degrees = 20.0 + result.rock_reflectivity = 0.72 + result.sand_reflectivity = 0.45 + result.primitives = [ + # Plateau: 24 m deep at z = -300 (about 25 m at the mooring), 6 m deeper over 400 m toward the south. + SiteTerrainBaker.SlopedPlane.new(24.0, 0.0, -0.015, 0.0, -300.0), + # L'Échine: long north-south rock ridge, 6 m high, flanks steeper than 20°. + SiteTerrainBaker.Ridge.new(RIDGE_START, RIDGE_END, 12.0, 6.0, 25.0), + # Les Deux Frères: twin pinnacles, 20 m apart, 6 m tall (crests stay below 20 m deep). + SiteTerrainBaker.Blob.new(Vector2(-160.0, -130.0), 8.4, 8.4, 6.0), + SiteTerrainBaker.Blob.new(Vector2(-142.0, -121.0), 8.4, 8.4, 6.0), + # La Saignée: 16 m wide groove, 9 m deep, steep walls, the risky route. + SiteTerrainBaker.Groove.new(GROOVE_START, GROOVE_END, 8.0, 9.0, 4.0), + # Basin and patch reef for the steel coaster decoy, east of the ridge. + SiteTerrainBaker.Blob.new(Vector2(140.0, 40.0), 30.0, 30.0, -8.0), + SiteTerrainBaker.Blob.new(Vector2(125.0, 30.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(155.0, 55.0), 7.7, 7.7, 2.5), + SiteTerrainBaker.Blob.new(Vector2(135.0, 60.0), 7.7, 7.7, 2.5), + # Shelf break (la rupture) then the drop-off (le tombant), clamped at -88 m. + SiteTerrainBaker.Step.new(Vector2(0.0, BREAK_Z), Vector2(0.0, 1.0), 16.0, 10.0, 0.7), + # Scree cones at the foot of the drop-off. + SiteTerrainBaker.Blob.new(Vector2(-40.0, 200.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(-5.0, 205.0), 6.3, 6.3, 2.0), + SiteTerrainBaker.Blob.new(Vector2(25.0, 198.0), 6.3, 6.3, 2.0), + # Les Rides: sand ripples near the vessel only. + SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.35), 9.0, 0.45, Vector2(0.0, -220.0), 90.0), + # Deterministic roughness everywhere: the original 50 m texture, plus hummocks + # and swales from 250 m down to 15 m (five octaves) so the plateau is never flat. + SiteTerrainBaker.Roughness.new(1933, 0.02, 0.6), + SiteTerrainBaker.Roughness.new(1934, 0.004, 2.2, 5), + SiteTerrainBaker.Roughness.new(1936, 0.012, 1.2, 4), + # Hummocks of 20 m down to 5 m, the scale the pilot sees through the fog. + SiteTerrainBaker.Roughness.new(1937, 0.05, 0.6, 3), + ] + result.modifiers = [ + # La rupture et le tombant are a carbonate drop-off: 4 m strata on the break + # (full effect) and fainter ledges on the 35 degree talus below. The band starts + # south of the target hull and of la Saignée's end cap, so neither changes. + # Crumbly rock on every slope steeper than the sand rule, smooth sand elsewhere. + # Before the strata so the ledges stay flat. + SiteTerrainBaker.SlopeRoughness.new(1935, 0.12, 0.7, 20.0, 40.0), + SiteTerrainBaker.Strata.new( + 4.0, 0.85, 25.0, 40.0, Rect2(-384.0, STRATA_START_Z, 768.0, 384.0 - STRATA_START_Z), + Vector2(0.008, 0.0), 1938, 2.0, 0.45 + ), + ] + result.material_patches = [ + SiteTerrainBaker.MaterialPatch.new(Vector2(140.0, 40.0), 28.0, SonarMaterial.Value.ROCK), + ] + return result + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + # The target hull's long axis (local +X) lies along la Saignée. + var groove_axis := (GROOVE_END - GROOVE_START).normalized() + var groove_yaw := atan2(-groove_axis.y, groove_axis.x) + var target_xz := GROOVE_END - groove_axis * TARGET_SET_BACK_M + var entrance_xz := GROOVE_START + groove_axis * ENTRANCE_INSET_M + var entrance_m := Vector3(entrance_xz.x, field.elevation_at(entrance_xz.x, entrance_xz.y), entrance_xz.y) + var result := DiveSiteDefinition.new() + result.site_id = &"zone1_vigilant" + result.display_name = "Banc du Vigilant" + result.terrain = terrain + result.playable_bounds_m = Rect2(-300.0, -300.0, 600.0, 600.0) + # Spawn beside the mooring, facing south toward the site. + result.spawn_position_m = Vector3(0.0, -10.0, -210.0) + result.spawn_yaw_radians = PI + result.placements = [ + _on_seabed(field, &"prospection_vessel", SitePlacement.Kind.WRECK_TARGET, + target_xz, groove_yaw, Vector3(28.0, 8.0, 9.0), 2.0), + _on_seabed(field, &"decoy_yacht", SitePlacement.Kind.WRECK_DECOY, + Vector2(-30.0, -150.0), 1.1, Vector3(12.0, 3.0, 4.0), 0.5), + _on_seabed(field, &"decoy_coaster", SitePlacement.Kind.WRECK_DECOY, + Vector2(140.0, 40.0), -0.6, Vector3(26.0, 6.0, 8.0), 1.2), + _on_seabed(field, &"secondary_item", SitePlacement.Kind.SECONDARY_ITEM, + Vector2(-6.0, 160.0), 0.0, Vector3(1.0, 1.0, 1.0), 0.0), + _on_seabed(field, &"debris_1", SitePlacement.Kind.DEBRIS, + Vector2(-12.0, 136.0), 0.4, Vector3(3.0, 2.0, 2.0), 0.3), + _on_seabed(field, &"debris_2", SitePlacement.Kind.DEBRIS, + Vector2(-10.0, 139.0), 1.2, Vector3(6.0, 2.5, 2.5), 0.4), + _on_seabed(field, &"debris_3", SitePlacement.Kind.DEBRIS, + Vector2(-9.0, 146.0), 0.0, Vector3(2.0, 1.5, 2.0), 0.2), + _on_seabed(field, &"debris_4", SitePlacement.Kind.DEBRIS, + Vector2(-7.0, 152.0), 2.0, Vector3(4.0, 1.5, 1.5), 0.3), + _marker(&"vessel_anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(0.0, -14.0, -210.0)), + _marker(&"echine", SitePlacement.Kind.LANDMARK, Vector3(75.0, -20.0, -50.0)), + _marker(&"deux_freres", SitePlacement.Kind.LANDMARK, Vector3(-151.0, -18.0, -125.0)), + _marker(&"rides", SitePlacement.Kind.LANDMARK, Vector3(0.0, -25.0, -230.0)), + _marker(&"saignee_entrance", SitePlacement.Kind.LANDMARK, entrance_m), + _marker(&"rupture", SitePlacement.Kind.LANDMARK, Vector3(40.0, -40.0, 125.0)), + ] + result.routes = [ + _route(&"safe_ridge", 32.0, [ + Vector3(0.0, -14.0, -210.0), Vector3(70.0, -16.0, -200.0), Vector3(76.0, -16.0, -100.0), + Vector3(78.0, -16.0, 0.0), Vector3(76.0, -18.0, 80.0), Vector3(40.0, -24.0, 105.0), + Vector3(0.0, -24.0, 100.0), Vector3(-14.0, -30.0, 98.0), + ]), + _route(&"risky_groove", 48.0, [ + Vector3(0.0, -14.0, -210.0), Vector3(-60.0, -20.0, -150.0), Vector3(-50.0, -31.0, -100.0), + Vector3(-35.0, -33.0, -20.0), Vector3(-22.0, -34.0, 60.0), Vector3(-14.0, -33.0, 98.0), + ]), + ] + return result + + +static func _on_seabed( + field: TerrainHeightField, + id: StringName, + kind: SitePlacement.Kind, + xz: Vector2, + yaw_radians: float, + footprint_m: Vector3, + burial_m: float +) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.footprint_m = footprint_m + placement.yaw_radians = yaw_radians + var seabed := field.elevation_at(xz.x, xz.y) + placement.position_m = Vector3(xz.x, seabed + footprint_m.y * 0.5 - burial_m, xz.y) + return placement + + +static func _marker(id: StringName, kind: SitePlacement.Kind, position_m: Vector3) -> SitePlacement: + var placement := SitePlacement.new() + placement.placement_id = id + placement.kind = kind + placement.position_m = position_m + return placement + + +static func _route(id: StringName, maximum_depth_m: float, waypoints: Array) -> SiteRoute: + var route := SiteRoute.new() + route.route_id = id + route.maximum_depth_m = maximum_depth_m + route.waypoints_m = PackedVector3Array(waypoints) + return route