Strata now take a world rectangle, so the 4 m ledges cover the whole break and talus south of z = 118 m without touching la Saignée or the landmarks. Roughness gains octave and fractal-type parameters; a SlopeRoughness modifier roughens rock faces only. Both sites are re-baked with hummocks from 250 m down to 5 m. The secondary item, the first debris piece and one safe-route waypoint move a few metres to keep their contracts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
264 lines
13 KiB
GDScript
264 lines
13 KiB
GDScript
extends SceneTree
|
|
|
|
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
|
const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd")
|
|
const VehicleTestRecipe = preload("res://tools/sites/vehicle_test_terrain_recipe.gd")
|
|
const Zone1Recipe = preload("res://tools/sites/zone1_vigilant_recipe.gd")
|
|
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
|
|
|
|
const ZONE1_TERRAIN_PATH := "res://content/sites/zone1_vigilant_terrain.res"
|
|
|
|
const TEST_COUNT := 48
|
|
|
|
var _failures: Array[String] = []
|
|
|
|
|
|
func _init() -> void:
|
|
_test_plane_and_blob()
|
|
_test_materials_and_determinism()
|
|
_test_modifiers_and_primitive_materials()
|
|
_test_zone1_artefact_is_stable()
|
|
_test_canyon()
|
|
_test_escarpment()
|
|
_test_outcrop()
|
|
_test_boulder_field()
|
|
_test_strata()
|
|
_test_slope_roughness()
|
|
|
|
if _failures.is_empty():
|
|
print("Site baker unit tests: %d passed, 0 failed." % TEST_COUNT)
|
|
quit(0)
|
|
else:
|
|
for failure: String in _failures:
|
|
push_error(failure)
|
|
print("Site baker unit tests: %d failed." % _failures.size())
|
|
quit(1)
|
|
|
|
|
|
func _small_recipe() -> SiteTerrainBaker.Recipe:
|
|
var recipe := SiteTerrainBaker.Recipe.new()
|
|
recipe.columns = 21
|
|
recipe.rows = 21
|
|
recipe.cell_size_m = 2.0
|
|
recipe.origin_x_m = -20.0
|
|
recipe.origin_z_m = -20.0
|
|
recipe.floor_elevation_m = -80.0
|
|
recipe.rock_slope_degrees = 20.0
|
|
return recipe
|
|
|
|
|
|
func _test_plane_and_blob() -> void:
|
|
var recipe := _small_recipe()
|
|
recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.1, 0.0, 0.0))
|
|
var data := SiteTerrainBaker.bake(recipe)
|
|
_expect(data != null and data.validation_error().is_empty(), "plane recipe bakes valid data")
|
|
_expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -30.0), "plane depth at the reference point")
|
|
_expect(is_equal_approx(data.elevations_m[data.index_of(10, 20)], -28.0), "plane slopes along +Z")
|
|
_expect(data.materials[data.index_of(10, 10)] == SonarMaterial.Value.SAND, "gentle plane is sand")
|
|
|
|
recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(0.0, 0.0), 4.0, 4.0, 10.0))
|
|
data = SiteTerrainBaker.bake(recipe)
|
|
_expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -20.0), "blob adds its amplitude at the centre")
|
|
_expect(data.materials[data.index_of(12, 10)] == SonarMaterial.Value.ROCK, "steep blob flank is rock")
|
|
|
|
|
|
func _test_materials_and_determinism() -> void:
|
|
var recipe := _small_recipe()
|
|
recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0))
|
|
recipe.primitives.append(SiteTerrainBaker.Roughness.new(1933, 0.05, 0.5))
|
|
recipe.material_patches.append(
|
|
SiteTerrainBaker.MaterialPatch.new(Vector2(10.0, 10.0), 3.0, SonarMaterial.Value.ROCK)
|
|
)
|
|
var first := SiteTerrainBaker.bake(recipe)
|
|
var second := SiteTerrainBaker.bake(recipe)
|
|
_expect(first.elevations_m == second.elevations_m, "same recipe bakes identical elevations")
|
|
_expect(first.materials == second.materials, "same recipe bakes identical materials")
|
|
_expect(first.materials[first.index_of(15, 15)] == SonarMaterial.Value.ROCK, "material patch overrides the slope rule")
|
|
var calibration := SiteTerrainBaker.bake(VehicleTestRecipe.recipe())
|
|
_expect(calibration != null and calibration.validation_error().is_empty(), "calibration recipe bakes valid data")
|
|
|
|
|
|
## Test doubles for the extension points: a modifier that lifts the field by one metre
|
|
## and a primitive that forces rock on a flat plane.
|
|
class Lift extends SiteTerrainBaker.Modifier:
|
|
func modify(_x_m: float, _z_m: float, elevation_m: float, _gradient: Vector2) -> float:
|
|
return elevation_m + 1.0
|
|
|
|
|
|
class GradientProbe extends SiteTerrainBaker.Modifier:
|
|
var gradient_at_centre := Vector2.ZERO
|
|
|
|
func modify(x_m: float, z_m: float, elevation_m: float, gradient: Vector2) -> float:
|
|
if is_zero_approx(x_m) and is_zero_approx(z_m):
|
|
gradient_at_centre = gradient
|
|
return elevation_m
|
|
|
|
|
|
class RockDisc extends SiteTerrainBaker.Primitive:
|
|
func material_at(x_m: float, z_m: float) -> int:
|
|
if Vector2(x_m, z_m).length() <= 5.0:
|
|
return SonarMaterial.Value.ROCK
|
|
return SiteTerrainBaker.NO_MATERIAL
|
|
|
|
|
|
func _test_modifiers_and_primitive_materials() -> void:
|
|
var recipe := _small_recipe()
|
|
recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.2, 0.0, 0.0, 0.0))
|
|
var probe := GradientProbe.new()
|
|
recipe.modifiers.append(probe)
|
|
recipe.modifiers.append(Lift.new())
|
|
var data := SiteTerrainBaker.bake(recipe)
|
|
_expect(is_equal_approx(data.elevations_m[data.index_of(10, 10)], -29.0), "modifier runs after the primitive sum")
|
|
_expect(probe.gradient_at_centre.is_equal_approx(Vector2(0.2, 0.0)), "modifier receives the local gradient (%s)" % probe.gradient_at_centre)
|
|
|
|
var flat := _small_recipe()
|
|
flat.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0))
|
|
flat.primitives.append(RockDisc.new())
|
|
var flat_data := SiteTerrainBaker.bake(flat)
|
|
_expect(flat_data.materials[flat_data.index_of(10, 10)] == SonarMaterial.Value.ROCK, "primitive material wins over the slope rule")
|
|
_expect(flat_data.materials[flat_data.index_of(0, 0)] == SonarMaterial.Value.SAND, "primitive material is local")
|
|
|
|
|
|
func _test_canyon() -> void:
|
|
var canyon := SiteTerrainBaker.Canyon.new(
|
|
PackedVector2Array([Vector2(-20.0, 0.0), Vector2(0.0, 0.0), Vector2(0.0, 20.0)]),
|
|
4.0, 10.0, 1.0, 0, 0.0, SonarMaterial.Value.SAND
|
|
)
|
|
_expect(is_equal_approx(canyon.elevation_delta(-10.0, 0.0), -10.0), "canyon floor sits at depth on the first leg")
|
|
_expect(canyon.material_at(-10.0, 0.0) == SonarMaterial.Value.SAND, "canyon imposes its floor material on the flat floor")
|
|
_expect(canyon.material_at(-10.0, 4.0) == SiteTerrainBaker.NO_MATERIAL, "canyon leaves its walls to the slope rule")
|
|
_expect(is_equal_approx(canyon.elevation_delta(1.5, 10.0), -10.0), "canyon floor is flat across its width on the second leg")
|
|
_expect(is_equal_approx(canyon.elevation_delta(-10.0, 6.0), 0.0), "canyon stops past half width plus softness")
|
|
_expect(is_equal_approx(canyon.elevation_delta(15.0, -15.0), 0.0), "canyon is zero far from the path")
|
|
_expect(is_equal_approx(canyon.elevation_delta(0.0, 20.0), 0.0), "canyon closes at its last vertex")
|
|
_expect(canyon.elevation_delta(-10.0, 4.0) < -4.0 and canyon.elevation_delta(-10.0, 4.0) > -6.0, "canyon wall is half way down at half width")
|
|
var rough := SiteTerrainBaker.Canyon.new(
|
|
PackedVector2Array([Vector2(-200.0, 0.0), Vector2(200.0, 0.0)]), 8.0, 10.0, 2.0, 7, 3.0, SiteTerrainBaker.NO_MATERIAL
|
|
)
|
|
var wall_values: Dictionary = {}
|
|
for x in range(-100, 101, 10):
|
|
wall_values[snappedf(rough.elevation_delta(float(x), 8.0), 0.01)] = true
|
|
_expect(wall_values.size() > 3, "outline noise makes the canyon wall irregular along the path")
|
|
|
|
|
|
func _test_escarpment() -> void:
|
|
var cliff := SiteTerrainBaker.Escarpment.new(
|
|
PackedVector2Array([Vector2(-50.0, 0.0), Vector2(50.0, 0.0)]), 10.0, 2.0, 0.5, 0, 0.0
|
|
)
|
|
_expect(is_equal_approx(cliff.elevation_delta(0.0, -10.0), 0.0), "escarpment leaves the left (north) side alone")
|
|
_expect(is_equal_approx(cliff.elevation_delta(0.0, 2.0), -10.0), "escarpment drops by drop_m on the right (south) side")
|
|
_expect(is_equal_approx(cliff.elevation_delta(0.0, 12.0), -15.0), "escarpment keeps falling at slope_beyond")
|
|
|
|
|
|
func _test_outcrop() -> void:
|
|
var mesa := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 10.0, 6.0, 4.0, 0, 0.0, true)
|
|
_expect(is_equal_approx(mesa.elevation_delta(0.0, 0.0), 6.0), "outcrop top is flat at height_m")
|
|
_expect(is_equal_approx(mesa.elevation_delta(0.0, 8.0), 3.0), "outcrop flank is half way down in the middle of the wall")
|
|
_expect(is_equal_approx(mesa.elevation_delta(0.0, 25.0), 0.0), "outcrop is zero past its outline")
|
|
_expect(mesa.material_at(0.0, 0.0) == SonarMaterial.Value.ROCK, "rock_top makes the flat summit rock")
|
|
_expect(mesa.material_at(0.0, 25.0) == SiteTerrainBaker.NO_MATERIAL, "outcrop leaves the surroundings to the slope rule")
|
|
var sand_top := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 10.0, 6.0, 4.0, 0, 0.0, false)
|
|
_expect(sand_top.material_at(0.0, 0.0) == SiteTerrainBaker.NO_MATERIAL, "without rock_top the summit follows the slope rule")
|
|
var pit := SiteTerrainBaker.Outcrop.new(Vector2.ZERO, 10.0, 20.0, -5.0, 3.0, 0, 0.0, false)
|
|
_expect(is_equal_approx(pit.elevation_delta(0.0, 0.0), -5.0), "negative height digs a flat-bottomed pit")
|
|
|
|
|
|
func _test_boulder_field() -> void:
|
|
var first := SiteTerrainBaker.BoulderField.new(Vector2(50.0, -50.0), 30.0, 11, 20, 2.0, 4.0, 0.8)
|
|
var second := SiteTerrainBaker.BoulderField.new(Vector2(50.0, -50.0), 30.0, 11, 20, 2.0, 4.0, 0.8)
|
|
_expect(first.boulders() == second.boulders(), "same seed scatters the same boulders")
|
|
var all_inside := true
|
|
for boulder in first.boulders():
|
|
all_inside = all_inside and Vector2(boulder.x, boulder.y).distance_to(Vector2(50.0, -50.0)) <= 30.0
|
|
_expect(all_inside and first.boulders().size() == 20, "boulders stay inside the field disc")
|
|
var top := first.boulders()[0]
|
|
_expect(first.elevation_delta(top.x, top.y) >= top.z * 0.8 - 0.001, "a boulder centre is at least its own height")
|
|
_expect(first.material_at(top.x, top.y) == SonarMaterial.Value.ROCK and first.material_at(200.0, 200.0) == SiteTerrainBaker.NO_MATERIAL, "boulders are rock, the sand around is not forced")
|
|
var stacked := SiteTerrainBaker.BoulderField.new(Vector2.ZERO, 0.0, 3, 5, 2.0, 4.0, 1.0)
|
|
_expect(stacked.elevation_delta(0.0, 0.0) <= 4.0 + 0.001, "overlapping boulders combine by maximum, not by sum")
|
|
|
|
|
|
func _test_strata() -> void:
|
|
# A 45 degree plane sampled every 0.5 m: four cells per 2 m layer.
|
|
var recipe := _small_recipe()
|
|
recipe.columns = 41
|
|
recipe.rows = 41
|
|
recipe.cell_size_m = 0.5
|
|
recipe.origin_x_m = -10.0
|
|
recipe.origin_z_m = -10.0
|
|
recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 1.0, 0.0, 0.0, 0.0))
|
|
var plain := SiteTerrainBaker.bake(recipe)
|
|
recipe.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2()))
|
|
var terraced := SiteTerrainBaker.bake(recipe)
|
|
var flat_cells := 0
|
|
var riser_cells := 0
|
|
for column in range(5, 35):
|
|
var rise := terraced.elevations_m[terraced.index_of(column + 1, 20)] - terraced.elevations_m[terraced.index_of(column, 20)]
|
|
if rise < 0.1:
|
|
flat_cells += 1
|
|
elif rise > 0.9:
|
|
riser_cells += 1
|
|
_expect(flat_cells >= 8 and riser_cells >= 4, "strata turn a steep slope into ledges and risers (%d flat, %d risers)" % [flat_cells, riser_cells])
|
|
_expect(is_equal_approx(terraced.elevations_m[terraced.index_of(20, 20)], plain.elevations_m[plain.index_of(20, 20)]), "strata keep the layer boundaries where they were")
|
|
|
|
var flat := _small_recipe()
|
|
flat.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.3, 0.0, 0.0, 0.0, 0.0))
|
|
flat.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2()))
|
|
var flat_data := SiteTerrainBaker.bake(flat)
|
|
_expect(is_equal_approx(flat_data.elevations_m[flat_data.index_of(10, 10)], -30.3), "strata leave flat ground alone")
|
|
|
|
var identity := _small_recipe()
|
|
identity.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 1.0, 0.0, 0.0, 0.0))
|
|
var before := SiteTerrainBaker.bake(identity)
|
|
identity.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.0, 20.0, 35.0, Rect2()))
|
|
var after := SiteTerrainBaker.bake(identity)
|
|
_expect(before.elevations_m == after.elevations_m, "sharpness 0 is the identity")
|
|
|
|
# Depth 30.7 keeps the plane off the layer boundaries, where quantisation is the identity.
|
|
var banded := _small_recipe()
|
|
banded.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.7, 1.0, 0.0, 0.0, 0.0))
|
|
var untouched := SiteTerrainBaker.bake(banded)
|
|
banded.modifiers.append(SiteTerrainBaker.Strata.new(2.0, 0.9, 20.0, 35.0, Rect2(-20.0, 0.0, 40.0, 20.0)))
|
|
var half := SiteTerrainBaker.bake(banded)
|
|
var north_same := half.elevations_m[half.index_of(13, 5)] == untouched.elevations_m[untouched.index_of(13, 5)]
|
|
var south_changed := half.elevations_m[half.index_of(13, 15)] != untouched.elevations_m[untouched.index_of(13, 15)]
|
|
_expect(north_same and south_changed, "strata stay inside their region")
|
|
|
|
|
|
func _test_slope_roughness() -> void:
|
|
var recipe := _small_recipe()
|
|
recipe.primitives.append(SiteTerrainBaker.SlopedPlane.new(30.0, 0.0, 0.0, 0.0, 0.0))
|
|
recipe.primitives.append(SiteTerrainBaker.Blob.new(Vector2(10.0, 10.0), 5.0, 5.0, 12.0))
|
|
var smooth := SiteTerrainBaker.bake(recipe)
|
|
recipe.modifiers.append(SiteTerrainBaker.SlopeRoughness.new(5, 0.2, 1.0, 20.0, 40.0))
|
|
var rough := SiteTerrainBaker.bake(recipe)
|
|
_expect(rough.elevations_m[rough.index_of(0, 0)] == smooth.elevations_m[smooth.index_of(0, 0)], "slope roughness leaves flat sand untouched")
|
|
var flank_changed := false
|
|
for column in range(13, 18):
|
|
flank_changed = flank_changed or rough.elevations_m[rough.index_of(column, 15)] != smooth.elevations_m[smooth.index_of(column, 15)]
|
|
_expect(flank_changed, "slope roughness roughens the steep flank")
|
|
var octaves := _small_recipe()
|
|
octaves.primitives.append(SiteTerrainBaker.Roughness.new(7, 0.01, 2.0, 5, FastNoiseLite.FRACTAL_RIDGED))
|
|
var ridged := SiteTerrainBaker.bake(octaves)
|
|
var within := true
|
|
for elevation: float in ridged.elevations_m:
|
|
within = within and absf(elevation) <= 2.0 + 0.001
|
|
_expect(within, "roughness amplitude bounds the ridged multi-octave noise")
|
|
|
|
|
|
func _test_zone1_artefact_is_stable() -> void:
|
|
var versioned := load(ZONE1_TERRAIN_PATH) as SiteTerrainData
|
|
_expect(versioned != null, "versioned zone 1 terrain loads")
|
|
if versioned == null:
|
|
return
|
|
var baked := SiteTerrainBaker.bake(Zone1Recipe.recipe())
|
|
_expect(
|
|
baked.elevations_m == versioned.elevations_m and baked.materials == versioned.materials,
|
|
"zone 1 recipe still bakes the versioned artefact byte for byte"
|
|
)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
if not condition:
|
|
_failures.append(label)
|