Expand generated terrain with biome-driven props
This commit is contained in:
parent
e8888ed05a
commit
1e24fb0bfc
103 changed files with 5427 additions and 351 deletions
|
|
@ -4,7 +4,37 @@ const RegionScene: PackedScene = preload(
|
|||
"res://world/generation/generated_world_region.tscn"
|
||||
)
|
||||
const FIRST_SEED := 13001
|
||||
const SECOND_SEED := 13002
|
||||
const SECOND_SEED := 13004
|
||||
const EXPECTED_PROP_IDS: Array[StringName] = [
|
||||
&"prop_bridge",
|
||||
&"prop_dock",
|
||||
&"prop_log",
|
||||
&"prop_log_post_1",
|
||||
&"prop_log_post_2",
|
||||
&"prop_mushroom",
|
||||
&"prop_palm",
|
||||
&"prop_pine",
|
||||
&"prop_timber_1",
|
||||
&"prop_timber_2",
|
||||
&"prop_tree_1",
|
||||
&"prop_tree_2",
|
||||
&"prop_tree_3",
|
||||
]
|
||||
const EXPECTED_BIOME_IDS: Array[StringName] = [
|
||||
&"biome_plains",
|
||||
&"biome_forest",
|
||||
&"biome_pine_forest",
|
||||
&"biome_coast",
|
||||
]
|
||||
const PROCEDURAL_PROP_IDS: Array[StringName] = [
|
||||
&"prop_log",
|
||||
&"prop_mushroom",
|
||||
&"prop_palm",
|
||||
&"prop_pine",
|
||||
&"prop_tree_1",
|
||||
&"prop_tree_2",
|
||||
&"prop_tree_3",
|
||||
]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -26,15 +56,20 @@ func _run() -> void:
|
|||
_validate_generated_region(region, generator)
|
||||
|
||||
var first_layout := generator.placement_keys()
|
||||
var first_biomes := _biome_signature(region, generator)
|
||||
var first_decorations := _decoration_signature(region)
|
||||
assert(region.generate_world(FIRST_SEED))
|
||||
assert(generator.placement_keys() == first_layout)
|
||||
assert(_biome_signature(region, generator) == first_biomes)
|
||||
assert(_decoration_signature(region) == first_decorations)
|
||||
|
||||
assert(region.generate_world(SECOND_SEED))
|
||||
await process_frame
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
_validate_generated_region(region, generator)
|
||||
assert(generator.placement_keys() != first_layout)
|
||||
assert(_biome_signature(region, generator) != first_biomes)
|
||||
|
||||
region.queue_free()
|
||||
await process_frame
|
||||
|
|
@ -47,14 +82,18 @@ func _validate_generated_region(
|
|||
generator: TerrainChunkGenerator,
|
||||
) -> void:
|
||||
var placements := generator.placement_keys()
|
||||
assert(placements.size() == 25)
|
||||
assert(placements[12].begins_with("chunk_spawn@"))
|
||||
assert(placements.size() == 49)
|
||||
assert(placements[24].begins_with("chunk_spawn@"))
|
||||
assert(_placement_count(placements, "chunk_spawn") == 1)
|
||||
assert(_placement_count(placements, "chunk_0001") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0002") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0003") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0004") >= 1)
|
||||
assert(generator.get_generated_chunks_root().get_child_count() == 25)
|
||||
assert(_placement_count(placements, "chunk_0005") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0006") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0007") >= 1)
|
||||
assert(generator.get_generated_chunks_root().get_child_count() == 49)
|
||||
assert(generator.get_primary_terrain_meshes().size() == 49)
|
||||
|
||||
var shop := region.get_node("Interactables/FishingShopWorld") as Node3D
|
||||
var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D
|
||||
|
|
@ -65,6 +104,8 @@ func _validate_generated_region(
|
|||
assert(region.get_fishing_shop() != null)
|
||||
assert(region.get_player_storage() != null)
|
||||
assert(region.get_player_spawn_transform().origin.y > 0.0)
|
||||
_validate_prop_catalog(region)
|
||||
_validate_biome_catalog(region, generator)
|
||||
|
||||
var ocean := region.get_node("WaterBodies/OceanWater") as WaterBodyAuthoring
|
||||
var fresh_root := region.get_node("WaterBodies/FreshWaterBodies") as Node3D
|
||||
|
|
@ -73,6 +114,8 @@ func _validate_generated_region(
|
|||
var fresh_placement_count := 0
|
||||
for record: Dictionary in generator.placement_records():
|
||||
var tags: PackedStringArray = record.get("tags", PackedStringArray())
|
||||
if "coast" in tags:
|
||||
_validate_ocean_facing_record(record, generator.grid_size)
|
||||
if "fresh_water" in tags:
|
||||
fresh_placement_count += 1
|
||||
assert(fresh_root.get_child_count() == fresh_placement_count)
|
||||
|
|
@ -95,38 +138,185 @@ func _validate_generated_region(
|
|||
assert(anchors != null)
|
||||
assert(anchors.get_spawn_positions().size() > 0)
|
||||
for child: Node in decorations.get_children():
|
||||
assert(
|
||||
child.name.begins_with("regular_tree_")
|
||||
or child.name.begins_with("palm_tree_")
|
||||
var prop_id := StringName(child.get_meta(&"terrain_prop_id", &""))
|
||||
assert(PROCEDURAL_PROP_IDS.has(prop_id))
|
||||
var definition := region.get_prop_catalog().definition_for_id(prop_id)
|
||||
assert(definition != null)
|
||||
var biome_id := StringName(
|
||||
child.get_meta(&"terrain_biome_id", &"")
|
||||
)
|
||||
_validate_decoration_transform(child as Node3D)
|
||||
assert(_decoration_count(decorations, "regular_tree_") > 0)
|
||||
assert(_decoration_count(decorations, "palm_tree_") > 0)
|
||||
var biome := region.get_biome_catalog().definition_for_id(biome_id)
|
||||
assert(biome != null)
|
||||
var biome_rule := biome.prop_rule_for_group(
|
||||
definition.procedural_group
|
||||
)
|
||||
assert(biome_rule != null and biome_rule.allows_prop(definition))
|
||||
var coordinate: Vector2i = child.get_meta(
|
||||
&"terrain_chunk_coordinate",
|
||||
Vector2i.ZERO,
|
||||
)
|
||||
var center := Vector2i(
|
||||
generator.grid_size.x / 2,
|
||||
generator.grid_size.y / 2,
|
||||
)
|
||||
var spawn_distance := (
|
||||
absi(coordinate.x - center.x)
|
||||
+ absi(coordinate.y - center.y)
|
||||
)
|
||||
assert(spawn_distance >= definition.minimum_spawn_chunk_distance)
|
||||
_validate_decoration_transform(
|
||||
region,
|
||||
child as Node3D,
|
||||
definition,
|
||||
)
|
||||
assert(_decoration_group_count(region, &"grass_tree") > 0)
|
||||
assert(_decoration_group_count(region, &"sand_tree") > 0)
|
||||
assert(
|
||||
_decoration_biome_group_count(
|
||||
region,
|
||||
&"biome_forest",
|
||||
&"grass_tree",
|
||||
) >= 2
|
||||
)
|
||||
assert(
|
||||
_decoration_biome_group_count(
|
||||
region,
|
||||
&"biome_pine_forest",
|
||||
&"grass_tree",
|
||||
) >= 2
|
||||
)
|
||||
|
||||
|
||||
func _validate_decoration_transform(prop: Node3D) -> void:
|
||||
func _validate_ocean_facing_record(
|
||||
record: Dictionary,
|
||||
grid_size: Vector2i,
|
||||
) -> void:
|
||||
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
|
||||
var ocean_edges := int(record.get("ocean_facing_edges", 0))
|
||||
assert(ocean_edges != 0)
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
if (ocean_edges & (1 << edge_value)) == 0:
|
||||
continue
|
||||
var ocean_coordinate := (
|
||||
coordinate
|
||||
+ TerrainChunkTopology.grid_offset(
|
||||
edge_value as TerrainChunkTopology.Edge
|
||||
)
|
||||
)
|
||||
assert(
|
||||
ocean_coordinate.x < 0
|
||||
or ocean_coordinate.y < 0
|
||||
or ocean_coordinate.x >= grid_size.x
|
||||
or ocean_coordinate.y >= grid_size.y
|
||||
)
|
||||
|
||||
|
||||
func _validate_prop_catalog(region: GeneratedWorldRegion) -> void:
|
||||
var catalog := region.get_prop_catalog()
|
||||
assert(catalog != null)
|
||||
assert(catalog.validation_errors().is_empty())
|
||||
assert(catalog.definitions.size() == EXPECTED_PROP_IDS.size())
|
||||
for stable_id: StringName in EXPECTED_PROP_IDS:
|
||||
var definition := catalog.definition_for_id(stable_id)
|
||||
assert(definition != null and definition.packed_scene != null)
|
||||
var instance := definition.packed_scene.instantiate() as Node3D
|
||||
assert(instance != null)
|
||||
assert(instance.position.is_zero_approx())
|
||||
assert(_find_mesh_instance(instance) != null)
|
||||
instance.free()
|
||||
|
||||
|
||||
func _validate_biome_catalog(
|
||||
region: GeneratedWorldRegion,
|
||||
generator: TerrainChunkGenerator,
|
||||
) -> void:
|
||||
var catalog := region.get_biome_catalog()
|
||||
assert(catalog != null)
|
||||
assert(catalog.validation_errors().is_empty())
|
||||
assert(catalog.definitions.size() == EXPECTED_BIOME_IDS.size())
|
||||
var counts: Dictionary[StringName, int] = {}
|
||||
for record: Dictionary in generator.placement_records():
|
||||
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
|
||||
var biome_id := region.get_biome_at(coordinate)
|
||||
var tags: PackedStringArray = record.get("tags", PackedStringArray())
|
||||
if "grass" in tags or "sand" in tags:
|
||||
assert(biome_id != &"")
|
||||
var biome := catalog.definition_for_id(biome_id)
|
||||
assert(biome != null and biome.supports_chunk_tags(tags))
|
||||
counts[biome_id] = counts.get(biome_id, 0) + 1
|
||||
else:
|
||||
assert(biome_id == &"")
|
||||
for biome_id: StringName in EXPECTED_BIOME_IDS:
|
||||
assert(counts.get(biome_id, 0) > 0)
|
||||
var center := Vector2i(
|
||||
generator.grid_size.x / 2,
|
||||
generator.grid_size.y / 2,
|
||||
)
|
||||
assert(region.get_biome_at(center) == &"biome_plains")
|
||||
for child: Node in generator.get_generated_chunks_root().get_children():
|
||||
var coordinate: Vector2i = child.get_meta(
|
||||
&"terrain_chunk_coordinate",
|
||||
Vector2i.ZERO,
|
||||
)
|
||||
assert(
|
||||
StringName(child.get_meta(&"terrain_biome_id", &""))
|
||||
== region.get_biome_at(coordinate)
|
||||
)
|
||||
|
||||
|
||||
func _validate_decoration_transform(
|
||||
region: GeneratedWorldRegion,
|
||||
prop: Node3D,
|
||||
definition: TerrainPropDefinition,
|
||||
) -> void:
|
||||
assert(prop != null)
|
||||
assert(prop.scale.is_equal_approx(Vector3.ONE))
|
||||
var visual := prop.get_node_or_null("Visual") as MeshInstance3D
|
||||
var visual_root := prop.get_node_or_null("Visual") as Node3D
|
||||
var visual := _find_mesh_instance(visual_root)
|
||||
var collision := prop.get_node_or_null(
|
||||
"TrunkCollision/CollisionShape"
|
||||
) as CollisionShape3D
|
||||
assert(visual_root != null and visual_root.position.is_zero_approx())
|
||||
assert(visual != null and visual.mesh != null)
|
||||
assert(collision != null and collision.shape is CylinderShape3D)
|
||||
assert(collision.global_basis.get_scale().is_equal_approx(Vector3.ONE))
|
||||
var bounds := visual.mesh.get_aabb()
|
||||
var minimum_y := INF
|
||||
for corner_index: int in 8:
|
||||
var corner := bounds.position + Vector3(
|
||||
bounds.size.x if (corner_index & 1) != 0 else 0.0,
|
||||
bounds.size.y if (corner_index & 2) != 0 else 0.0,
|
||||
bounds.size.z if (corner_index & 4) != 0 else 0.0,
|
||||
if definition.has_collision():
|
||||
assert(collision != null)
|
||||
assert(
|
||||
collision.shape is CylinderShape3D
|
||||
or collision.shape is BoxShape3D
|
||||
)
|
||||
minimum_y = minf(
|
||||
minimum_y,
|
||||
(visual.global_transform * corner).y,
|
||||
)
|
||||
assert(absf(minimum_y - prop.global_position.y) <= 0.01)
|
||||
assert(collision.global_basis.get_scale().is_equal_approx(Vector3.ONE))
|
||||
else:
|
||||
assert(collision == null)
|
||||
var query := PhysicsRayQueryParameters3D.create(
|
||||
prop.global_position + Vector3.UP * 8.0,
|
||||
prop.global_position + Vector3.DOWN * 8.0,
|
||||
1,
|
||||
)
|
||||
var prop_body := prop.get_node_or_null(
|
||||
"TrunkCollision",
|
||||
) as CollisionObject3D
|
||||
if prop_body != null:
|
||||
query.exclude = [prop_body.get_rid()]
|
||||
var hit := region.get_world_3d().direct_space_state.intersect_ray(query)
|
||||
assert(
|
||||
not hit.is_empty(),
|
||||
"No terrain collision below %s (%s) at %s."
|
||||
% [prop.name, definition.stable_id, prop.global_position],
|
||||
)
|
||||
var ground_position: Vector3 = hit["position"]
|
||||
assert(absf(ground_position.y - prop.global_position.y) <= 0.01)
|
||||
|
||||
|
||||
func _find_mesh_instance(root_node: Node) -> MeshInstance3D:
|
||||
if root_node is MeshInstance3D:
|
||||
return root_node as MeshInstance3D
|
||||
if root_node == null:
|
||||
return null
|
||||
for child: Node in root_node.get_children():
|
||||
var mesh_instance := _find_mesh_instance(child)
|
||||
if mesh_instance != null:
|
||||
return mesh_instance
|
||||
return null
|
||||
|
||||
|
||||
func _validate_authored_chunk_surfaces(
|
||||
|
|
@ -136,6 +326,9 @@ func _validate_authored_chunk_surfaces(
|
|||
var generated := generator.get_generated_chunks_root()
|
||||
var found_beach := false
|
||||
var found_pond := false
|
||||
var found_grass_ocean_edge := false
|
||||
var found_grass_beach_transition := false
|
||||
var found_grass_ocean_corner := false
|
||||
for chunk_root: Node in generated.get_children():
|
||||
if chunk_root.name.begins_with("chunk_0002r"):
|
||||
var beach := chunk_root.find_child(
|
||||
|
|
@ -157,8 +350,53 @@ func _validate_authored_chunk_surfaces(
|
|||
assert(normal.y >= -0.001)
|
||||
_validate_pond_collision(region, pond)
|
||||
found_pond = true
|
||||
elif chunk_root.name.begins_with("chunk_0005r"):
|
||||
var grass_ocean_edge := chunk_root.find_child(
|
||||
"chunk_0005", true, false
|
||||
) as MeshInstance3D
|
||||
assert(grass_ocean_edge != null and grass_ocean_edge.mesh != null)
|
||||
var edge_materials := _material_names(grass_ocean_edge)
|
||||
assert("grass_lite" in edge_materials)
|
||||
assert("cliff_wall" in edge_materials)
|
||||
found_grass_ocean_edge = true
|
||||
elif chunk_root.name.begins_with("chunk_0006r"):
|
||||
var grass_beach_transition := chunk_root.find_child(
|
||||
"chunk_0006", true, false
|
||||
) as MeshInstance3D
|
||||
assert(
|
||||
grass_beach_transition != null
|
||||
and grass_beach_transition.mesh != null
|
||||
)
|
||||
var transition_materials := _material_names(
|
||||
grass_beach_transition
|
||||
)
|
||||
assert("sand" in transition_materials)
|
||||
assert("grass_lite" in transition_materials)
|
||||
assert("cliff_wall" in transition_materials)
|
||||
found_grass_beach_transition = true
|
||||
elif chunk_root.name.begins_with("chunk_0007r"):
|
||||
var grass_ocean_corner := chunk_root.find_child(
|
||||
"chunk_0007", true, false
|
||||
) as MeshInstance3D
|
||||
assert(grass_ocean_corner != null and grass_ocean_corner.mesh != null)
|
||||
var corner_materials := _material_names(grass_ocean_corner)
|
||||
assert("grass_lite" in corner_materials)
|
||||
assert("cliff_wall" in corner_materials)
|
||||
found_grass_ocean_corner = true
|
||||
assert(found_beach)
|
||||
assert(found_pond)
|
||||
assert(found_grass_ocean_edge)
|
||||
assert(found_grass_beach_transition)
|
||||
assert(found_grass_ocean_corner)
|
||||
|
||||
|
||||
func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray:
|
||||
var result := PackedStringArray()
|
||||
for surface_index: int in mesh_instance.mesh.get_surface_count():
|
||||
var material := mesh_instance.get_active_material(surface_index)
|
||||
if material != null:
|
||||
result.append(material.resource_name)
|
||||
return result
|
||||
|
||||
|
||||
func _validate_pond_collision(
|
||||
|
|
@ -207,14 +445,50 @@ func _validate_pond_collision(
|
|||
assert(collider != null and collider.get_parent() == pond)
|
||||
|
||||
|
||||
func _decoration_count(decorations: Node3D, prefix: String) -> int:
|
||||
func _decoration_group_count(
|
||||
region: GeneratedWorldRegion,
|
||||
group: StringName,
|
||||
) -> int:
|
||||
var count := 0
|
||||
var decorations := region.get_node("Decorations") as Node3D
|
||||
for child: Node in decorations.get_children():
|
||||
if child.name.begins_with(prefix):
|
||||
var stable_id := StringName(child.get_meta(&"terrain_prop_id", &""))
|
||||
var definition := region.get_prop_catalog().definition_for_id(stable_id)
|
||||
if definition != null and definition.procedural_group == group:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _decoration_biome_group_count(
|
||||
region: GeneratedWorldRegion,
|
||||
biome_id: StringName,
|
||||
group: StringName,
|
||||
) -> int:
|
||||
var count := 0
|
||||
var decorations := region.get_node("Decorations") as Node3D
|
||||
for child: Node in decorations.get_children():
|
||||
if StringName(child.get_meta(&"terrain_biome_id", &"")) != biome_id:
|
||||
continue
|
||||
var prop_id := StringName(child.get_meta(&"terrain_prop_id", &""))
|
||||
var definition := region.get_prop_catalog().definition_for_id(prop_id)
|
||||
if definition != null and definition.procedural_group == group:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _biome_signature(
|
||||
region: GeneratedWorldRegion,
|
||||
generator: TerrainChunkGenerator,
|
||||
) -> PackedStringArray:
|
||||
var result := PackedStringArray()
|
||||
for row: int in generator.grid_size.y:
|
||||
for column: int in generator.grid_size.x:
|
||||
result.append(
|
||||
String(region.get_biome_at(Vector2i(column, row)))
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
func _placement_count(keys: PackedStringArray, stable_id: String) -> int:
|
||||
var count := 0
|
||||
for key: String in keys:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ extends SceneTree
|
|||
const CATALOG: TerrainChunkCatalog = preload(
|
||||
"res://world/generation/chunks/terrain_chunk_catalog.tres"
|
||||
)
|
||||
const MATCH_TOLERANCE := 0.01
|
||||
const BIOME_CATALOG: TerrainBiomeCatalog = preload(
|
||||
"res://world/generation/biomes/terrain_biome_catalog.tres"
|
||||
)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -11,30 +13,34 @@ func _initialize() -> void:
|
|||
|
||||
|
||||
func _run() -> void:
|
||||
var generator := _make_generator()
|
||||
root.add_child(generator)
|
||||
if not generator._prepare_catalog():
|
||||
generator.free()
|
||||
quit(1)
|
||||
return
|
||||
var variants: Array[TerrainChunkVariant] = []
|
||||
for definition: TerrainChunkDefinition in CATALOG.definitions:
|
||||
var seen: Dictionary[String, bool] = {}
|
||||
for variant: TerrainChunkVariant in TerrainChunkAnalyzer.create_variants(
|
||||
definition,
|
||||
CATALOG.chunk_size,
|
||||
):
|
||||
var signature := variant.topology_signature(0.001)
|
||||
if seen.has(signature):
|
||||
continue
|
||||
seen[signature] = true
|
||||
variants.append(variant)
|
||||
variants.assign(generator._solver_variants)
|
||||
|
||||
print("Terrain chunk variants: %d" % variants.size())
|
||||
print(
|
||||
"Terrain chunk rotations: %d authored, %d distinct constraints"
|
||||
% [generator._variants.size(), variants.size()]
|
||||
)
|
||||
for variant: TerrainChunkVariant in variants:
|
||||
print("\n", variant.stable_key())
|
||||
var ocean_edges := variant.rotated_edge_mask(
|
||||
variant.definition.ocean_facing_edges
|
||||
)
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var edge := edge_value as TerrainChunkTopology.Edge
|
||||
var matches := PackedStringArray()
|
||||
for other: TerrainChunkVariant in variants:
|
||||
var opposite := TerrainChunkTopology.opposite_edge(edge)
|
||||
if variant.profile(edge).matches(
|
||||
other.profile(opposite),
|
||||
MATCH_TOLERANCE,
|
||||
if generator._edges_are_compatible(
|
||||
variant,
|
||||
edge,
|
||||
other,
|
||||
opposite,
|
||||
):
|
||||
matches.append(
|
||||
"%s.%s"
|
||||
|
|
@ -44,17 +50,61 @@ func _run() -> void:
|
|||
]
|
||||
)
|
||||
print(
|
||||
" %s [%d points]: %s"
|
||||
" %s%s [%d points]: %s"
|
||||
% [
|
||||
TerrainChunkTopology.edge_name(edge),
|
||||
(
|
||||
" [ocean]"
|
||||
if (ocean_edges & (1 << edge_value)) != 0
|
||||
else ""
|
||||
),
|
||||
variant.profile(edge).points.size(),
|
||||
", ".join(matches) if not matches.is_empty() else "NONE",
|
||||
]
|
||||
)
|
||||
|
||||
if generator.generate():
|
||||
var summary := generator._build_summary()
|
||||
print(
|
||||
(
|
||||
"\nSeeded diagnostic layout %s "
|
||||
+ "(%d backtracks, %d repeated edges):"
|
||||
)
|
||||
% [
|
||||
str(summary["layout_fingerprint"]).substr(0, 12),
|
||||
int(summary["backtracks"]),
|
||||
int(summary["adjacent_repeat_edges"]),
|
||||
]
|
||||
)
|
||||
var keys := generator.placement_keys()
|
||||
for row: int in generator.grid_size.y:
|
||||
var cells := PackedStringArray()
|
||||
for column: int in generator.grid_size.x:
|
||||
cells.append(keys[row * generator.grid_size.x + column])
|
||||
print(" ", " ".join(cells))
|
||||
var biomes := TerrainBiomeAssigner.assign(
|
||||
BIOME_CATALOG,
|
||||
generator.placement_records(),
|
||||
generator.generation_seed,
|
||||
)
|
||||
print("\nBiome layout:")
|
||||
for row: int in generator.grid_size.y:
|
||||
var cells := PackedStringArray()
|
||||
for column: int in generator.grid_size.x:
|
||||
var biome_id: StringName = biomes.get(
|
||||
Vector2i(column, row),
|
||||
&"",
|
||||
)
|
||||
cells.append(String(biome_id).trim_prefix("biome_"))
|
||||
print(" ", " ".join(cells))
|
||||
generator.free()
|
||||
quit(0)
|
||||
|
||||
|
||||
func _make_generator() -> TerrainChunkGenerator:
|
||||
var generator := TerrainChunkGenerator.new()
|
||||
generator.catalog = CATALOG
|
||||
generator.grid_size = Vector2i(5, 5)
|
||||
generator.grid_size = Vector2i(7, 7)
|
||||
generator.generation_seed = 13001
|
||||
generator.generate_on_ready = false
|
||||
generator.build_collision = false
|
||||
|
|
@ -66,16 +116,9 @@ func _run() -> void:
|
|||
"chunk_0002",
|
||||
"chunk_0003",
|
||||
"chunk_0004",
|
||||
"chunk_0005",
|
||||
"chunk_0006",
|
||||
"chunk_0007",
|
||||
]
|
||||
)
|
||||
root.add_child(generator)
|
||||
if generator.generate():
|
||||
print("\nSeeded diagnostic layout:")
|
||||
var keys := generator.placement_keys()
|
||||
for row: int in generator.grid_size.y:
|
||||
var cells := PackedStringArray()
|
||||
for column: int in generator.grid_size.x:
|
||||
cells.append(keys[row * generator.grid_size.x + column])
|
||||
print(" ", " ".join(cells))
|
||||
generator.free()
|
||||
quit(0)
|
||||
return generator
|
||||
|
|
|
|||
|
|
@ -24,10 +24,14 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
func _on_generation_completed(summary: Dictionary) -> void:
|
||||
_status.text = (
|
||||
"Terrain generator diagnostic • seed %d • %d chunks • "
|
||||
+ "%d variants • %d backtracks\nR: regenerate • Esc/B: close"
|
||||
+ "%d rotations / %d constraints • %d backtracks • "
|
||||
+ "%d repeated edges\nlayout %s • R: regenerate • Esc/B: close"
|
||||
) % [
|
||||
int(summary["seed"]),
|
||||
int(summary["chunk_count"]),
|
||||
int(summary["variant_count"]),
|
||||
int(summary["solver_variant_count"]),
|
||||
int(summary["backtracks"]),
|
||||
int(summary["adjacent_repeat_edges"]),
|
||||
str(summary["layout_fingerprint"]).substr(0, 12),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ shadow_enabled = true
|
|||
[node name="TerrainChunkGenerator" type="Node3D" parent="."]
|
||||
script = ExtResource("2_generator")
|
||||
catalog = ExtResource("3_catalog")
|
||||
grid_size = Vector2i(5, 5)
|
||||
grid_size = Vector2i(7, 7)
|
||||
generation_seed = 13001
|
||||
build_collision = true
|
||||
show_chunk_labels = false
|
||||
force_center_chunk_id = &"chunk_spawn"
|
||||
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004")
|
||||
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007")
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("4_player")]
|
||||
position = Vector3(0, 0.3, 0)
|
||||
|
|
|
|||
135
tests/terrain_biome_validation.gd
Normal file
135
tests/terrain_biome_validation.gd
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
extends SceneTree
|
||||
|
||||
const BIOME_CATALOG: TerrainBiomeCatalog = preload(
|
||||
"res://world/generation/biomes/terrain_biome_catalog.tres"
|
||||
)
|
||||
const PROP_CATALOG: TerrainPropCatalog = preload(
|
||||
"res://world/generation/props/terrain_prop_catalog.tres"
|
||||
)
|
||||
const EXPECTED_BIOMES: Array[StringName] = [
|
||||
&"biome_plains",
|
||||
&"biome_forest",
|
||||
&"biome_pine_forest",
|
||||
&"biome_coast",
|
||||
]
|
||||
const TEST_SEED := 13001
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred(&"_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_validate_catalog()
|
||||
_validate_assignment()
|
||||
_finish()
|
||||
|
||||
|
||||
func _validate_catalog() -> void:
|
||||
for error: String in BIOME_CATALOG.validation_errors():
|
||||
_check(false, error)
|
||||
_check(
|
||||
BIOME_CATALOG.definitions.size() == EXPECTED_BIOMES.size(),
|
||||
"The biome catalog must contain all initial authored profiles.",
|
||||
)
|
||||
for biome_id: StringName in EXPECTED_BIOMES:
|
||||
var biome := BIOME_CATALOG.definition_for_id(biome_id)
|
||||
_check(biome != null, "The biome catalog is missing %s." % biome_id)
|
||||
if biome == null:
|
||||
continue
|
||||
for rule: TerrainBiomePropRule in biome.prop_rules:
|
||||
for prop_id_value: String in rule.allowed_prop_ids:
|
||||
var prop := PROP_CATALOG.definition_for_id(
|
||||
StringName(prop_id_value)
|
||||
)
|
||||
_check(
|
||||
prop != null and rule.allows_prop(prop),
|
||||
"%s has an invalid %s prop rule."
|
||||
% [biome_id, prop_id_value],
|
||||
)
|
||||
|
||||
|
||||
func _validate_assignment() -> void:
|
||||
var records := _synthetic_records()
|
||||
var first := TerrainBiomeAssigner.assign(
|
||||
BIOME_CATALOG,
|
||||
records,
|
||||
TEST_SEED,
|
||||
)
|
||||
var second := TerrainBiomeAssigner.assign(
|
||||
BIOME_CATALOG,
|
||||
records,
|
||||
TEST_SEED,
|
||||
)
|
||||
_check(first == second, "Biome assignment must be deterministic by seed.")
|
||||
_check(
|
||||
TerrainBiomeAssigner.fingerprint(first).length() == 64,
|
||||
"Biome assignments must expose a SHA-256 fingerprint.",
|
||||
)
|
||||
var counts := TerrainBiomeAssigner.counts(first)
|
||||
for biome_id: StringName in EXPECTED_BIOMES:
|
||||
_check(
|
||||
counts.get(biome_id, 0) > 0,
|
||||
"The synthetic map must contain %s." % biome_id,
|
||||
)
|
||||
_check(
|
||||
first.get(Vector2i(3, 3), &"") == &"biome_plains",
|
||||
"The spawn-tagged center must remain plains.",
|
||||
)
|
||||
for record: Dictionary in records:
|
||||
var coordinate: Vector2i = record["coordinate"]
|
||||
var tags: PackedStringArray = record["tags"]
|
||||
var biome_id: StringName = first.get(coordinate, &"")
|
||||
if "fresh_water" in tags:
|
||||
_check(
|
||||
biome_id == &"",
|
||||
"Freshwater-only chunks must not inherit a land-prop biome.",
|
||||
)
|
||||
continue
|
||||
var biome := BIOME_CATALOG.definition_for_id(biome_id)
|
||||
_check(
|
||||
biome != null and biome.supports_chunk_tags(tags),
|
||||
"Biome %s does not support chunk %s."
|
||||
% [biome_id, coordinate],
|
||||
)
|
||||
|
||||
|
||||
func _synthetic_records() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for row: int in 7:
|
||||
for column: int in 7:
|
||||
var coordinate := Vector2i(column, row)
|
||||
var tags := PackedStringArray(["land", "walkable", "grass"])
|
||||
if row == 0:
|
||||
tags = PackedStringArray(["land", "walkable", "sand"])
|
||||
elif coordinate == Vector2i(0, 6):
|
||||
tags = PackedStringArray([
|
||||
"land",
|
||||
"walkable",
|
||||
"fresh_water",
|
||||
"pond",
|
||||
])
|
||||
elif coordinate == Vector2i(3, 3):
|
||||
tags.append("spawn")
|
||||
result.append({
|
||||
"coordinate": coordinate,
|
||||
"tags": tags,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if _failures.is_empty():
|
||||
print("Terrain biome validation: PASS")
|
||||
quit(0)
|
||||
return
|
||||
for failure: String in _failures:
|
||||
printerr("Terrain biome validation: ", failure)
|
||||
quit(1)
|
||||
1
tests/terrain_biome_validation.gd.uid
Normal file
1
tests/terrain_biome_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cxioi62f6oap0
|
||||
|
|
@ -9,6 +9,9 @@ const EXPECTED_IDS: Array[String] = [
|
|||
"chunk_0002",
|
||||
"chunk_0003",
|
||||
"chunk_0004",
|
||||
"chunk_0005",
|
||||
"chunk_0006",
|
||||
"chunk_0007",
|
||||
"chunk_spawn",
|
||||
]
|
||||
const REQUIRED_IDS: Array[String] = [
|
||||
|
|
@ -17,8 +20,24 @@ const REQUIRED_IDS: Array[String] = [
|
|||
"chunk_0002",
|
||||
"chunk_0003",
|
||||
"chunk_0004",
|
||||
"chunk_0005",
|
||||
"chunk_0006",
|
||||
"chunk_0007",
|
||||
]
|
||||
const TEST_SEED := 13001
|
||||
const CONNECTOR_STRESS_SEED := 287
|
||||
const EXPECTED_SOLVER_VARIANT_COUNT := 27
|
||||
const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = {
|
||||
&"chunk_0000": 4,
|
||||
&"chunk_0001": 4,
|
||||
&"chunk_0002": 4,
|
||||
&"chunk_0003": 4,
|
||||
&"chunk_0004": 4,
|
||||
&"chunk_0005": 4,
|
||||
&"chunk_0006": 4,
|
||||
&"chunk_0007": 4,
|
||||
&"chunk_spawn": 1,
|
||||
}
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
|
@ -50,6 +69,130 @@ func _validate_catalog() -> void:
|
|||
definition.packed_scene != null,
|
||||
"%s must reference an imported GLB." % stable_id,
|
||||
)
|
||||
var grass := CATALOG.definition_for_id(&"chunk_0000")
|
||||
var sand := CATALOG.definition_for_id(&"chunk_0001")
|
||||
var beach := CATALOG.definition_for_id(&"chunk_0002")
|
||||
var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
|
||||
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
|
||||
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007")
|
||||
var spawn := CATALOG.definition_for_id(&"chunk_spawn")
|
||||
_check(
|
||||
(
|
||||
grass != null
|
||||
and sand != null
|
||||
and beach != null
|
||||
and grass_ocean_edge != null
|
||||
and grass_beach_transition != null
|
||||
and grass_ocean_corner != null
|
||||
and spawn != null
|
||||
),
|
||||
"The authored biome-rule definitions must be available.",
|
||||
)
|
||||
if (
|
||||
grass != null
|
||||
and sand != null
|
||||
and beach != null
|
||||
and grass_ocean_edge != null
|
||||
and grass_beach_transition != null
|
||||
and grass_ocean_corner != null
|
||||
and spawn != null
|
||||
):
|
||||
_check(
|
||||
sand.allows_non_water_neighbor(beach),
|
||||
"Flat sand must permit an adjacent authored beach.",
|
||||
)
|
||||
_check(
|
||||
sand.allows_non_water_neighbor(grass),
|
||||
"Flat sand must permit the inland transition to grass.",
|
||||
)
|
||||
_check(
|
||||
grass.allows_non_water_neighbor(sand),
|
||||
"Grass must permit the inland side of a flat-sand transition.",
|
||||
)
|
||||
_check(
|
||||
"coast" in sand.required_neighbor_tags,
|
||||
"Every flat-sand chunk must remain attached to an authored beach.",
|
||||
)
|
||||
_check(
|
||||
spawn.minimum_required_neighbors == 3,
|
||||
"Spawn must retain its three-sided safe-grass requirement.",
|
||||
)
|
||||
_check(
|
||||
beach.ocean_facing_edges
|
||||
== (1 << int(TerrainChunkTopology.Edge.EAST)),
|
||||
"The authored beach must descend eastward into ocean before rotation.",
|
||||
)
|
||||
_check(
|
||||
grass_ocean_edge.ocean_facing_edges
|
||||
== (1 << int(TerrainChunkTopology.Edge.EAST)),
|
||||
"The authored grass cliff must face eastward ocean before rotation.",
|
||||
)
|
||||
_check(
|
||||
grass_beach_transition.ocean_facing_edges
|
||||
== (1 << int(TerrainChunkTopology.Edge.EAST)),
|
||||
"The authored grass/beach join must face eastward ocean before rotation.",
|
||||
)
|
||||
_check(
|
||||
grass_beach_transition.minimum_required_neighbors == 2
|
||||
and "coast" in grass_beach_transition.required_neighbor_tags,
|
||||
"The grass/beach join must remain between two coastline chunks.",
|
||||
)
|
||||
_check(
|
||||
grass_beach_transition.allows_non_water_neighbor_on_edge(
|
||||
grass,
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
)
|
||||
and not grass_beach_transition.allows_non_water_neighbor_on_edge(
|
||||
sand,
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
),
|
||||
"The grass-backed transition edge must accept grass and reject sand.",
|
||||
)
|
||||
_check(
|
||||
grass_beach_transition.allows_non_water_neighbor_on_edge(
|
||||
beach,
|
||||
TerrainChunkTopology.Edge.NORTH,
|
||||
)
|
||||
and grass_beach_transition.allows_non_water_neighbor_on_edge(
|
||||
grass_ocean_edge,
|
||||
TerrainChunkTopology.Edge.SOUTH,
|
||||
),
|
||||
"The transition's coastline seams must retain their authored roles.",
|
||||
)
|
||||
_check(
|
||||
not grass_ocean_edge.allows_non_water_neighbor_on_edge(
|
||||
sand,
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
),
|
||||
"The straight grass coast's inland edge must reject sand.",
|
||||
)
|
||||
_check(
|
||||
grass_ocean_corner.ocean_facing_edges
|
||||
== (
|
||||
(1 << int(TerrainChunkTopology.Edge.EAST))
|
||||
| (1 << int(TerrainChunkTopology.Edge.SOUTH))
|
||||
),
|
||||
"The authored grass corner must expose east and south to ocean.",
|
||||
)
|
||||
_check(
|
||||
grass_ocean_corner.minimum_required_neighbors == 2
|
||||
and (
|
||||
"grass_ocean_edge"
|
||||
in grass_ocean_corner.required_neighbor_tags
|
||||
),
|
||||
"The grass corner must join two straight grass ocean edges.",
|
||||
)
|
||||
_check(
|
||||
grass_ocean_corner.allows_non_water_neighbor_on_edge(
|
||||
grass_ocean_edge,
|
||||
TerrainChunkTopology.Edge.NORTH,
|
||||
)
|
||||
and grass_ocean_corner.allows_non_water_neighbor_on_edge(
|
||||
grass_ocean_edge,
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
),
|
||||
"Both corner land seams must accept straight grass ocean edges.",
|
||||
)
|
||||
|
||||
|
||||
func _validate_profiles() -> void:
|
||||
|
|
@ -68,10 +211,20 @@ func _validate_profiles() -> void:
|
|||
variant.edge_profiles.size() == 4,
|
||||
"%s must expose four edge profiles." % variant.stable_key(),
|
||||
)
|
||||
for profile: TerrainChunkEdgeProfile in variant.edge_profiles:
|
||||
var ocean_edges := variant.rotated_edge_mask(
|
||||
definition.ocean_facing_edges
|
||||
)
|
||||
for edge_index: int in variant.edge_profiles.size():
|
||||
var profile := variant.edge_profiles[edge_index]
|
||||
_check(
|
||||
not profile.points.is_empty(),
|
||||
"%s has an empty edge profile." % variant.stable_key(),
|
||||
(
|
||||
not profile.points.is_empty()
|
||||
or (ocean_edges & (1 << edge_index)) != 0
|
||||
),
|
||||
(
|
||||
"%s has an empty non-ocean edge profile."
|
||||
% variant.stable_key()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -79,12 +232,12 @@ func _validate_generation() -> void:
|
|||
var first := _make_generator()
|
||||
root.add_child(first)
|
||||
var first_solved := first.generate()
|
||||
_check(first_solved, "Generator must solve the prototype 5x5 grid.")
|
||||
_check(first_solved, "Generator must solve the prototype 7x7 grid.")
|
||||
if not first_solved:
|
||||
first.free()
|
||||
return
|
||||
var first_keys := first.placement_keys()
|
||||
_check(first_keys.size() == 25, "Generator must place exactly 25 chunks.")
|
||||
_check(first_keys.size() == 49, "Generator must place exactly 49 chunks.")
|
||||
for stable_id: String in REQUIRED_IDS:
|
||||
_check(
|
||||
_placements_contain(first_keys, stable_id),
|
||||
|
|
@ -99,9 +252,25 @@ func _validate_generation() -> void:
|
|||
"Generated layouts must contain exactly one spawn chunk.",
|
||||
)
|
||||
_check(
|
||||
first_keys[12].begins_with("chunk_spawn@"),
|
||||
first_keys[24].begins_with("chunk_spawn@"),
|
||||
"The spawn chunk must occupy the center cell.",
|
||||
)
|
||||
_validate_layout_rules(first)
|
||||
_validate_authored_rotations(first)
|
||||
_validate_generation_summary(first)
|
||||
var first_generated_root := first.get_generated_chunks_root()
|
||||
_check(
|
||||
first.generate(),
|
||||
"Repeated generation on one generator must solve.",
|
||||
)
|
||||
_check(
|
||||
first.placement_keys() == first_keys,
|
||||
"Repeated generation on one generator must remain deterministic.",
|
||||
)
|
||||
_check(
|
||||
first.get_generated_chunks_root() != first_generated_root,
|
||||
"Successful regeneration must replace the generated scene atomically.",
|
||||
)
|
||||
|
||||
var second := _make_generator()
|
||||
root.add_child(second)
|
||||
|
|
@ -115,8 +284,20 @@ func _validate_generation() -> void:
|
|||
second.placement_keys() == first_keys,
|
||||
"The same seed and catalog must produce the same layout.",
|
||||
)
|
||||
var restored := _make_generator()
|
||||
restored.generation_seed = TEST_SEED + 999
|
||||
root.add_child(restored)
|
||||
_check(
|
||||
restored.generate_from_placement_keys(first_keys),
|
||||
"A resolved placement manifest must rebuild successfully.",
|
||||
)
|
||||
_check(
|
||||
restored.placement_keys() == first_keys,
|
||||
"A resolved placement manifest must preserve exact rotations.",
|
||||
)
|
||||
first.free()
|
||||
second.free()
|
||||
restored.free()
|
||||
|
||||
for seed_offset: int in range(1, 6):
|
||||
var generator := _make_generator()
|
||||
|
|
@ -135,18 +316,349 @@ func _validate_generation() -> void:
|
|||
"Seed %d must contain %s."
|
||||
% [generator.generation_seed, stable_id],
|
||||
)
|
||||
_check(
|
||||
_all_neighbor_edges_match(generator),
|
||||
_check(
|
||||
_all_neighbor_edges_match(generator),
|
||||
"Seed %d has a mismatched neighboring edge."
|
||||
% generator.generation_seed,
|
||||
)
|
||||
% generator.generation_seed,
|
||||
)
|
||||
_validate_layout_rules(generator)
|
||||
generator.free()
|
||||
|
||||
var connector_stress := _make_generator()
|
||||
connector_stress.generation_seed = CONNECTOR_STRESS_SEED
|
||||
connector_stress.maximum_backtracks = 100
|
||||
root.add_child(connector_stress)
|
||||
_check(
|
||||
connector_stress.generate(),
|
||||
"Connector propagation must solve the known stress seed.",
|
||||
)
|
||||
_check(
|
||||
connector_stress._backtrack_count < 100,
|
||||
"Connector propagation must reject unsupported branches early.",
|
||||
)
|
||||
connector_stress.free()
|
||||
|
||||
|
||||
func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
||||
var counts: Dictionary[StringName, int] = {}
|
||||
for variant: TerrainChunkVariant in generator._variants:
|
||||
var stable_id := variant.definition.stable_id
|
||||
counts[stable_id] = counts.get(stable_id, 0) + 1
|
||||
for stable_id: StringName in EXPECTED_VARIANT_COUNTS:
|
||||
_check(
|
||||
counts.get(stable_id, 0) == EXPECTED_VARIANT_COUNTS[stable_id],
|
||||
"%s must preserve every explicitly allowed rotation." % stable_id,
|
||||
)
|
||||
|
||||
var stream := CATALOG.definition_for_id(&"chunk_0003")
|
||||
_check(stream != null, "The stream definition must be available.")
|
||||
if stream == null:
|
||||
return
|
||||
for quarter_turns: int in 4:
|
||||
var stream_variant := _find_variant(
|
||||
generator,
|
||||
&"chunk_0003",
|
||||
quarter_turns,
|
||||
)
|
||||
_check(
|
||||
stream_variant != null,
|
||||
"The stream must support rotation %d." % quarter_turns,
|
||||
)
|
||||
if stream_variant == null:
|
||||
continue
|
||||
var expected_inlet := 1 << int(TerrainChunkTopology.rotated_edge(
|
||||
TerrainChunkTopology.Edge.NORTH,
|
||||
quarter_turns,
|
||||
))
|
||||
var expected_outlet := 1 << int(TerrainChunkTopology.rotated_edge(
|
||||
TerrainChunkTopology.Edge.SOUTH,
|
||||
quarter_turns,
|
||||
))
|
||||
_check(
|
||||
stream_variant.rotated_edge_mask(stream.water_inlet_edges)
|
||||
== expected_inlet,
|
||||
"The rotated stream inlet must follow its authored orientation.",
|
||||
)
|
||||
_check(
|
||||
stream_variant.rotated_edge_mask(stream.water_outlet_edges)
|
||||
== expected_outlet,
|
||||
"The rotated stream outlet must follow its authored orientation.",
|
||||
)
|
||||
|
||||
var beach := CATALOG.definition_for_id(&"chunk_0002")
|
||||
_check(beach != null, "The beach definition must be available.")
|
||||
if beach == null:
|
||||
return
|
||||
for quarter_turns: int in 4:
|
||||
var beach_variant := _find_variant(
|
||||
generator,
|
||||
&"chunk_0002",
|
||||
quarter_turns,
|
||||
)
|
||||
_check(
|
||||
beach_variant != null,
|
||||
"The beach must support rotation %d." % quarter_turns,
|
||||
)
|
||||
if beach_variant == null:
|
||||
continue
|
||||
var expected_ocean_edge := 1 << int(
|
||||
TerrainChunkTopology.rotated_edge(
|
||||
TerrainChunkTopology.Edge.EAST,
|
||||
quarter_turns,
|
||||
)
|
||||
)
|
||||
_check(
|
||||
beach_variant.rotated_edge_mask(beach.ocean_facing_edges)
|
||||
== expected_ocean_edge,
|
||||
"The rotated beach must keep its low edge facing the ocean.",
|
||||
)
|
||||
_check(
|
||||
not generator._variant_respects_ocean_boundary(
|
||||
beach_variant,
|
||||
Vector2i(3, 3),
|
||||
),
|
||||
"A beach slope must never direct its low edge into the map.",
|
||||
)
|
||||
var boundary_coordinate := Vector2i(3, 3)
|
||||
match TerrainChunkTopology.rotated_edge(
|
||||
TerrainChunkTopology.Edge.EAST,
|
||||
quarter_turns,
|
||||
):
|
||||
TerrainChunkTopology.Edge.NORTH:
|
||||
boundary_coordinate.y = 0
|
||||
TerrainChunkTopology.Edge.EAST:
|
||||
boundary_coordinate.x = generator.grid_size.x - 1
|
||||
TerrainChunkTopology.Edge.SOUTH:
|
||||
boundary_coordinate.y = generator.grid_size.y - 1
|
||||
TerrainChunkTopology.Edge.WEST:
|
||||
boundary_coordinate.x = 0
|
||||
_check(
|
||||
generator._variant_respects_ocean_boundary(
|
||||
beach_variant,
|
||||
boundary_coordinate,
|
||||
),
|
||||
"A beach slope must be legal when its low edge faces open ocean.",
|
||||
)
|
||||
|
||||
var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
|
||||
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
|
||||
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007")
|
||||
_check(
|
||||
(
|
||||
grass_ocean_edge != null
|
||||
and grass_beach_transition != null
|
||||
and grass_ocean_corner != null
|
||||
),
|
||||
"The authored coastline additions must be available.",
|
||||
)
|
||||
if (
|
||||
grass_ocean_edge == null
|
||||
or grass_beach_transition == null
|
||||
or grass_ocean_corner == null
|
||||
):
|
||||
return
|
||||
var beach_zero := _find_variant(generator, &"chunk_0002", 0)
|
||||
var grass_edge_zero := _find_variant(generator, &"chunk_0005", 0)
|
||||
var grass_edge_south := _find_variant(generator, &"chunk_0005", 3)
|
||||
var transition_zero := _find_variant(generator, &"chunk_0006", 0)
|
||||
var corner_zero := _find_variant(generator, &"chunk_0007", 0)
|
||||
_check(
|
||||
beach_zero != null
|
||||
and grass_edge_zero != null
|
||||
and grass_edge_south != null
|
||||
and transition_zero != null
|
||||
and corner_zero != null,
|
||||
"The unrotated coastline variants must be available.",
|
||||
)
|
||||
if (
|
||||
beach_zero != null
|
||||
and grass_edge_zero != null
|
||||
and grass_edge_south != null
|
||||
and transition_zero != null
|
||||
and corner_zero != null
|
||||
):
|
||||
_check(
|
||||
grass_edge_zero.profile(
|
||||
TerrainChunkTopology.Edge.EAST
|
||||
).points.is_empty(),
|
||||
"The grass cliff's authored east edge must remain open to ocean.",
|
||||
)
|
||||
_check(
|
||||
transition_zero.profile(
|
||||
TerrainChunkTopology.Edge.NORTH
|
||||
).matches(
|
||||
beach_zero.profile(TerrainChunkTopology.Edge.SOUTH),
|
||||
generator.edge_match_tolerance,
|
||||
),
|
||||
"The transition's north side must match the authored beach.",
|
||||
)
|
||||
_check(
|
||||
transition_zero.profile(
|
||||
TerrainChunkTopology.Edge.SOUTH
|
||||
).matches(
|
||||
grass_edge_zero.profile(TerrainChunkTopology.Edge.NORTH),
|
||||
generator.edge_match_tolerance,
|
||||
),
|
||||
"The transition's south side must match the grass cliff.",
|
||||
)
|
||||
_check(
|
||||
corner_zero.profile(TerrainChunkTopology.Edge.EAST).points.is_empty()
|
||||
and corner_zero.profile(
|
||||
TerrainChunkTopology.Edge.SOUTH
|
||||
).points.is_empty(),
|
||||
"The corner's authored east and south edges must remain open to ocean.",
|
||||
)
|
||||
_check(
|
||||
corner_zero.profile(TerrainChunkTopology.Edge.NORTH).matches(
|
||||
grass_edge_zero.profile(TerrainChunkTopology.Edge.SOUTH),
|
||||
generator.edge_match_tolerance,
|
||||
),
|
||||
"The corner's north side must join a straight grass ocean edge.",
|
||||
)
|
||||
_check(
|
||||
corner_zero.profile(TerrainChunkTopology.Edge.WEST).matches(
|
||||
grass_edge_south.profile(TerrainChunkTopology.Edge.EAST),
|
||||
generator.edge_match_tolerance,
|
||||
),
|
||||
"The corner's west side must join a straight grass ocean edge.",
|
||||
)
|
||||
var flat_grass := _find_variant(generator, &"chunk_0000", 0)
|
||||
var flat_sand := _find_variant(generator, &"chunk_0001", 0)
|
||||
_check(
|
||||
flat_grass != null and flat_sand != null,
|
||||
"Flat grass and sand variants must be available for seam validation.",
|
||||
)
|
||||
if flat_grass != null and flat_sand != null:
|
||||
for quarter_turns: int in 4:
|
||||
var transition := _find_variant(
|
||||
generator,
|
||||
&"chunk_0006",
|
||||
quarter_turns,
|
||||
)
|
||||
if transition == null:
|
||||
continue
|
||||
var inland_edge := TerrainChunkTopology.rotated_edge(
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
quarter_turns,
|
||||
)
|
||||
var neighbor_edge := TerrainChunkTopology.opposite_edge(inland_edge)
|
||||
_check(
|
||||
generator._edges_are_compatible(
|
||||
transition,
|
||||
inland_edge,
|
||||
flat_grass,
|
||||
neighbor_edge,
|
||||
),
|
||||
"Every transition rotation must accept grass behind it.",
|
||||
)
|
||||
_check(
|
||||
not generator._edges_are_compatible(
|
||||
transition,
|
||||
inland_edge,
|
||||
flat_sand,
|
||||
neighbor_edge,
|
||||
),
|
||||
"No transition rotation may accept sand behind it.",
|
||||
)
|
||||
for stable_id: StringName in [&"chunk_0005", &"chunk_0006", &"chunk_0007"]:
|
||||
for quarter_turns: int in 4:
|
||||
var coast_variant := _find_variant(
|
||||
generator,
|
||||
stable_id,
|
||||
quarter_turns,
|
||||
)
|
||||
_check(
|
||||
coast_variant != null,
|
||||
"%s must support rotation %d." % [stable_id, quarter_turns],
|
||||
)
|
||||
if coast_variant == null:
|
||||
continue
|
||||
_check(
|
||||
not generator._variant_respects_ocean_boundary(
|
||||
coast_variant,
|
||||
Vector2i(3, 3),
|
||||
),
|
||||
"%s must never place its ocean edge inside the map." % stable_id,
|
||||
)
|
||||
if stable_id != &"chunk_0007":
|
||||
continue
|
||||
var boundary_coordinate := Vector2i(3, 3)
|
||||
var ocean_edges := coast_variant.rotated_edge_mask(
|
||||
grass_ocean_corner.ocean_facing_edges
|
||||
)
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
if (ocean_edges & (1 << edge_value)) == 0:
|
||||
continue
|
||||
match edge_value as TerrainChunkTopology.Edge:
|
||||
TerrainChunkTopology.Edge.NORTH:
|
||||
boundary_coordinate.y = 0
|
||||
TerrainChunkTopology.Edge.EAST:
|
||||
boundary_coordinate.x = generator.grid_size.x - 1
|
||||
TerrainChunkTopology.Edge.SOUTH:
|
||||
boundary_coordinate.y = generator.grid_size.y - 1
|
||||
TerrainChunkTopology.Edge.WEST:
|
||||
boundary_coordinate.x = 0
|
||||
_check(
|
||||
generator._variant_respects_ocean_boundary(
|
||||
coast_variant,
|
||||
boundary_coordinate,
|
||||
),
|
||||
"Each grass corner rotation must fit its matching map corner.",
|
||||
)
|
||||
|
||||
|
||||
func _validate_generation_summary(generator: TerrainChunkGenerator) -> void:
|
||||
var summary := generator._build_summary()
|
||||
_check(
|
||||
int(summary.get("variant_count", 0)) == 33,
|
||||
"The current catalog must expose all 33 authored rotations.",
|
||||
)
|
||||
_check(
|
||||
int(summary.get("solver_variant_count", 0))
|
||||
== EXPECTED_SOLVER_VARIANT_COUNT,
|
||||
"Equivalent visual rotations must share one solver constraint.",
|
||||
)
|
||||
_check(
|
||||
int(summary.get("adjacent_repeat_edges", -1))
|
||||
== generator._count_adjacent_repeat_edges(),
|
||||
"The summary must report adjacent repeated chunk definitions.",
|
||||
)
|
||||
_check(
|
||||
str(summary.get("layout_fingerprint", ""))
|
||||
== generator.placement_fingerprint(),
|
||||
"The summary must identify the exact resolved layout.",
|
||||
)
|
||||
_check(
|
||||
generator.placement_fingerprint().length() == 64,
|
||||
"The resolved layout fingerprint must be SHA-256.",
|
||||
)
|
||||
var variant_counts: Dictionary = summary.get("variant_counts", {})
|
||||
for stable_id: StringName in EXPECTED_VARIANT_COUNTS:
|
||||
_check(
|
||||
int(variant_counts.get(stable_id, 0))
|
||||
== EXPECTED_VARIANT_COUNTS[stable_id],
|
||||
"The summary must report %s rotation candidates." % stable_id,
|
||||
)
|
||||
|
||||
|
||||
func _find_variant(
|
||||
generator: TerrainChunkGenerator,
|
||||
stable_id: StringName,
|
||||
quarter_turns: int,
|
||||
) -> TerrainChunkVariant:
|
||||
for variant: TerrainChunkVariant in generator._variants:
|
||||
if (
|
||||
variant.definition.stable_id == stable_id
|
||||
and variant.quarter_turns == quarter_turns
|
||||
):
|
||||
return variant
|
||||
return null
|
||||
|
||||
|
||||
func _make_generator() -> TerrainChunkGenerator:
|
||||
var generator := TerrainChunkGenerator.new()
|
||||
generator.catalog = CATALOG
|
||||
generator.grid_size = Vector2i(5, 5)
|
||||
generator.grid_size = Vector2i(7, 7)
|
||||
generator.generation_seed = TEST_SEED
|
||||
generator.generate_on_ready = false
|
||||
generator.build_collision = false
|
||||
|
|
@ -201,6 +713,128 @@ func _all_neighbor_edges_match(generator: TerrainChunkGenerator) -> bool:
|
|||
return true
|
||||
|
||||
|
||||
func _validate_layout_rules(generator: TerrainChunkGenerator) -> void:
|
||||
_check(
|
||||
generator._neighbor_requirement_validation_error(
|
||||
generator._placements
|
||||
).is_empty(),
|
||||
"Every generated chunk must satisfy its authored neighbor count.",
|
||||
)
|
||||
_check(
|
||||
generator._walkable_connectivity_validation_error(
|
||||
generator._placements
|
||||
).is_empty(),
|
||||
"All walkable generated terrain must remain connected to spawn.",
|
||||
)
|
||||
var center := Vector2i(
|
||||
generator.grid_size.x / 2,
|
||||
generator.grid_size.y / 2,
|
||||
)
|
||||
var safe_spawn_neighbors := 0
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var neighbor_coordinate := (
|
||||
center
|
||||
+ TerrainChunkTopology.grid_offset(
|
||||
edge_value as TerrainChunkTopology.Edge
|
||||
)
|
||||
)
|
||||
var neighbor := generator._placements[
|
||||
neighbor_coordinate.y * generator.grid_size.x
|
||||
+ neighbor_coordinate.x
|
||||
]
|
||||
if "spawn_safe" in neighbor.definition.tags:
|
||||
safe_spawn_neighbors += 1
|
||||
_check(
|
||||
safe_spawn_neighbors >= 3,
|
||||
"At least three cardinal spawn neighbors must be safe flat grass.",
|
||||
)
|
||||
for index: int in generator._placements.size():
|
||||
var placement := generator._placements[index]
|
||||
var coordinate := Vector2i(
|
||||
index % generator.grid_size.x,
|
||||
index / generator.grid_size.x,
|
||||
)
|
||||
if "coast" in placement.definition.tags:
|
||||
_check(
|
||||
generator._variant_respects_ocean_boundary(
|
||||
placement,
|
||||
coordinate,
|
||||
),
|
||||
"Every coastline chunk must direct its ocean edge outside the grid.",
|
||||
)
|
||||
if placement.definition.stable_id == &"chunk_0006":
|
||||
var touches_beach := false
|
||||
var touches_grass_edge := false
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var neighbor_coordinate := (
|
||||
coordinate
|
||||
+ TerrainChunkTopology.grid_offset(
|
||||
edge_value as TerrainChunkTopology.Edge
|
||||
)
|
||||
)
|
||||
if not generator._coordinate_is_inside_grid(neighbor_coordinate):
|
||||
continue
|
||||
var neighbor := generator._placements[
|
||||
neighbor_coordinate.y * generator.grid_size.x
|
||||
+ neighbor_coordinate.x
|
||||
]
|
||||
touches_beach = (
|
||||
touches_beach
|
||||
or neighbor.definition.stable_id == &"chunk_0002"
|
||||
)
|
||||
touches_grass_edge = (
|
||||
touches_grass_edge
|
||||
or neighbor.definition.stable_id == &"chunk_0005"
|
||||
)
|
||||
_check(
|
||||
touches_beach and touches_grass_edge,
|
||||
"Every grass/beach transition must join both authored edge types.",
|
||||
)
|
||||
if placement.definition.stable_id == &"chunk_0007":
|
||||
var straight_edge_neighbors := 0
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var neighbor_coordinate := (
|
||||
coordinate
|
||||
+ TerrainChunkTopology.grid_offset(
|
||||
edge_value as TerrainChunkTopology.Edge
|
||||
)
|
||||
)
|
||||
if not generator._coordinate_is_inside_grid(neighbor_coordinate):
|
||||
continue
|
||||
var neighbor := generator._placements[
|
||||
neighbor_coordinate.y * generator.grid_size.x
|
||||
+ neighbor_coordinate.x
|
||||
]
|
||||
if neighbor.definition.stable_id == &"chunk_0005":
|
||||
straight_edge_neighbors += 1
|
||||
_check(
|
||||
straight_edge_neighbors == 2,
|
||||
"Every grass corner must join two straight grass ocean edges.",
|
||||
)
|
||||
if placement.definition.stable_id != &"chunk_0001":
|
||||
continue
|
||||
var touches_coast := false
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var neighbor_coordinate := (
|
||||
coordinate
|
||||
+ TerrainChunkTopology.grid_offset(
|
||||
edge_value as TerrainChunkTopology.Edge
|
||||
)
|
||||
)
|
||||
if not generator._coordinate_is_inside_grid(neighbor_coordinate):
|
||||
continue
|
||||
var neighbor := generator._placements[
|
||||
neighbor_coordinate.y * generator.grid_size.x
|
||||
+ neighbor_coordinate.x
|
||||
]
|
||||
if "coast" in neighbor.definition.tags:
|
||||
touches_coast = true
|
||||
_check(
|
||||
touches_coast,
|
||||
"Every flat-sand chunk must remain directly attached to a beach.",
|
||||
)
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(message)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue