From 6dd2e86f91ebbe2d73b54aa2ce48490763b9d796 Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 6 Oct 2026 21:38:32 +0200 Subject: [PATCH] feat(tools): Outcrop and BoulderField primitives with forced rock material Co-Authored-By: Claude Fable 5.1 --- tests/unit/run_site_baker_tests.gd | 32 ++++++++- tools/site_terrain_baker.gd | 104 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/tests/unit/run_site_baker_tests.gd b/tests/unit/run_site_baker_tests.gd index 6103717..f81e68f 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 := 26 +const TEST_COUNT := 36 var _failures: Array[String] = [] @@ -20,6 +20,8 @@ func _init() -> void: _test_zone1_artefact_is_stable() _test_canyon() _test_escarpment() + _test_outcrop() + _test_boulder_field() if _failures.is_empty(): print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT) @@ -144,6 +146,34 @@ func _test_escarpment() -> void: _expect(is_equal_approx(cliff.elevation_delta(0.0, 12.0), -15.0), "escarpment keeps falling at slope_beyond") +func _test_outcrop() -> void: + var mesa := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 10.0, 6.0, 4.0, 0, 0.0, true) + _expect(is_equal_approx(mesa.elevation_delta(0.0, 0.0), 6.0), "outcrop top is flat at height_m") + _expect(is_equal_approx(mesa.elevation_delta(0.0, 8.0), 3.0), "outcrop flank is half way down in the middle of the wall") + _expect(is_equal_approx(mesa.elevation_delta(0.0, 25.0), 0.0), "outcrop is zero past its outline") + _expect(mesa.material_at(0.0, 0.0) == SonarMaterial.Value.ROCK, "rock_top makes the flat summit rock") + _expect(mesa.material_at(0.0, 25.0) == SiteTerrainBaker.NO_MATERIAL, "outcrop leaves the surroundings to the slope rule") + var sand_top := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 10.0, 6.0, 4.0, 0, 0.0, false) + _expect(sand_top.material_at(0.0, 0.0) == SiteTerrainBaker.NO_MATERIAL, "without rock_top the summit follows the slope rule") + var pit := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 20.0, -5.0, 3.0, 0, 0.0, false) + _expect(is_equal_approx(pit.elevation_delta(0.0, 0.0), -5.0), "negative height digs a flat-bottomed pit") + + +func _test_boulder_field() -> void: + var first := SiteTerrainBaker.BoulderField.new(Vector2(50.0, -50.0), 30.0, 11, 20, 2.0, 4.0, 0.8) + var second := SiteTerrainBaker.BoulderField.new(Vector2(50.0, -50.0), 30.0, 11, 20, 2.0, 4.0, 0.8) + _expect(first.boulders() == second.boulders(), "same seed scatters the same boulders") + var all_inside := true + for boulder in first.boulders(): + all_inside = all_inside and Vector2(boulder.x, boulder.y).distance_to(Vector2(50.0, -50.0)) <= 30.0 + _expect(all_inside and first.boulders().size() == 20, "boulders stay inside the field disc") + var top := first.boulders()[0] + _expect(first.elevation_delta(top.x, top.y) >= top.z * 0.8 - 0.001, "a boulder centre is at least its own height") + _expect(first.material_at(top.x, top.y) == SonarMaterial.Value.ROCK and first.material_at(200.0, 200.0) == SiteTerrainBaker.NO_MATERIAL, "boulders are rock, the sand around is not forced") + var stacked := SiteTerrainBaker.BoulderField.new(Vector2.ZERO, 0.0, 3, 5, 2.0, 4.0, 1.0) + _expect(stacked.elevation_delta(0.0, 0.0) <= 4.0 + 0.001, "overlapping boulders combine by maximum, not by sum") + + func _test_zone1_artefact_is_stable() -> void: var versioned := load(ZONE1_TERRAIN_PATH) as SiteTerrainData _expect(versioned != null, "versioned zone 1 terrain loads") diff --git a/tools/site_terrain_baker.gd b/tools/site_terrain_baker.gd index 94d46bd..2ec7ce4 100644 --- a/tools/site_terrain_baker.gd +++ b/tools/site_terrain_baker.gd @@ -243,6 +243,110 @@ class Escarpment extends Primitive: return -(step + slope) +## Flat-topped elliptical mound (or, with a negative height, a flat-bottomed pit) whose +## flank spans wall_width_m inside a noise-roughened outline. With rock_top the whole +## mound above 30 % of its height is rock, sommet plat compris. +class Outcrop extends Primitive: + var centre: Vector2 + var radius_x_m: float + var radius_z_m: float + var height_m: float + var wall_width_m: float + var edge_amplitude_m: float + var rock_top: bool + var _noise: FastNoiseLite + var _mean_radius_m: float + + func _init( + p_centre: Vector2, + p_radius_x_m: float, + p_radius_z_m: float, + p_height_m: float, + p_wall_width_m: float, + p_edge_seed: int, + p_edge_amplitude_m: float, + p_rock_top: bool + ) -> void: + centre = p_centre + radius_x_m = p_radius_x_m + radius_z_m = p_radius_z_m + height_m = p_height_m + wall_width_m = p_wall_width_m + edge_amplitude_m = p_edge_amplitude_m + rock_top = p_rock_top + _noise = edge_noise(p_edge_seed, p_edge_amplitude_m) + _mean_radius_m = (p_radius_x_m + p_radius_z_m) * 0.5 + + func elevation_delta(x_m: float, z_m: float) -> float: + var dx := (x_m - centre.x) / radius_x_m + var dz := (z_m - centre.y) / radius_z_m + var edge_distance := (sqrt(dx * dx + dz * dz) - 1.0) * _mean_radius_m + edge_distance += edge_offset(_noise, edge_amplitude_m, x_m, z_m) + return height_m * (1.0 - smoothstep(-wall_width_m, 0.0, edge_distance)) + + func material_at(x_m: float, z_m: float) -> int: + if rock_top and absf(elevation_delta(x_m, z_m)) > 0.3 * absf(height_m): + return SonarMaterial.Value.ROCK + return NO_MATERIAL + + +## count half-ellipsoid boulders scattered uniformly in a disc by a seeded generator. +## Boulders combine by maximum, so overlapping blocks never pile into one mound, and every +## cell they cover is rock. +class BoulderField extends Primitive: + var centre: Vector2 + var radius_m: float + var height_ratio: float + var _boulders := PackedVector3Array() # x, z, radius + var _reach_m: float + + func _init( + p_centre: Vector2, + p_radius_m: float, + p_seed: int, + p_count: int, + p_boulder_radius_min_m: float, + p_boulder_radius_max_m: float, + p_height_ratio: float + ) -> void: + centre = p_centre + radius_m = p_radius_m + height_ratio = p_height_ratio + _reach_m = p_radius_m + p_boulder_radius_max_m + var generator := RandomNumberGenerator.new() + generator.seed = p_seed + for _index in p_count: + var distance := p_radius_m * sqrt(generator.randf()) + var angle := TAU * generator.randf() + var boulder_radius := generator.randf_range(p_boulder_radius_min_m, p_boulder_radius_max_m) + _boulders.append(Vector3( + p_centre.x + distance * cos(angle), + p_centre.y + distance * sin(angle), + boulder_radius + )) + + func boulders() -> PackedVector3Array: + return _boulders + + func elevation_delta(x_m: float, z_m: float) -> float: + if Vector2(x_m, z_m).distance_to(centre) > _reach_m: + return 0.0 + var highest := 0.0 + for boulder in _boulders: + var dx := x_m - boulder.x + var dz := z_m - boulder.y + var squared := dx * dx + dz * dz + var radius_squared := boulder.z * boulder.z + if squared < radius_squared: + highest = maxf(highest, boulder.z * height_ratio * sqrt(1.0 - squared / radius_squared)) + return highest + + func material_at(x_m: float, z_m: float) -> int: + if elevation_delta(x_m, z_m) > 0.0: + return SonarMaterial.Value.ROCK + return NO_MATERIAL + + class Step extends Primitive: var point: Vector2 var normal: Vector2