2966 lines
122 KiB
Markdown
2966 lines
122 KiB
Markdown
# 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 `"<Suite>: %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 <noreply@anthropic.com>`.
|
||
|
||
## 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: `-- <recipe_name>` |
|
||
| `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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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/<name>_terrain.res` with `ResourceSaver.save`, writes `res://docs/briefs/captures/<name>_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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
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/<name>_terrain.res` (with `ResourceSaver.FLAG_CHANGE_PATH`) then `res://content/sites/<name>.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/<name>.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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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/<nom>.tres` est une `DiveSiteDefinition` ; `sites/<nom>_terrain.res`
|
||
est son `SiteTerrainData`. Les deux sont produits par
|
||
`tools/bake_site_terrain.gd -- <nom>` depuis `tools/sites/<nom>_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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## 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).
|