diff --git a/content/sites/terrain_showcase_terrain.res b/content/sites/terrain_showcase_terrain.res index f97c88c..acec241 100644 Binary files a/content/sites/terrain_showcase_terrain.res and b/content/sites/terrain_showcase_terrain.res differ diff --git a/docs/briefs/captures/terrain_showcase_terrain_preview.png b/docs/briefs/captures/terrain_showcase_terrain_preview.png index 7ec72f8..d856bd4 100644 Binary files a/docs/briefs/captures/terrain_showcase_terrain_preview.png and b/docs/briefs/captures/terrain_showcase_terrain_preview.png differ diff --git a/docs/briefs/terrain-varied-landforms.md b/docs/briefs/terrain-varied-landforms.md index 3416e00..c9a531b 100644 --- a/docs/briefs/terrain-varied-landforms.md +++ b/docs/briefs/terrain-varied-landforms.md @@ -82,7 +82,7 @@ ou `smoothstep`) et **mono-segment**. Il manque : | Primitive | Paramètres | Forme | |---|---|---| -| `Canyon` | `path: PackedVector2Array`, `half_width_m`, `depth_m`, `wall_softness_m`, `edge_seed`, `edge_amplitude_m` | tranchée à fond plat le long d'une polyligne ; parois `smoothstep` sur `wall_softness_m` ; distance au tracé perturbée par un bruit de contour ; se referme sur une demi-largeur à chaque bout | +| `Canyon` | `path: PackedVector2Array`, `half_width_m`, `depth_m`, `wall_softness_m`, `edge_seed`, `edge_amplitude_m`, `floor_material` | tranchée à fond plat le long d'une polyligne ; parois `smoothstep` sur `wall_softness_m` ; distance au tracé perturbée par un bruit de contour ; se referme sur une demi-largeur à chaque bout ; impose `floor_material` (sable en général) sur le fond plat, même à travers une butte rocheuse | | `Outcrop` | `centre`, `radius_x_m`, `radius_z_m`, `height_m`, `wall_width_m`, `edge_seed`, `edge_amplitude_m`, `rock_top` | butte elliptique à sommet plat, flanc de largeur `wall_width_m`, contour perturbé ; matériau roche partout où elle dépasse 30 % de sa hauteur si `rock_top` | | `BoulderField` | `centre`, `radius_m`, `seed`, `count`, `boulder_radius_min_m`, `boulder_radius_max_m`, `height_ratio` | `count` demi-ellipsoïdes tirés par `RandomNumberGenerator` seedé dans le disque, combinés par **maximum** ; roche sur toute leur emprise | | `Escarpment` | `path: PackedVector2Array`, `drop_m`, `softness_m`, `slope_beyond`, `edge_seed`, `edge_amplitude_m` | `Step` le long d'une polyligne : le terrain descend à **droite** du sens de parcours ; ligne de crête perturbée par un bruit de contour | @@ -129,7 +129,7 @@ Plan de masse (nord en haut) : | 55°, gradins 4 m, fond ridé entre buttes | | [départ] | | SW : chaos de blocs SE : éperons | - | 80 blocs r 2–6 m sur deux buttes | + | 90 blocs r 2–6 m sur deux buttes | | sable ridé alvéolées, | | chenal 20 m | | ====== falaise sinueuse (Escarpment) ===== | @@ -148,13 +148,15 @@ Plan de masse (nord en haut) : gradins : au moins deux niveaux plats ; un fond plat ne bouge pas) ; cuisson du Banc du Vigilant identique à l'artefact versionné. - **Contrat (`run_site_content_tests.gd`, section showcase)** : canyon - (profondeur ≥ 20 m sous la mesa, fond plat à ± 1 m sur 10 m, paroi ≥ 60° - quelque part sur une transversale, ≥ 2 replats de gradin) ; crevasse - (fond ≥ 16 m sous les bords, largeur au fond ≤ 8 m) ; chaos (roche sur un + (profondeur ≥ 18 m sous le sommet bruité de la mesa, fond de sable plat à + ± 1 m sur 10 m, paroi ≥ 60° quelque part sur une transversale, ≥ 2 replats + distincts) ; crevasse (fond de sable ≥ 16 m sous le plateau ouvert, 3 à 8 m + de large à 10 m sous le plateau) ; chaos (roche sur un bloc, sable entre deux blocs, ≥ 40 cellules de roche distinctes… mesuré par fraction de roche dans le disque) ; éperons (chenal plus bas que les deux - buttes, roche sur chaque butte) ; falaise (drop ≥ 20 m entre deux points de - part et d'autre) ; bornes et profondeurs globales. + buttes, roche sur chaque butte) ; falaise (marche ≥ 18 m mesurée dans la + seule bande de crête, pour que la pente en contrebas ne masque pas une + marche absente) ; bornes et profondeurs globales. - **Intégration (`run_site_terrain_integration.gd`)** : chargement du showcase sous 3 s, rayon dans le canyon plus bas que sur la mesa, scan de référence lisant roche et sable. diff --git a/tests/contract/run_site_content_tests.gd b/tests/contract/run_site_content_tests.gd index e914dda..c466dd2 100644 --- a/tests/contract/run_site_content_tests.gd +++ b/tests/contract/run_site_content_tests.gd @@ -15,20 +15,23 @@ const DiveRulesResolver = preload("res://game/domain/rules/dive_rules_resolver.g const SITE_PATH := "res://content/sites/zone1_vigilant.tres" const SHOWCASE_PATH := "res://content/sites/terrain_showcase.tres" # Showcase probe positions (docs/briefs/terrain-varied-landforms.md §5). The canyon -# transect crosses its first leg at right angles; the crevasse transect is the -# z = -120 row between the two buttes. +# transect crosses its second leg at right angles; the crevasse transect is the +# z = -120 row between the two buttes, compared with the open plateau on the same row; +# the escarpment is probed across its step band at x = 0, where the crest is at z = 217. const SHOWCASE_MESA_TOP := Vector2(-80.0, -90.0) const SHOWCASE_CANYON_MID := Vector2(-130.0, -135.0) const SHOWCASE_CANYON_AXIS := Vector2(40.0, -30.0) const SHOWCASE_CREVASSE_ROW_Z := -120.0 -const SHOWCASE_CREVASSE_SHELF_X := 118.0 +const SHOWCASE_CREVASSE_PLAIN_X := 0.0 const SHOWCASE_CREVASSE_FLOOR_X := 124.0 const SHOWCASE_BOULDER_FIELD_CENTRE := Vector2(-120.0, 110.0) const SHOWCASE_BOULDER_FIELD_RADIUS_M := 75.0 const SHOWCASE_WEST_SPUR := Vector2(80.0, 120.0) const SHOWCASE_CHANNEL := Vector2(125.0, 120.0) const SHOWCASE_EAST_SPUR := Vector2(170.0, 125.0) -const STEEP_WALL_RISE_PER_M := 1.7 # tan(60 degrees) +const SHOWCASE_ESCARPMENT_CREST_Z := 217.0 +const SHOWCASE_ESCARPMENT_BAND_M := 14.0 # softness 6 m plus 8 m of outline noise +const STEEP_WALL_RISE_PER_M := 1.73 # tan(60 degrees) const LEDGE_RISE_PER_M := 0.35 const ROUTE_CLEARANCE_M := 3.0 const MINIMUM_LANDMARK_SPACING_M := 60.0 @@ -36,7 +39,7 @@ const MAXIMUM_ANCHOR_TO_WRECK_M := 320.0 const MAXIMUM_WRECK_SPACING_M := 260.0 const HULL_HALF_LENGTH_M := 14.0 const HULL_SEABED_TOLERANCE_M := 2.0 -const TEST_COUNT := 52 +const TEST_COUNT := 54 var _failures: Array[String] = [] @@ -161,7 +164,7 @@ func _test_showcase_content() -> void: return _expect(field.cell_size_m() == 1.0 and field.columns() == 513 and field.rows() == 513, "showcase grid is 513 x 513 cells of 1 m") _expect(definition.playable_bounds_m.is_equal_approx(Rect2(-220.0, -220.0, 440.0, 440.0)), "showcase playable zone is 440 x 440 m") - _expect(field.minimum_elevation_m() >= -80.5 and field.maximum_elevation_m() <= -18.5, "showcase terrain stays between 19 m and 80 m deep") + _expect(field.minimum_elevation_m() >= -80.5 and field.maximum_elevation_m() <= -19.0, "showcase terrain stays between 19 m and 80 m deep") var anchors := definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR) _expect(anchors.size() == 1 and definition.placements.size() == 1, "showcase has a single placement, the mooring beacon") var spawn := definition.spawn_position_m @@ -171,6 +174,7 @@ func _test_showcase_content() -> void: var mesa_top := field.elevation_at(SHOWCASE_MESA_TOP.x, SHOWCASE_MESA_TOP.y) var canyon_floor := field.elevation_at(SHOWCASE_CANYON_MID.x, SHOWCASE_CANYON_MID.y) _expect(mesa_top - canyon_floor >= 18.0, "canyon floor is at least 18 m below the mesa top (%.1f m)" % (mesa_top - canyon_floor)) + _expect(field.material_at(SHOWCASE_CANYON_MID.x, SHOWCASE_CANYON_MID.y) == SonarMaterial.Value.SAND, "canyon floor is sand through the rock mesa") var axis := SHOWCASE_CANYON_AXIS.normalized() var lowest := INF var highest := -INF @@ -186,23 +190,30 @@ func _test_showcase_content() -> void: var point := SHOWCASE_CANYON_MID + normal * float(offset) transect.append(field.elevation_at(point.x, point.y)) var steepest := 0.0 - var ledge_pairs := 0 + var ledges := 0 + var on_ledge := false for index in range(transect.size() - 1): var rise := absf(transect[index + 1] - transect[index]) steepest = maxf(steepest, rise) var in_wall := transect[index] > canyon_floor + 1.0 and transect[index] < mesa_top - 1.0 + # A ledge is a run of flat metres inside the wall, separated from the next by a riser. if in_wall and rise < LEDGE_RISE_PER_M: - ledge_pairs += 1 - _expect(steepest >= STEEP_WALL_RISE_PER_M, "canyon wall is steeper than 60 degrees somewhere (%.1f m per m)" % steepest) - _expect(ledge_pairs >= 2, "canyon wall carries at least two ledges (%d flat metres)" % ledge_pairs) + if not on_ledge: + ledges += 1 + on_ledge = true + else: + on_ledge = false + _expect(steepest >= STEEP_WALL_RISE_PER_M, "canyon wall is steeper than 60 degrees somewhere (%.2f m per m)" % steepest) + _expect(ledges >= 2, "canyon walls carry at least two distinct ledges (%d)" % ledges) # NE: crevasse between the buttes. - var shelf := field.elevation_at(SHOWCASE_CREVASSE_SHELF_X, SHOWCASE_CREVASSE_ROW_Z) + var plain := field.elevation_at(SHOWCASE_CREVASSE_PLAIN_X, SHOWCASE_CREVASSE_ROW_Z) var crevasse_floor := field.elevation_at(SHOWCASE_CREVASSE_FLOOR_X, SHOWCASE_CREVASSE_ROW_Z) - _expect(shelf - crevasse_floor >= 16.0, "crevasse floor is at least 16 m below the rim (%.1f m)" % (shelf - crevasse_floor)) + _expect(plain - crevasse_floor >= 16.0, "crevasse floor is at least 16 m below the open plateau (%.1f m)" % (plain - crevasse_floor)) + _expect(field.material_at(SHOWCASE_CREVASSE_FLOOR_X, SHOWCASE_CREVASSE_ROW_Z) == SonarMaterial.Value.SAND, "crevasse floor is sand") var deep_cells := 0 for x in range(100, 146): - if field.elevation_at(float(x), SHOWCASE_CREVASSE_ROW_Z) < shelf - 10.0: + if field.elevation_at(float(x), SHOWCASE_CREVASSE_ROW_Z) < plain - 10.0: deep_cells += 1 _expect(deep_cells >= 3 and deep_cells <= 8, "crevasse is 3 to 8 m wide at 10 m down (%d m)" % deep_cells) @@ -234,10 +245,11 @@ func _test_showcase_content() -> void: ) _expect(field.material_at(SHOWCASE_CHANNEL.x, SHOWCASE_CHANNEL.y + 30.0) == SonarMaterial.Value.SAND, "the channel floor is sand") - # S: escarpment. - var above := field.elevation_at(0.0, 190.0) - var below := field.elevation_at(0.0, 250.0) - _expect(above - below >= 20.0, "the escarpment drops at least 20 m (%.1f m)" % (above - below)) + # S: escarpment, measured across its step band only so the slope below cannot + # stand in for a missing step (the slope alone would give about 7 m here). + var above := field.elevation_at(0.0, SHOWCASE_ESCARPMENT_CREST_Z - SHOWCASE_ESCARPMENT_BAND_M) + var below := field.elevation_at(0.0, SHOWCASE_ESCARPMENT_CREST_Z + SHOWCASE_ESCARPMENT_BAND_M) + _expect(above - below >= 18.0, "the escarpment steps down at least 18 m across its crest band (%.1f m)" % (above - below)) func _rests_on_terrain(field: TerrainHeightField, placement: SitePlacement) -> bool: diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd index 30caa0b..a7279d1 100644 --- a/tests/integration/run_site_terrain_integration.gd +++ b/tests/integration/run_site_terrain_integration.gd @@ -240,7 +240,7 @@ func _test_showcase_site() -> void: var mesa_top := _terrain_height(space, terrain, -80.0, -90.0, []) _expect(mesa_top - canyon_floor >= 18.0, "collision rays find the canyon under the mesa (%.1f m vs %.1f m)" % [canyon_floor, mesa_top]) - # Sensor above the canyon looking along its first leg (the acquisition probes along + # Sensor above the canyon looking along its second leg (the acquisition probes along # local +Z): rock walls and sand floor. var sensor := Transform3D(Basis.IDENTITY, Vector3(-130.0, -15.0, -135.0)) sensor.basis = Basis.looking_at(-Vector3(40.0, 0.0, -30.0).normalized(), Vector3.UP) diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd index f8081e5..e0c3191 100644 --- a/tests/unit/run_site_baker_tests.gd +++ b/tests/unit/run_site_baker_tests.gd @@ -8,7 +8,7 @@ const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") const ZONE1_TERRAIN_PATH := "res://content/sites/zone1_vigilant_terrain.res" -const TEST_COUNT := 40 +const TEST_COUNT := 44 var _failures: Array[String] = [] @@ -121,16 +121,18 @@ func _test_modifiers_and_primitive_materials() -> void: func _test_canyon() -> void: var canyon := SiteTerrainBaker.Canyon.new( PackedVector2Array([Vector2(-20.0, 0.0), Vector2(0.0, 0.0), Vector2(0.0, 20.0)]), - 4.0, 10.0, 1.0, 0, 0.0 + 4.0, 10.0, 1.0, 0, 0.0, SonarMaterial.Value.SAND ) _expect(is_equal_approx(canyon.elevation_delta(-10.0, 0.0), -10.0), "canyon floor sits at depth on the first leg") + _expect(canyon.material_at(-10.0, 0.0) == SonarMaterial.Value.SAND, "canyon imposes its floor material on the flat floor") + _expect(canyon.material_at(-10.0, 4.0) == SiteTerrainBaker.NO_MATERIAL, "canyon leaves its walls to the slope rule") _expect(is_equal_approx(canyon.elevation_delta(1.5, 10.0), -10.0), "canyon floor is flat across its width on the second leg") _expect(is_equal_approx(canyon.elevation_delta(-10.0, 6.0), 0.0), "canyon stops past half width plus softness") _expect(is_equal_approx(canyon.elevation_delta(15.0, -15.0), 0.0), "canyon is zero far from the path") _expect(is_equal_approx(canyon.elevation_delta(0.0, 20.0), 0.0), "canyon closes at its last vertex") _expect(canyon.elevation_delta(-10.0, 4.0) < -4.0 and canyon.elevation_delta(-10.0, 4.0) > -6.0, "canyon wall is half way down at half width") var rough := SiteTerrainBaker.Canyon.new( - PackedVector2Array([Vector2(-200.0, 0.0), Vector2(200.0, 0.0)]), 8.0, 10.0, 2.0, 7, 3.0 + PackedVector2Array([Vector2(-200.0, 0.0), Vector2(200.0, 0.0)]), 8.0, 10.0, 2.0, 7, 3.0, SiteTerrainBaker.NO_MATERIAL ) var wall_values: Dictionary = {} for x in range(-100, 101, 10): diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd index 4cc2220..7281613 100644 --- a/tools/site_terrain_baker.gd +++ b/tools/site_terrain_baker.gd @@ -170,13 +170,15 @@ class Groove extends Primitive: ## Flat-floored trench along a polyline. The floor sits depth_m below the surroundings ## wherever the (noise-perturbed) distance to the path is under half_width_m minus ## wall_softness_m; the walls climb over 2 * wall_softness_m; the trench closes over one -## half-width at each end. +## half-width at each end. floor_material (a SonarMaterial value, or NO_MATERIAL) is +## imposed on the flat floor, so a canyon cut through a rock outcrop keeps a sand bed. class Canyon extends Primitive: var path: PackedVector2Array var half_width_m: float var depth_m: float var wall_softness_m: float var edge_amplitude_m: float + var floor_material: int var _noise: FastNoiseLite var _length_m: float @@ -186,17 +188,31 @@ class Canyon extends Primitive: p_depth_m: float, p_wall_softness_m: float, p_edge_seed: int, - p_edge_amplitude_m: float + p_edge_amplitude_m: float, + p_floor_material: int ) -> void: + assert(p_path.size() >= 2, "a canyon path needs at least two vertices") + assert(p_half_width_m > 0.0, "a canyon needs a positive half width") path = p_path half_width_m = p_half_width_m depth_m = p_depth_m wall_softness_m = p_wall_softness_m edge_amplitude_m = p_edge_amplitude_m + floor_material = p_floor_material _noise = edge_noise(p_edge_seed, p_edge_amplitude_m) _length_m = path_length(p_path) func elevation_delta(x_m: float, z_m: float) -> float: + return -depth_m * _presence(x_m, z_m) + + func material_at(x_m: float, z_m: float) -> int: + # Full presence means the flat floor: inside the walls and clear of the end tapers. + if floor_material != NO_MATERIAL and _presence(x_m, z_m) >= 0.999: + return floor_material + return NO_MATERIAL + + ## 1 on the flat floor, 0 outside the trench, in between on the walls and end tapers. + func _presence(x_m: float, z_m: float) -> float: var sample := path_sample(Vector2(x_m, z_m), path) var distance := sample.x + edge_offset(_noise, edge_amplitude_m, x_m, z_m) var inside := 1.0 - smoothstep( @@ -205,13 +221,14 @@ class Canyon extends Primitive: distance ) var taper := smoothstep(0.0, half_width_m, sample.y) * smoothstep(0.0, half_width_m, _length_m - sample.y) - return -depth_m * inside * taper + return inside * taper ## Step along a polyline: the terrain drops by drop_m on the right of the travel direction ## (south of an eastbound path), over 2 * softness_m, then keeps falling at slope_beyond. -## The crest line is roughened by outline noise. Extend the path past the field edges: -## beyond its ends the contour wraps around the end vertex. +## The crest line is roughened by outline noise. The path must extend past the field +## edges: beyond an end vertex the drop is decided by the half-plane of the last segment, +## which leaves a seam along the path's extension. class Escarpment extends Primitive: var path: PackedVector2Array var drop_m: float @@ -228,6 +245,7 @@ class Escarpment extends Primitive: p_edge_seed: int, p_edge_amplitude_m: float ) -> void: + assert(p_path.size() >= 2, "an escarpment path needs at least two vertices") path = p_path drop_m = p_drop_m softness_m = p_softness_m @@ -267,6 +285,7 @@ class Outcrop extends Primitive: p_edge_amplitude_m: float, p_rock_top: bool ) -> void: + assert(p_radius_x_m > 0.0 and p_radius_z_m > 0.0, "an outcrop needs positive radii") centre = p_centre radius_x_m = p_radius_x_m radius_z_m = p_radius_z_m @@ -435,6 +454,7 @@ class Strata extends Modifier: p_centre: Vector2, p_radius_m: float ) -> void: + assert(p_layer_height_m > 0.0, "strata need a positive layer height") layer_height_m = p_layer_height_m sharpness = clampf(p_sharpness, 0.0, 0.999) centre = p_centre diff --git a/tools/sites/terrain_showcase_recipe.gd b/tools/sites/terrain_showcase_recipe.gd index 4908593..377165f 100644 --- a/tools/sites/terrain_showcase_recipe.gd +++ b/tools/sites/terrain_showcase_recipe.gd @@ -5,6 +5,7 @@ extends RefCounted ## mooring beacon at the centre. Field 512 x 512 m at 1 m, +X east, +Z south. const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") const SitePlacement = preload("res://game/domain/sites/site_placement.gd") @@ -59,15 +60,15 @@ static func recipe() -> SiteTerrainBaker.Recipe: # modifier (one ledge every 2.8 m across, resolvable at 1 m per cell), rippled # sand on the floor. SiteTerrainBaker.Outcrop.new(MESA_CENTRE, 95.0, 85.0, 6.0, 10.0, 101, 6.0, true), - SiteTerrainBaker.Canyon.new(PackedVector2Array(CANYON_PATH), CANYON_HALF_WIDTH_M, CANYON_DEPTH_M, 7.0, 102, 2.0), + SiteTerrainBaker.Canyon.new(PackedVector2Array(CANYON_PATH), CANYON_HALF_WIDTH_M, CANYON_DEPTH_M, 7.0, 102, 2.0, SonarMaterial.Value.SAND), SiteTerrainBaker.Ripples.new(Vector2(0.8, -0.6), 4.0, 0.25, Vector2(-130.0, -140.0), 70.0), # NE — la Fente: two buttes 8-9 m high with a 6 m wide, 20 m deep slot between them. SiteTerrainBaker.Outcrop.new(WEST_BUTTE_CENTRE, 38.0, 70.0, 8.0, 8.0, 201, 5.0, true), SiteTerrainBaker.Outcrop.new(EAST_BUTTE_CENTRE, 38.0, 75.0, 9.0, 8.0, 202, 5.0, true), - SiteTerrainBaker.Canyon.new(PackedVector2Array(CREVASSE_PATH), CREVASSE_HALF_WIDTH_M, CREVASSE_DEPTH_M, 1.0, 203, 1.0), + SiteTerrainBaker.Canyon.new(PackedVector2Array(CREVASSE_PATH), CREVASSE_HALF_WIDTH_M, CREVASSE_DEPTH_M, 1.0, 203, 1.0, SonarMaterial.Value.SAND), - # SW — le Chaos: ninety boulders 2-6 m across plus six coral heads, on rippled sand. + # SW — le Chaos: ninety boulders of 2-6 m radius plus six coral heads, on rippled sand. SiteTerrainBaker.BoulderField.new(BOULDER_FIELD_CENTRE, BOULDER_FIELD_RADIUS_M, 301, 90, 2.0, 6.0, 0.7), SiteTerrainBaker.BoulderField.new(Vector2(-100.0, 130.0), 40.0, 302, 6, 6.0, 9.0, 0.6), SiteTerrainBaker.Ripples.new(Vector2(1.0, 0.3), 3.5, 0.3, BOULDER_FIELD_CENTRE, 110.0),