122 KiB
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.shenforces this withrg. - No
res://src/,res://scenes/,res://tests/imports ingame/. No autoload. No plugin. - No dictionaries across production boundaries: typed Resources / RefCounted with
validation_error() -> String(existing convention, seegame/domain/rules/dive_difficulty_profile.gd). - GDScript style in repo: tabs, static typing,
preloadconstants at top,class_name, snake_case, French user-facing strings viatr(). - Test runner convention:
extends SceneTree,_failures: Array[String],_expect(condition, label),const TEST_COUNT := Nequal to the number of_expectcalls, prints"<Suite>: %d passed, 0 failed."andquit(0), elsepush_erroreach failure andquit(1). Every new suite MUST be added toscripts/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
ImagePNG 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 asPackedFloat32Arrayinside a binary.resSiteTerrainDataresource 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 frommain(superpowers:using-git-worktrees) and rebase at the end. Nevergit checkout --/stashsomeone 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
# 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
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:
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
"$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:
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
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 Resourcewith@exportfieldscolumns: 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:
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
"$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:
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:
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:
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
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.ZEROallowed 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 byspawn_yaw_radians, originspawn_position_m). -
Step 1: Add failing tests
In tests/unit/run_site_domain_tests.gd add the preloads
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:
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:
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:
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:
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.Thenbash scripts/check_architecture.sh. -
Step 7: Commit
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.ZEROwhenINSIDE);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:
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):
"$GODOT_BIN" --headless --path . --script tests/unit/run_site_application_tests.gd
- Step 3: Implement
game/application/sites/playable_bounds_status.gd:
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:
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:
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
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"toSEARCH_ROOTS),scripts/quality_gate.sh
Interfaces:
-
SiteTerrainBaker extends RefCountedwith 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 bysmoothstep(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 withtaper_m = half_width_m.Step(point: Vector2, normal: Vector2, drop_m, softness_m, slope_beyond): signed distanced = 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 <= 0means 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 =ROCKwhen the slope (fromTerrainHeightField.normal_at_cellof a provisional field) exceedsrock_slope_degrees, elseSAND; 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: readsOS.get_cmdline_user_args()[0]as recipe name, looks it up in aconst RECIPES := { "vehicle_test": ..., "zone1_vigilant": ... }of script paths, callsrecipe_script.recipe(), bakes, savesres://content/sites/<name>_terrain.reswithResourceSaver.save, writesres://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.Recipereproducing the current analytic terrain ofvehicle_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:
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):
"$GODOT_BIN" --headless --path . --script tests/unit/run_site_baker_tests.gd
- Step 3: Implement
tools/site_terrain_baker.gd
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).
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:
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 is10 * 2 * (4/16) * exp(-1) ≈ 4.6, far steeper than 20°. -
Step 7: Bake the calibration terrain
"$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:
"$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:
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
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. ConstantsTILE_CELLS := 64,VISIBILITY_RANGE_END_M := 260.0. Collision layer stays the default (1), which is whatReferenceBathymetryAcquisition.COLLISION_MASKand the submarine use. -
Step 1: Write the failing integration test
tests/integration/run_site_terrain_integration.gd:
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):
"$GODOT_BIN" --headless --path . --script tests/integration/run_site_terrain_integration.gd
- Step 3: Write the shader
game/world/sites/seabed_terrain.gdshader:
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:
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:
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
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(adddefinition),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.gdwritesres://content/sites/<name>_terrain.res(withResourceSaver.FLAG_CHANGE_PATH) thenres://content/sites/<name>.tres. -
ReferenceBathymetryAcquisitionpreferscollider.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:
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:
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:
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
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.
[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:
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:
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:
"$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) andbash scripts/check_architecture.sh. -
Step 8: Commit
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 nextNouvelle partie);dive_site() -> DiveSite;latest_bounds_status() -> PlayableBoundsStatus. -
CockpitHud.present_bounds(status: PlayableBoundsStatus) -> void;bounds_warning_visible() -> bool. Scene nodes%BoundsPanel,%BoundsLabel. -
dive_world.tscnunique 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 beforenew_game_button.pressed.emit(), addcomposition.select_dive_site("res://content/sites/vehicle_test.tres"); - after
_expect(composition.latest_update() != null, ...)add:
_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_siteundefined):
"$GODOT_BIN" --headless --path . --script tests/integration/run_vertical_slice_foundation.gd
- Step 3: Write
dive_world.tscn
[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:
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:
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:
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:
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:
_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():
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:
[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:
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
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
"$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
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:
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):
"$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.
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
"$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:
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
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
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
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
"$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":
- `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
## 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:
É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
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).