From 1e24fb0bfc569ab8194c0a75940887b668ffc1cf Mon Sep 17 00:00:00 2001 From: Voyager Date: Thu, 20 Aug 2026 00:28:55 -0400 Subject: [PATCH] Expand generated terrain with biome-driven props --- scripts/run_validations.sh | 1 + tests/generated_world_runtime_validation.gd | 330 ++++- .../terrain_chunk_compatibility_report.gd | 101 +- .../terrain_chunk_generator_test.gd | 6 +- .../terrain_chunk_generator_test.tscn | 4 +- tests/terrain_biome_validation.gd | 135 ++ tests/terrain_biome_validation.gd.uid | 1 + tests/terrain_chunk_generator_validation.gd | 656 ++++++++- tools/blender/export_terrain_chunks.py | 465 ++++++- .../biomes/definitions/biome_coast.tres | 20 + .../biomes/definitions/biome_forest.tres | 31 + .../biomes/definitions/biome_pine_forest.tres | 31 + .../biomes/definitions/biome_plains.tres | 28 + .../biomes/terrain_biome_catalog.tres | 12 + world/generation/chunks/assets/chunk_0005.glb | Bin 0 -> 17972 bytes .../chunks/assets/chunk_0005.glb.import | 45 + .../chunks/assets/chunk_0005_cliff_wall.png | Bin 0 -> 9025 bytes .../assets/chunk_0005_cliff_wall.png.import | 45 + .../chunks/assets/chunk_0005_grass_lite.png | Bin 0 -> 6317 bytes .../assets/chunk_0005_grass_lite.png.import | 45 + world/generation/chunks/assets/chunk_0006.glb | Bin 0 -> 33760 bytes .../chunks/assets/chunk_0006.glb.import | 45 + .../chunks/assets/chunk_0006_cliff_wall.png | Bin 0 -> 9025 bytes .../assets/chunk_0006_cliff_wall.png.import | 45 + .../chunks/assets/chunk_0006_grass_lite.png | Bin 0 -> 6317 bytes .../assets/chunk_0006_grass_lite.png.import | 45 + .../chunks/assets/chunk_0006_sand.png | Bin 0 -> 6289 bytes .../chunks/assets/chunk_0006_sand.png.import | 45 + world/generation/chunks/assets/chunk_0007.glb | Bin 0 -> 19004 bytes .../chunks/assets/chunk_0007.glb.import | 45 + .../chunks/assets/chunk_0007_cliff_wall.png | Bin 0 -> 9025 bytes .../assets/chunk_0007_cliff_wall.png.import | 45 + .../chunks/assets/chunk_0007_grass_lite.png | Bin 0 -> 6317 bytes .../assets/chunk_0007_grass_lite.png.import | 45 + .../chunks/definitions/chunk_0000.tres | 4 +- .../chunks/definitions/chunk_0001.tres | 6 +- .../chunks/definitions/chunk_0002.tres | 9 +- .../chunks/definitions/chunk_0003.tres | 4 +- .../chunks/definitions/chunk_0004.tres | 4 +- .../chunks/definitions/chunk_0005.tres | 21 + .../chunks/definitions/chunk_0006.tres | 23 + .../chunks/definitions/chunk_0007.tres | 22 + .../chunks/definitions/chunk_spawn.tres | 6 +- .../chunks/terrain_chunk_catalog.tres | 7 +- world/generation/generated_world_region.gd | 699 ++++++++-- world/generation/generated_world_region.tscn | 10 +- world/generation/props/assets/prop_bridge.glb | Bin 0 -> 81588 bytes .../props/assets/prop_bridge.glb.import | 45 + world/generation/props/assets/prop_dock.glb | Bin 0 -> 40396 bytes .../props/assets/prop_dock.glb.import | 45 + world/generation/props/assets/prop_log.glb | Bin 0 -> 11760 bytes .../props/assets/prop_log.glb.import | 45 + .../props/assets/prop_log_post_1.glb | Bin 0 -> 4864 bytes .../props/assets/prop_log_post_1.glb.import | 45 + .../props/assets/prop_log_post_2.glb | Bin 0 -> 4868 bytes .../props/assets/prop_log_post_2.glb.import | 45 + .../generation/props/assets/prop_mushroom.glb | Bin 0 -> 9384 bytes .../props/assets/prop_mushroom.glb.import | 45 + world/generation/props/assets/prop_palm.glb | Bin 0 -> 26408 bytes .../props/assets/prop_palm.glb.import | 45 + world/generation/props/assets/prop_pine.glb | Bin 0 -> 9408 bytes .../props/assets/prop_pine.glb.import | 45 + .../generation/props/assets/prop_timber_1.glb | Bin 0 -> 6264 bytes .../props/assets/prop_timber_1.glb.import | 45 + .../generation/props/assets/prop_timber_2.glb | Bin 0 -> 6264 bytes .../props/assets/prop_timber_2.glb.import | 45 + world/generation/props/assets/prop_tree_1.glb | Bin 0 -> 8416 bytes .../props/assets/prop_tree_1.glb.import | 45 + world/generation/props/assets/prop_tree_2.glb | Bin 0 -> 6324 bytes .../props/assets/prop_tree_2.glb.import | 45 + world/generation/props/assets/prop_tree_3.glb | Bin 0 -> 7316 bytes .../props/assets/prop_tree_3.glb.import | 45 + .../props/definitions/prop_bridge.tres | 11 + .../props/definitions/prop_dock.tres | 11 + .../props/definitions/prop_log.tres | 17 + .../props/definitions/prop_log_post_1.tres | 11 + .../props/definitions/prop_log_post_2.tres | 11 + .../props/definitions/prop_mushroom.tres | 18 + .../props/definitions/prop_palm.tres | 16 + .../props/definitions/prop_pine.tres | 18 + .../props/definitions/prop_timber_1.tres | 11 + .../props/definitions/prop_timber_2.tres | 11 + .../props/definitions/prop_tree_1.tres | 17 + .../props/definitions/prop_tree_2.tres | 17 + .../props/definitions/prop_tree_3.tres | 17 + .../props/terrain_prop_catalog.tres | 21 + world/generation/terrain_biome_assigner.gd | 220 +++ .../generation/terrain_biome_assigner.gd.uid | 1 + world/generation/terrain_biome_catalog.gd | 74 + world/generation/terrain_biome_catalog.gd.uid | 1 + world/generation/terrain_biome_definition.gd | 27 + .../terrain_biome_definition.gd.uid | 1 + world/generation/terrain_biome_prop_rule.gd | 21 + .../generation/terrain_biome_prop_rule.gd.uid | 1 + world/generation/terrain_chunk_catalog.gd | 45 + world/generation/terrain_chunk_definition.gd | 99 ++ world/generation/terrain_chunk_generator.gd | 1218 +++++++++++++++-- world/generation/terrain_chunk_topology.gd | 13 + world/generation/terrain_chunk_variant.gd | 39 + world/generation/terrain_prop_catalog.gd | 85 ++ world/generation/terrain_prop_catalog.gd.uid | 1 + world/generation/terrain_prop_definition.gd | 53 + .../generation/terrain_prop_definition.gd.uid | 1 + 103 files changed, 5427 insertions(+), 351 deletions(-) create mode 100644 tests/terrain_biome_validation.gd create mode 100644 tests/terrain_biome_validation.gd.uid create mode 100644 world/generation/biomes/definitions/biome_coast.tres create mode 100644 world/generation/biomes/definitions/biome_forest.tres create mode 100644 world/generation/biomes/definitions/biome_pine_forest.tres create mode 100644 world/generation/biomes/definitions/biome_plains.tres create mode 100644 world/generation/biomes/terrain_biome_catalog.tres create mode 100644 world/generation/chunks/assets/chunk_0005.glb create mode 100644 world/generation/chunks/assets/chunk_0005.glb.import create mode 100644 world/generation/chunks/assets/chunk_0005_cliff_wall.png create mode 100644 world/generation/chunks/assets/chunk_0005_cliff_wall.png.import create mode 100644 world/generation/chunks/assets/chunk_0005_grass_lite.png create mode 100644 world/generation/chunks/assets/chunk_0005_grass_lite.png.import create mode 100644 world/generation/chunks/assets/chunk_0006.glb create mode 100644 world/generation/chunks/assets/chunk_0006.glb.import create mode 100644 world/generation/chunks/assets/chunk_0006_cliff_wall.png create mode 100644 world/generation/chunks/assets/chunk_0006_cliff_wall.png.import create mode 100644 world/generation/chunks/assets/chunk_0006_grass_lite.png create mode 100644 world/generation/chunks/assets/chunk_0006_grass_lite.png.import create mode 100644 world/generation/chunks/assets/chunk_0006_sand.png create mode 100644 world/generation/chunks/assets/chunk_0006_sand.png.import create mode 100644 world/generation/chunks/assets/chunk_0007.glb create mode 100644 world/generation/chunks/assets/chunk_0007.glb.import create mode 100644 world/generation/chunks/assets/chunk_0007_cliff_wall.png create mode 100644 world/generation/chunks/assets/chunk_0007_cliff_wall.png.import create mode 100644 world/generation/chunks/assets/chunk_0007_grass_lite.png create mode 100644 world/generation/chunks/assets/chunk_0007_grass_lite.png.import create mode 100644 world/generation/chunks/definitions/chunk_0005.tres create mode 100644 world/generation/chunks/definitions/chunk_0006.tres create mode 100644 world/generation/chunks/definitions/chunk_0007.tres create mode 100644 world/generation/props/assets/prop_bridge.glb create mode 100644 world/generation/props/assets/prop_bridge.glb.import create mode 100644 world/generation/props/assets/prop_dock.glb create mode 100644 world/generation/props/assets/prop_dock.glb.import create mode 100644 world/generation/props/assets/prop_log.glb create mode 100644 world/generation/props/assets/prop_log.glb.import create mode 100644 world/generation/props/assets/prop_log_post_1.glb create mode 100644 world/generation/props/assets/prop_log_post_1.glb.import create mode 100644 world/generation/props/assets/prop_log_post_2.glb create mode 100644 world/generation/props/assets/prop_log_post_2.glb.import create mode 100644 world/generation/props/assets/prop_mushroom.glb create mode 100644 world/generation/props/assets/prop_mushroom.glb.import create mode 100644 world/generation/props/assets/prop_palm.glb create mode 100644 world/generation/props/assets/prop_palm.glb.import create mode 100644 world/generation/props/assets/prop_pine.glb create mode 100644 world/generation/props/assets/prop_pine.glb.import create mode 100644 world/generation/props/assets/prop_timber_1.glb create mode 100644 world/generation/props/assets/prop_timber_1.glb.import create mode 100644 world/generation/props/assets/prop_timber_2.glb create mode 100644 world/generation/props/assets/prop_timber_2.glb.import create mode 100644 world/generation/props/assets/prop_tree_1.glb create mode 100644 world/generation/props/assets/prop_tree_1.glb.import create mode 100644 world/generation/props/assets/prop_tree_2.glb create mode 100644 world/generation/props/assets/prop_tree_2.glb.import create mode 100644 world/generation/props/assets/prop_tree_3.glb create mode 100644 world/generation/props/assets/prop_tree_3.glb.import create mode 100644 world/generation/props/definitions/prop_bridge.tres create mode 100644 world/generation/props/definitions/prop_dock.tres create mode 100644 world/generation/props/definitions/prop_log.tres create mode 100644 world/generation/props/definitions/prop_log_post_1.tres create mode 100644 world/generation/props/definitions/prop_log_post_2.tres create mode 100644 world/generation/props/definitions/prop_mushroom.tres create mode 100644 world/generation/props/definitions/prop_palm.tres create mode 100644 world/generation/props/definitions/prop_pine.tres create mode 100644 world/generation/props/definitions/prop_timber_1.tres create mode 100644 world/generation/props/definitions/prop_timber_2.tres create mode 100644 world/generation/props/definitions/prop_tree_1.tres create mode 100644 world/generation/props/definitions/prop_tree_2.tres create mode 100644 world/generation/props/definitions/prop_tree_3.tres create mode 100644 world/generation/props/terrain_prop_catalog.tres create mode 100644 world/generation/terrain_biome_assigner.gd create mode 100644 world/generation/terrain_biome_assigner.gd.uid create mode 100644 world/generation/terrain_biome_catalog.gd create mode 100644 world/generation/terrain_biome_catalog.gd.uid create mode 100644 world/generation/terrain_biome_definition.gd create mode 100644 world/generation/terrain_biome_definition.gd.uid create mode 100644 world/generation/terrain_biome_prop_rule.gd create mode 100644 world/generation/terrain_biome_prop_rule.gd.uid create mode 100644 world/generation/terrain_prop_catalog.gd create mode 100644 world/generation/terrain_prop_catalog.gd.uid create mode 100644 world/generation/terrain_prop_definition.gd create mode 100644 world/generation/terrain_prop_definition.gd.uid diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index e612f37..67b8abe 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -43,6 +43,7 @@ readonly -a QUICK_TESTS=( "tests/shoreline_ambience_validation.gd" "tests/surface_drawing_validation.gd" "tests/tackle_order_validation.gd" + "tests/terrain_biome_validation.gd" "tests/terrain_blender_material_validation.gd" "tests/terrain_chunk_generator_validation.gd" "tests/texture_sampling_validation.gd" diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index fdc8cc2..c6ac945 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -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: diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd index 2420d0d..64d5f1f 100644 --- a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd @@ -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 diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd index f815109..657347f 100644 --- a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd @@ -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), ] diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn index 742d2e8..964da9a 100644 --- a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn @@ -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) diff --git a/tests/terrain_biome_validation.gd b/tests/terrain_biome_validation.gd new file mode 100644 index 0000000..968ff5e --- /dev/null +++ b/tests/terrain_biome_validation.gd @@ -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) diff --git a/tests/terrain_biome_validation.gd.uid b/tests/terrain_biome_validation.gd.uid new file mode 100644 index 0000000..40de9e4 --- /dev/null +++ b/tests/terrain_biome_validation.gd.uid @@ -0,0 +1 @@ +uid://cxioi62f6oap0 diff --git a/tests/terrain_chunk_generator_validation.gd b/tests/terrain_chunk_generator_validation.gd index c82f28d..0d70816 100644 --- a/tests/terrain_chunk_generator_validation.gd +++ b/tests/terrain_chunk_generator_validation.gd @@ -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) diff --git a/tools/blender/export_terrain_chunks.py b/tools/blender/export_terrain_chunks.py index f6ed976..27b775d 100644 --- a/tools/blender/export_terrain_chunks.py +++ b/tools/blender/export_terrain_chunks.py @@ -9,7 +9,11 @@ Run through Blender rather than a standalone Python interpreter: Collections use ``chunk_####_description`` and contain one primary terrain mesh named ``chunk_####``. Additional production objects may live in the same -collection; unrelated collections are ignored. +collection. Reusable procedural props use individual ``prop_description`` +mesh objects. They may be arranged anywhere in the source file because each +object's authored origin becomes the exported runtime anchor. A same-named +``prop_description`` collection remains supported for multi-object props. +Unrelated objects and collections are ignored. """ from __future__ import annotations @@ -30,10 +34,14 @@ from mathutils import Vector CHUNK_SIZE_METERS = 10.0 -DEFAULT_TOLERANCE = 0.001 +# Match the runtime boundary analyzer's five-millimeter authoring tolerance. +DEFAULT_TOLERANCE = 0.005 COLLECTION_PATTERN = re.compile( r"^chunk_(?P\d{4})(?:_(?P