diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd index 6e456fd..842b3e8 100644 --- a/tests/fishing_surface_validation.gd +++ b/tests/fishing_surface_validation.gd @@ -199,6 +199,59 @@ func _validate_portable_water_body() -> void: mesh_derived_body.queue_free() await process_frame + var polygon_body := WaterBodyScene.instantiate() as WaterBodyAuthoring + polygon_body.position = Vector3(30.0, 5.0, 20.0) + polygon_body.surface_polygon = PackedVector2Array([ + Vector2(-4.0, -4.0), + Vector2(4.0, -4.0), + Vector2(4.0, 4.0), + Vector2(0.0, 1.0), + Vector2(-4.0, 4.0), + ]) + polygon_body.visual_surface_enabled = false + polygon_body.fish_pool = PondPool + root.add_child(polygon_body) + await physics_frame + await physics_frame + + var polygon_fishing_region := polygon_body.get_node( + "FishingRegion" + ) as FishableWaterRegionType + var polygon_recovery_region := polygon_body.get_node( + "RecoveryRegion" + ) as Area3D + assert(polygon_fishing_region.get_child_count() == 3) + assert(polygon_recovery_region.get_child_count() == 3) + for child: Node in polygon_fishing_region.get_children(): + var polygon_shape_node := child as CollisionShape3D + assert(polygon_shape_node != null) + assert(polygon_shape_node.shape is ConvexPolygonShape3D) + + var polygon_resolver := FishingSurfaceResolverType.new() + var polygon_space_state := ( + polygon_body.get_world_3d().direct_space_state + ) + var polygon_inside: FishingSurfaceSampleType = ( + polygon_resolver.resolve_surface( + polygon_space_state, + Vector3(30.0, 0.0, 20.0), + 8.0, + ) + ) + assert(polygon_inside.is_fishable()) + assert(is_equal_approx(polygon_inside.position.y, 5.0)) + var polygon_notch: FishingSurfaceSampleType = ( + polygon_resolver.resolve_surface( + polygon_space_state, + Vector3(30.0, 0.0, 23.0), + 8.0, + ) + ) + assert(not polygon_notch.has_surface) + + polygon_body.queue_free() + await process_frame + func _validate_starter_region_surfaces() -> void: var region := StarterRegionScene.instantiate() as Node3D diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 0f7a4dc..0d7d26f 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -14,6 +14,7 @@ const GeneratedRiverPool: FishPool = preload( ) const FIRST_SEED := 13001 const SECOND_SEED := 13002 +const RIVER_FALLBACK_SEED := 13012 const EXPECTED_PROP_IDS: Array[StringName] = [ &"prop_bridge", &"prop_dock", @@ -62,6 +63,9 @@ func _run() -> void: ) as TerrainChunkGenerator assert(configured_generator != null) configured_generator.elevated_cliff_third_level_chance = 1.0 + configured_generator.elevated_cliff_third_tier_base_chance = 0.0 + configured_generator.elevated_cliff_third_tier_stack_chance = 1.0 + configured_generator.elevated_cliff_double_third_tier_chance = 0.0 root.add_child(region) await process_frame await physics_frame @@ -74,13 +78,19 @@ func _run() -> void: _validate_generated_region(region, generator) var first_layout := generator.placement_keys() + var first_stacked_layout := generator.stacked_elevated_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) + generator._elevated_feature_center = Vector2i(-1, -1) + assert(generator.generate_from_placement_keys(first_layout)) + assert(generator.stacked_elevated_placement_keys() == first_stacked_layout) + configured_generator.elevated_cliff_third_tier_base_chance = 1.0 + configured_generator.elevated_cliff_third_tier_stack_chance = 0.0 assert(region.generate_world(SECOND_SEED)) await process_frame await physics_frame @@ -88,6 +98,17 @@ func _run() -> void: _validate_generated_region(region, generator) assert(generator.placement_keys() != first_layout) assert(_biome_signature(region, generator) != first_biomes) + var second_layout := generator.placement_keys() + + configured_generator.elevated_cliff_double_third_tier_chance = 1.0 + assert(region.generate_world(RIVER_FALLBACK_SEED)) + await process_frame + await physics_frame + await physics_frame + _validate_generated_region(region, generator) + assert(generator.placement_keys() != first_layout) + assert(generator.placement_keys() != second_layout) + assert(generator._river_feature_candidate_index >= 0) region.queue_free() await process_frame @@ -106,32 +127,192 @@ func _validate_generated_region( generator.grid_size.y / 2, ) var center_index := center.y * generator.grid_size.x + center.x + assert(generator.grid_size == Vector2i(20, 20)) + assert(generator._lake_feature_footprint_size.x >= 7) + assert( + generator._lake_feature_footprint_size.x + == generator._lake_feature_footprint_size.y + ) + assert(maxi( + generator._river_feature_footprint_size.x, + generator._river_feature_footprint_size.y, + ) >= 7) + assert(mini( + generator._river_feature_footprint_size.x, + generator._river_feature_footprint_size.y, + ) == 2) + assert(generator._river_feature_flow_direction in [ + Vector2i.RIGHT, + Vector2i.DOWN, + Vector2i.LEFT, + Vector2i.UP, + ]) + assert(generator.elevated_cliff_coastal_feature_required) assert(placements.size() == expected_chunk_count) assert(placements[center_index].begins_with("chunk_spawn@")) assert(_placement_count(placements, "chunk_spawn") == 1) assert(_placement_count(placements, "chunk_0001") >= 1) - assert(_placement_count(placements, "chunk_0003") >= 1) - assert(_placement_count(placements, "chunk_0004") >= 1) + var stream_count := ( + _placement_count(placements, "chunk_0003") + + _placement_count(placements, "chunk_0016") + ) + assert(stream_count >= 1 and stream_count <= 3) + assert(_placement_count(placements, "chunk_0004") == 2) var beach_count := _placement_count(placements, "chunk_0002") var grass_coast_count := _placement_count(placements, "chunk_0005") var grass_corner_count := _placement_count(placements, "chunk_0007") var beach_corner_count := _placement_count(placements, "chunk_0008") - assert(beach_count == 44 and grass_coast_count == 0) - assert(beach_corner_count == 3 and grass_corner_count == 0) - assert(_placement_count(placements, "chunk_0009") == 10) - assert(_placement_count(placements, "chunk_0010") == 5) + var has_secondary_elevation := ( + generator._secondary_elevated_feature_center.x >= 0 + ) + var has_third_tier_base := ( + _placement_count(placements, "chunk_0028") > 0 + ) + var coastline_edge_count := ( + 2 * (generator.grid_size.x - 2) + + 2 * (generator.grid_size.y - 2) + ) + assert( + beach_count + == coastline_edge_count - (4 if has_secondary_elevation else 0) - 2 + and grass_coast_count == 0 + ) + assert( + beach_corner_count == (3 if has_secondary_elevation else 4) + and grass_corner_count == 0 + ) + assert( + _placement_count(placements, "chunk_0009") + == ( + (1 if has_secondary_elevation else 0) + + (0 if has_third_tier_base else 9) + ) + ) + assert( + _placement_count(placements, "chunk_0010") + == ( + (1 if has_secondary_elevation else 0) + + (0 if has_third_tier_base else 4) + ) + ) assert( _placement_count(placements, "chunk_0011") + _placement_count(placements, "chunk_0012") - == 14 + == ( + (2 if has_secondary_elevation else 0) + + (0 if has_third_tier_base else 12) + ) + ) + assert( + _placement_count(placements, "chunk_0012") + == ( + (1 if has_secondary_elevation else 0) + + (0 if has_third_tier_base else 1) + ) + ) + assert( + _placement_count(placements, "chunk_0028") + == (9 if has_third_tier_base else 0) + ) + assert( + _placement_count(placements, "chunk_0027") + == (4 if has_third_tier_base else 0) + ) + assert( + _placement_count(placements, "chunk_0026") + == (10 if has_third_tier_base else 0) + ) + assert(_placement_count(placements, "chunk_0029") == 1) + assert(_placement_count(placements, "chunk_0030") == 1) + assert(_placement_count(placements, "chunk_0031") == 1) + assert(_placement_count(placements, "chunk_0032") == 1) + assert( + _placement_count(placements, "chunk_0014") + == (2 if has_secondary_elevation else 0) + ) + assert( + _placement_count(placements, "chunk_0015") + == (1 if has_secondary_elevation else 0) ) - assert(_placement_count(placements, "chunk_0012") == 2) - assert(_placement_count(placements, "chunk_0014") == 2) - assert(_placement_count(placements, "chunk_0015") == 1) - assert(_placement_count(placements, "chunk_0017") == 0) assert(_placement_count(placements, "chunk_0018") == 0) - assert(_placement_count(placements, "chunk_0019") == 1) - assert(_placement_count(placements, "chunk_0020") == 1) + assert(_placement_count(placements, "chunk_0020") == 4) + assert(_placement_count(placements, "chunk_0022") == 4) + assert(_placement_count(placements, "chunk_0023") == 4) + var lake_edge_count := _placement_count(placements, "chunk_0017") + var lake_fill_count := _placement_count(placements, "chunk_0019") + var lake_narrow_count := _placement_count(placements, "chunk_0021") + var lake_size := generator._lake_feature_footprint_size.x + assert(lake_size in [7, 8]) + assert(lake_fill_count == (lake_size - 2) * (lake_size - 2)) + assert(lake_edge_count == (lake_size - 6) * 4) + assert(lake_narrow_count == 8) + assert(_placement_count(placements, "chunk_9999") == 0) + assert(_placement_count(placements, "chunk_9998") == 0) + assert( + _placement_count(placements, "chunk_9997") + == (1 if has_secondary_elevation else 0) + ) + assert( + _placement_count(placements, "chunk_9996") + == (1 if has_secondary_elevation else 0) + ) + assert(_placement_count(placements, "chunk_9995") == 0) + var river_length := maxi( + generator._river_feature_footprint_size.x, + generator._river_feature_footprint_size.y, + ) + assert(river_length in [7, 8]) + assert(_placement_count(placements, "chunk_0024") == river_length - 2) + assert(_placement_count(placements, "chunk_0025") == river_length - 2) + var river_source_coordinates := generator._river_source_coordinates( + generator._river_feature_origin, + generator._river_feature_footprint_size, + generator._river_feature_flow_direction, + ) + assert(river_source_coordinates.size() == 2) + for source_coordinate: Vector2i in river_source_coordinates: + var source_index := ( + source_coordinate.y * generator.grid_size.x + source_coordinate.x + ) + assert( + placements[source_index].begins_with("chunk_0029@") + or placements[source_index].begins_with("chunk_0030@") + ) + var source_offset := source_coordinate - generator._elevated_feature_center + assert( + maxi(absi(source_offset.x), absi(source_offset.y)) + == TerrainChunkGenerator.ELEVATED_FEATURE_RADIUS + ) + var outlet_coordinates: Array[Vector2i] = [] + for index: int in placements.size(): + if ( + placements[index].begins_with("chunk_0031@") + or placements[index].begins_with("chunk_0032@") + ): + var outlet_coordinate := Vector2i( + index % generator.grid_size.x, + index / generator.grid_size.x, + ) + outlet_coordinates.append(outlet_coordinate) + assert( + not generator._coordinate_is_inside_grid( + outlet_coordinate + + generator._river_feature_flow_direction + ) + ) + assert(outlet_coordinates.size() == 2) + var outlet_delta := outlet_coordinates[1] - outlet_coordinates[0] + assert(absi(outlet_delta.x) + absi(outlet_delta.y) == 1) + for beach_coordinate: Vector2i in [ + outlet_coordinates[0] - outlet_delta, + outlet_coordinates[1] + outlet_delta, + ]: + assert(generator._coordinate_is_inside_grid(beach_coordinate)) + assert(generator._coordinate_is_on_boundary(beach_coordinate)) + var beach_index := ( + beach_coordinate.y * generator.grid_size.x + beach_coordinate.x + ) + assert(placements[beach_index].begins_with("chunk_0002@")) for index: int in placements.size(): var coordinate := Vector2i( index % generator.grid_size.x, @@ -159,8 +340,11 @@ func _validate_generated_region( var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D assert(shop != null and shop.has_node("Shopkeeper")) assert(storage != null and storage.has_node("InteractionArea")) - assert(absf(shop.position.x) < 5.0 and absf(shop.position.z) < 5.0) - assert(absf(storage.position.x) < 5.0 and absf(storage.position.z) < 5.0) + var spawn_chunk_center := generator.chunk_position(center) + var shop_offset := shop.position - spawn_chunk_center + var storage_offset := storage.position - spawn_chunk_center + assert(absf(shop_offset.x) < 5.0 and absf(shop_offset.z) < 5.0) + assert(absf(storage_offset.x) < 5.0 and absf(storage_offset.z) < 5.0) assert(region.get_fishing_shop() != null) assert(region.get_player_storage() != null) assert(region.get_player_spawn_transform().origin.y > 0.0) @@ -173,30 +357,68 @@ func _validate_generated_region( assert(ocean.water_type == WaterType.Type.SALT_WATER) assert(is_equal_approx(ocean.position.y, GeneratedWorldRegion.WATER_HEIGHT)) var fresh_placement_count := 0 + var river_placement_count := 0 + var polygon_sizes_by_coordinate: Dictionary[Vector2i, int] = {} 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 + if "river" in tags: + river_placement_count += 1 + assert(not "pond" in tags) + assert(not "lake" in tags) + var surface_polygon: PackedVector2Array = record.get( + "water_surface_polygon", + PackedVector2Array(), + ) + if surface_polygon.size() >= 3: + polygon_sizes_by_coordinate[ + record.get("coordinate", Vector2i.ZERO) + ] = surface_polygon.size() _validate_complete_coastline(generator) assert(fresh_root.get_child_count() == fresh_placement_count) + assert(river_placement_count == river_length * 2) + assert( + polygon_sizes_by_coordinate.size() + == 20 + river_placement_count + ) + var river_body_count := 0 for child: Node in fresh_root.get_children(): var fresh := child as WaterBodyAuthoring assert(fresh != null) assert(fresh.water_type == WaterType.Type.FRESH_WATER) assert(is_equal_approx(fresh.position.y, GeneratedWorldRegion.WATER_HEIGHT)) assert(fresh.visible) - assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0) + assert(fresh.surface_size.x <= 10.0 and fresh.surface_size.y <= 10.0) assert(not fresh.visual_surface_enabled) assert(not (fresh.get_node("VisualWater") as MeshInstance3D).visible) + var coordinate_tokens := child.name.trim_prefix("FreshWater_").split("_") + assert(coordinate_tokens.size() == 2) + var coordinate := Vector2i( + int(coordinate_tokens[0]), + int(coordinate_tokens[1]), + ) + if polygon_sizes_by_coordinate.has(coordinate): + assert( + fresh.surface_polygon.size() + == polygon_sizes_by_coordinate[coordinate] + ) + assert( + fresh.get_node("FishingRegion").get_child_count() > 1 + ) + else: + assert(fresh.surface_polygon.is_empty()) if &"river" in fresh.location_tags: + river_body_count += 1 assert(fresh.fish_pool == GeneratedRiverPool) elif &"lake" in fresh.location_tags: assert(fresh.fish_pool == GeneratedLakePool) else: assert(&"pond" in fresh.location_tags) assert(fresh.fish_pool == GeneratedPondPool) + assert(river_body_count == river_placement_count) _validate_authored_chunk_surfaces(region, generator) @@ -351,10 +573,10 @@ func _validate_elevated_cliff_feature( var coastal_ids: Array[StringName] = [ &"chunk_0014", &"chunk_0015", - &"chunk_0017", - &"chunk_0018", - &"chunk_0019", - &"chunk_0020", + &"chunk_9999", + &"chunk_9998", + &"chunk_9997", + &"chunk_9996", ] var has_coastal_feature := false for record: Dictionary in generator.placement_records(): @@ -364,12 +586,17 @@ func _validate_elevated_cliff_feature( &"chunk_0010", &"chunk_0011", &"chunk_0012", + &"chunk_0026", + &"chunk_0027", + &"chunk_0028", + &"chunk_0029", + &"chunk_0030", &"chunk_0014", &"chunk_0015", - &"chunk_0017", - &"chunk_0018", - &"chunk_0019", - &"chunk_0020", + &"chunk_9999", + &"chunk_9998", + &"chunk_9997", + &"chunk_9996", ]: continue var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) @@ -383,14 +610,32 @@ func _validate_elevated_cliff_feature( assert(coordinate.x > 0 and coordinate.x < generator.grid_size.x - 1) assert(coordinate.y > 0 and coordinate.y < generator.grid_size.y - 1) elevated_coordinates[coordinate] = stable_id - if stable_id == &"chunk_0009": + if stable_id in [&"chunk_0009", &"chunk_0028"]: top_coordinates.append(coordinate) elif stable_id == &"chunk_0012": ramp_records.append(record) assert(elevated_coordinates.size() == (34 if has_coastal_feature else 25)) - assert(top_coordinates.size() == (10 if has_coastal_feature else 9)) - assert(ramp_records.size() == (2 if has_coastal_feature else 1)) - var feature_center := Vector2i(-1, -1) + var feature_center := generator._elevated_feature_center + assert(feature_center != Vector2i(-1, -1)) + var center_index := feature_center.y * generator.grid_size.x + feature_center.x + assert(center_index >= 0 and center_index < generator.placement_keys().size()) + var primary_top_id := StringName( + generator.placement_keys()[center_index].get_slice("@", 0) + ) + assert(primary_top_id in [&"chunk_0009", &"chunk_0028"]) + var primary_is_third_tier := primary_top_id == &"chunk_0028" + assert( + top_coordinates.size() + == (9 + (1 if has_coastal_feature else 0)) + ) + assert( + ramp_records.size() + == ( + (0 if primary_is_third_tier else 1) + + (1 if has_coastal_feature else 0) + ) + ) + var detected_feature_center := Vector2i(-1, -1) for candidate: Vector2i in top_coordinates: var neighboring_top_count := 0 for row_offset: int in range(-1, 2): @@ -399,12 +644,12 @@ func _validate_elevated_cliff_feature( elevated_coordinates.get( candidate + Vector2i(column_offset, row_offset), &"", - ) == &"chunk_0009" + ) == primary_top_id ) if neighboring_top_count == 9: - feature_center = candidate + detected_feature_center = candidate break - assert(feature_center != Vector2i(-1, -1)) + assert(detected_feature_center == feature_center) for row_offset: int in range(-2, 3): for column_offset: int in range(-2, 3): assert( @@ -413,14 +658,7 @@ func _validate_elevated_cliff_feature( ) ) if has_coastal_feature: - var secondary_top := Vector2i(-1, -1) - for coordinate: Vector2i in top_coordinates: - if ( - absi(coordinate.x - feature_center.x) > 1 - or absi(coordinate.y - feature_center.y) > 1 - ): - secondary_top = coordinate - break + var secondary_top := generator._secondary_elevated_feature_center assert(secondary_top != Vector2i(-1, -1)) for row_offset: int in range(-1, 2): for column_offset: int in range(-1, 2): @@ -429,7 +667,7 @@ func _validate_elevated_cliff_feature( secondary_top + Vector2i(column_offset, row_offset) ) ) - var found_deep_top_landing := false + var found_primary_ramp := false for ramp_record: Dictionary in ramp_records: var ramp_coordinate: Vector2i = ramp_record.get( "coordinate", Vector2i(-1, -1) @@ -444,8 +682,8 @@ func _validate_elevated_cliff_feature( elevated_coordinates.get(ramp_coordinate + high_offset, &"") == &"chunk_0009" ) - found_deep_top_landing = ( - found_deep_top_landing + found_primary_ramp = ( + found_primary_ramp or elevated_coordinates.get( ramp_coordinate + high_offset * 2, &"", @@ -459,7 +697,7 @@ func _validate_elevated_cliff_feature( generator.placement_keys()[low_index].begins_with("chunk_0000@") or generator.placement_keys()[low_index].begins_with("chunk_spawn@") ) - assert(found_deep_top_landing) + assert(found_primary_ramp == not primary_is_third_tier) var generated := generator.get_generated_chunks_root() assert(generated != null) @@ -473,25 +711,39 @@ func _validate_elevated_cliff_feature( &"chunk_0010", &"chunk_0011", &"chunk_0012", + &"chunk_0026", + &"chunk_0027", + &"chunk_0028", + &"chunk_0029", + &"chunk_0030", &"chunk_0014", &"chunk_0015", - &"chunk_0017", - &"chunk_0018", - &"chunk_0019", - &"chunk_0020", + &"chunk_9999", + &"chunk_9998", + &"chunk_9997", + &"chunk_9996", ]: continue if stable_id in [&"chunk_0014", &"chunk_0015"]: assert(chunk_root.get_node_or_null("TerrainBaseLayer") == null) continue + if stable_id in [&"chunk_0029", &"chunk_0030"]: + assert(chunk_root.get_node_or_null("TerrainBaseLayer") == null) + var source_mesh := TerrainChunkAnalyzer.find_primary_mesh( + chunk_root, + stable_id, + ) + assert(source_mesh != null and source_mesh.mesh != null) + assert(source_mesh.has_node("TerrainCollision")) + continue layered_count += 1 var base_layer := chunk_root.get_node_or_null("TerrainBaseLayer") var overlay := chunk_root.get_node_or_null("TerrainOverlay") assert(base_layer != null and overlay != null) var transition_base_mesh := &"chunk_0000" - if stable_id in [&"chunk_0017", &"chunk_0018"]: + if stable_id in [&"chunk_9999", &"chunk_9998"]: transition_base_mesh = &"chunk_0007" - elif stable_id in [&"chunk_0019", &"chunk_0020"]: + elif stable_id in [&"chunk_9997", &"chunk_9996"]: transition_base_mesh = &"chunk_0008" var base_mesh := TerrainChunkAnalyzer.find_primary_mesh( base_layer, @@ -502,10 +754,10 @@ func _validate_elevated_cliff_feature( ( &"chunk_0015" if stable_id in [ - &"chunk_0017", - &"chunk_0018", - &"chunk_0019", - &"chunk_0020", + &"chunk_9999", + &"chunk_9998", + &"chunk_9997", + &"chunk_9996", ] else stable_id ), @@ -514,32 +766,40 @@ func _validate_elevated_cliff_feature( assert(overlay_mesh != null and overlay_mesh.mesh != null) assert(base_mesh.has_node("TerrainBaseLayerCollision")) assert(overlay_mesh.has_node("TerrainCollision")) - assert(layered_count == (31 if has_coastal_feature else 25)) + assert(layered_count == (29 if has_coastal_feature else 23)) var stacked_keys := generator.stacked_elevated_placement_keys() assert(stacked_keys.size() in [0, 4]) if stacked_keys.is_empty(): return for key: String in stacked_keys: - assert(key.begins_with("chunk_0010@")) + assert( + key.begins_with("chunk_0010@") + or key.begins_with("chunk_0027@") + ) var stacked_count := 0 for chunk_root: Node in generated.get_children(): for child: Node in chunk_root.get_children(): if not bool(child.get_meta(&"terrain_stacked_elevation", false)): continue stacked_count += 1 - assert( - StringName(child.get_meta(&"terrain_chunk_id", &"")) - == &"chunk_0010" + var stacked_id := StringName( + child.get_meta(&"terrain_chunk_id", &"") + ) + assert(stacked_id in [&"chunk_0010", &"chunk_0027"]) + var expected_height := ( + generator.elevated_cliff_level_height * 2.0 + if primary_is_third_tier + else generator.elevated_cliff_level_height ) assert( is_equal_approx( (child as Node3D).position.y, - generator.elevated_cliff_level_height, + expected_height, ) ) var stacked_mesh := TerrainChunkAnalyzer.find_primary_mesh( child, - &"chunk_0010", + stacked_id, ) assert(stacked_mesh != null and stacked_mesh.mesh != null) assert(stacked_mesh.has_node("TerrainCollision")) diff --git a/tests/terrain_chunk_generator_validation.gd b/tests/terrain_chunk_generator_validation.gd index 591467b..e8bf158 100644 --- a/tests/terrain_chunk_generator_validation.gd +++ b/tests/terrain_chunk_generator_validation.gd @@ -25,6 +25,23 @@ const EXPECTED_IDS: Array[String] = [ "chunk_0018", "chunk_0019", "chunk_0020", + "chunk_0021", + "chunk_0022", + "chunk_0023", + "chunk_0024", + "chunk_0025", + "chunk_0026", + "chunk_0027", + "chunk_0028", + "chunk_0029", + "chunk_0030", + "chunk_0031", + "chunk_0032", + "chunk_9999", + "chunk_9998", + "chunk_9997", + "chunk_9996", + "chunk_9995", "chunk_spawn", ] const REQUIRED_IDS: Array[String] = [ @@ -57,8 +74,25 @@ const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = { &"chunk_0016": 4, &"chunk_0017": 4, &"chunk_0018": 4, - &"chunk_0019": 4, + &"chunk_0019": 1, &"chunk_0020": 4, + &"chunk_0021": 4, + &"chunk_0022": 4, + &"chunk_0023": 4, + &"chunk_0024": 4, + &"chunk_0025": 4, + &"chunk_0026": 4, + &"chunk_0027": 4, + &"chunk_0028": 4, + &"chunk_0029": 4, + &"chunk_0030": 4, + &"chunk_0031": 4, + &"chunk_0032": 4, + &"chunk_9999": 4, + &"chunk_9998": 4, + &"chunk_9997": 4, + &"chunk_9996": 4, + &"chunk_9995": 4, &"chunk_spawn": 1, } @@ -104,15 +138,32 @@ func _validate_catalog() -> void: var pond := CATALOG.definition_for_id(&"chunk_0004") var cliff_sea_edge := CATALOG.definition_for_id(&"chunk_0014") var cliff_sea_corner := CATALOG.definition_for_id(&"chunk_0015") - var cliff_sea_transition_right := CATALOG.definition_for_id(&"chunk_0017") - var cliff_sea_transition_left := CATALOG.definition_for_id(&"chunk_0018") - var cliff_beach_transition_right := CATALOG.definition_for_id(&"chunk_0019") - var cliff_beach_transition_left := CATALOG.definition_for_id(&"chunk_0020") + var cliff_sea_transition_right := CATALOG.definition_for_id(&"chunk_9999") + var cliff_sea_transition_left := CATALOG.definition_for_id(&"chunk_9998") + var cliff_beach_transition_right := CATALOG.definition_for_id(&"chunk_9997") + var cliff_beach_transition_left := CATALOG.definition_for_id(&"chunk_9996") var stream_variant := CATALOG.definition_for_id(&"chunk_0016") + var lake_edge := CATALOG.definition_for_id(&"chunk_0017") + var lake_corner := CATALOG.definition_for_id(&"chunk_0018") + var lake_fill := CATALOG.definition_for_id(&"chunk_0019") + var lake_diagonal := CATALOG.definition_for_id(&"chunk_0020") + var lake_narrow := CATALOG.definition_for_id(&"chunk_0021") + var lake_regular_to_narrow := CATALOG.definition_for_id(&"chunk_0022") + var lake_narrow_to_regular := CATALOG.definition_for_id(&"chunk_0023") + var river_edge := CATALOG.definition_for_id(&"chunk_0024") + var river_edge_variant := CATALOG.definition_for_id(&"chunk_0025") + var river_source_right := CATALOG.definition_for_id(&"chunk_0029") + var river_source_left := CATALOG.definition_for_id(&"chunk_0030") + var river_outlet_right := CATALOG.definition_for_id(&"chunk_0031") + var river_outlet_left := CATALOG.definition_for_id(&"chunk_0032") + var river_cap := CATALOG.definition_for_id(&"chunk_9995") var cliff_top := CATALOG.definition_for_id(&"chunk_0009") var cliff_corner := CATALOG.definition_for_id(&"chunk_0010") var cliff_edge := CATALOG.definition_for_id(&"chunk_0011") var cliff_ramp := CATALOG.definition_for_id(&"chunk_0012") + var cliff_third_tier_edge := CATALOG.definition_for_id(&"chunk_0026") + var cliff_third_tier_corner := CATALOG.definition_for_id(&"chunk_0027") + var cliff_third_tier_top := CATALOG.definition_for_id(&"chunk_0028") var spawn := CATALOG.definition_for_id(&"chunk_spawn") _check( ( @@ -132,10 +183,27 @@ func _validate_catalog() -> void: and cliff_beach_transition_right != null and cliff_beach_transition_left != null and stream_variant != null + and lake_edge != null + and lake_corner != null + and lake_fill != null + and lake_diagonal != null + and lake_narrow != null + and lake_regular_to_narrow != null + and lake_narrow_to_regular != null + and river_edge != null + and river_edge_variant != null + and river_source_right != null + and river_source_left != null + and river_outlet_right != null + and river_outlet_left != null + and river_cap != null and cliff_top != null and cliff_corner != null and cliff_edge != null and cliff_ramp != null + and cliff_third_tier_edge != null + and cliff_third_tier_corner != null + and cliff_third_tier_top != null and spawn != null ), "The authored biome-rule definitions must be available.", @@ -157,10 +225,27 @@ func _validate_catalog() -> void: and cliff_beach_transition_right != null and cliff_beach_transition_left != null and stream_variant != null + and lake_edge != null + and lake_corner != null + and lake_fill != null + and lake_diagonal != null + and lake_narrow != null + and lake_regular_to_narrow != null + and lake_narrow_to_regular != null + and river_edge != null + and river_edge_variant != null + and river_source_right != null + and river_source_left != null + and river_outlet_right != null + and river_outlet_left != null + and river_cap != null and cliff_top != null and cliff_corner != null and cliff_edge != null and cliff_ramp != null + and cliff_third_tier_edge != null + and cliff_third_tier_corner != null + and cliff_third_tier_top != null and spawn != null ): _check( @@ -314,10 +399,18 @@ func _validate_catalog() -> void: == (1 << int(TerrainChunkTopology.Edge.NORTH)), "A pond must terminate either end of an inland freshwater chain.", ) + _check( + pond.maximum_placements == 2, + "Generated worlds must contain only one pond/stream chain.", + ) _check( stream != null and is_equal_approx(stream.selection_weight, 0.3) and is_equal_approx(stream_variant.selection_weight, 0.3) + and stream.maximum_placements == 2 + and stream_variant.maximum_placements == 1 + and "pond" in stream.tags + and "river" not in stream.tags and stream_variant.tags == stream.tags and stream_variant.allowed_neighbor_tags == stream.allowed_neighbor_tags @@ -331,10 +424,98 @@ func _validate_catalog() -> void: stream.water_surface_offset.y, ), ( - "Both stream visuals must share one combined selection weight " + "Both pond-stream visuals must share one combined selection weight " + "and mirrored water footprints." ), ) + _check( + ( + not lake_edge.participates_in_base_solver + and not lake_corner.participates_in_base_solver + and not lake_fill.participates_in_base_solver + and not lake_diagonal.participates_in_base_solver + and not lake_narrow.participates_in_base_solver + and not lake_regular_to_narrow.participates_in_base_solver + and not lake_narrow_to_regular.participates_in_base_solver + and lake_edge.water_inlet_edges == lake_edge.water_outlet_edges + and lake_edge.water_inlet_edges == 7 + and lake_corner.water_inlet_edges + == lake_corner.water_outlet_edges + and lake_corner.water_inlet_edges == 6 + and lake_fill.water_inlet_edges + == lake_fill.water_outlet_edges + and lake_fill.water_inlet_edges == 15 + and lake_fill.water_surface_size == Vector2(10.0, 10.0) + and lake_diagonal.water_inlet_edges + == lake_diagonal.water_outlet_edges + and lake_diagonal.water_inlet_edges == 6 + and lake_diagonal.maximum_placements == 4 + and lake_diagonal.water_surface_polygon.size() == 6 + and lake_narrow.water_inlet_edges == 14 + and lake_narrow.water_outlet_edges == 14 + and lake_narrow.water_surface_polygon.size() == 4 + and lake_regular_to_narrow.water_inlet_edges == 14 + and lake_regular_to_narrow.water_outlet_edges == 14 + and lake_regular_to_narrow.water_surface_polygon.size() == 5 + and lake_narrow_to_regular.water_inlet_edges == 14 + and lake_narrow_to_regular.water_outlet_edges == 14 + and lake_narrow_to_regular.water_surface_polygon.size() == 5 + and "lake" in lake_edge.tags + and "lake" in lake_corner.tags + and "lake" in lake_fill.tags + and "lake" in lake_diagonal.tags + and "lake" in lake_narrow.tags + and "lake" in lake_regular_to_narrow.tags + and "lake" in lake_narrow_to_regular.tags + ), + "The deterministic lake ring and bottom must remain connected freshwater chunks.", + ) + _check( + ( + not river_edge.participates_in_base_solver + and not river_edge_variant.participates_in_base_solver + and not river_source_right.participates_in_base_solver + and not river_source_left.participates_in_base_solver + and not river_outlet_right.participates_in_base_solver + and not river_outlet_left.participates_in_base_solver + and not river_cap.participates_in_base_solver + and river_edge.water_inlet_edges == 14 + and river_edge.water_outlet_edges == 14 + and river_edge_variant.water_inlet_edges == 14 + and river_edge_variant.water_outlet_edges == 14 + and river_source_right.water_inlet_edges == 6 + and river_source_right.water_outlet_edges == 6 + and river_source_left.water_inlet_edges == 3 + and river_source_left.water_outlet_edges == 3 + and river_outlet_right.water_inlet_edges == 14 + and river_outlet_right.water_outlet_edges == 14 + and river_outlet_left.water_inlet_edges == 11 + and river_outlet_left.water_outlet_edges == 11 + and river_cap.water_inlet_edges == 6 + and river_cap.water_outlet_edges == 6 + and river_edge.water_surface_polygon.size() == 13 + and river_edge_variant.water_surface_polygon.size() == 13 + and river_source_right.water_surface_polygon.size() == 13 + and river_source_left.water_surface_polygon.size() == 13 + and river_outlet_right.water_surface_polygon.size() == 19 + and river_outlet_left.water_surface_polygon.size() == 19 + and river_outlet_right.ocean_facing_edges == 2 + and river_outlet_left.ocean_facing_edges == 2 + and river_cap.water_surface_polygon.size() == 6 + and river_cap.primary_mesh_name == &"chunk_0020" + and "river" in river_edge.tags + and "river" in river_edge_variant.tags + and "river_source_right" in river_source_right.tags + and "river_source_left" in river_source_left.tags + and "river_outlet_right" in river_outlet_right.tags + and "river_outlet_left" in river_outlet_left.tags + and "river" in river_cap.tags + ), + ( + "The river banks, cliff source, ocean outlet, and legacy caps " + + "must remain connected freshwater chunks." + ), + ) for cliff_definition: TerrainChunkDefinition in [ cliff_top, cliff_corner, @@ -348,6 +529,19 @@ func _validate_catalog() -> void: and cliff_definition.base_layer_mesh_name == &"chunk_0000", "Every second-tier piece must overlay level-one grass inland.", ) + for cliff_definition: TerrainChunkDefinition in [ + cliff_third_tier_top, + cliff_third_tier_corner, + cliff_third_tier_edge, + ]: + _check( + cliff_definition.overlay_only + and cliff_definition.must_be_interior + and cliff_definition.base_layer_scene != null + and cliff_definition.base_layer_mesh_name == &"chunk_0000" + and "elevation_3" in cliff_definition.tags, + "Every third-tier piece must overlay level-one grass inland.", + ) for coastal_cliff: TerrainChunkDefinition in [ cliff_sea_edge, cliff_sea_corner, @@ -449,6 +643,13 @@ func _validate_catalog() -> void: and cliff_ramp.maximum_placements > 0, "The second-tier ramp must remain available without being required.", ) + _check( + cliff_third_tier_top.minimum_required_neighbors == 4 + and "elevation_3" in cliff_third_tier_top.required_neighbor_tags + and "ramp" not in cliff_third_tier_edge.tags + and "ramp" not in cliff_third_tier_corner.tags, + "The third-tier set must form a closed, intentionally unramped plateau.", + ) func _validate_profiles() -> void: @@ -1099,6 +1300,242 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: ), "No rotated diagonal edge may accept the opposite surface.", ) + var lake_diagonal_northwest := _find_variant(generator, &"chunk_0020", 0) + var lake_diagonal_northeast := _find_variant(generator, &"chunk_0020", 3) + var lake_narrow_top := _find_variant(generator, &"chunk_0021", 0) + var lake_narrow_left := _find_variant(generator, &"chunk_0021", 1) + var lake_regular_to_narrow := _find_variant(generator, &"chunk_0022", 0) + var lake_narrow_to_regular := _find_variant(generator, &"chunk_0023", 0) + var lake_regular_top := _find_variant(generator, &"chunk_0017", 3) + _check( + ( + lake_diagonal_northwest != null + and lake_diagonal_northeast != null + and lake_narrow_top != null + and lake_narrow_left != null + and lake_regular_to_narrow != null + and lake_narrow_to_regular != null + and lake_regular_top != null + ), + "Every authored diagonal lake perimeter variant must be available.", + ) + if ( + lake_diagonal_northwest != null + and lake_diagonal_northeast != null + and lake_narrow_top != null + and lake_narrow_left != null + and lake_regular_to_narrow != null + and lake_narrow_to_regular != null + and lake_regular_top != null + ): + _check( + generator._edges_are_compatible( + lake_diagonal_northwest, + TerrainChunkTopology.Edge.EAST, + lake_narrow_top, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + lake_diagonal_northwest, + TerrainChunkTopology.Edge.SOUTH, + lake_narrow_left, + TerrainChunkTopology.Edge.NORTH, + ), + "Both diagonal banks must join the exact narrow lake profile.", + ) + _check( + generator._edges_are_compatible( + lake_narrow_top, + TerrainChunkTopology.Edge.EAST, + lake_narrow_to_regular, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + lake_narrow_to_regular, + TerrainChunkTopology.Edge.EAST, + lake_regular_top, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + lake_regular_top, + TerrainChunkTopology.Edge.EAST, + lake_regular_to_narrow, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + lake_regular_to_narrow, + TerrainChunkTopology.Edge.EAST, + lake_narrow_top, + TerrainChunkTopology.Edge.WEST, + ), + "The narrow lake run must transition exactly into the regular edge.", + ) + _check( + generator._edges_are_compatible( + lake_diagonal_northwest, + TerrainChunkTopology.Edge.EAST, + lake_narrow_to_regular, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + lake_narrow_to_regular, + TerrainChunkTopology.Edge.EAST, + lake_regular_to_narrow, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + lake_regular_to_narrow, + TerrainChunkTopology.Edge.EAST, + lake_diagonal_northeast, + TerrainChunkTopology.Edge.WEST, + ), + "The five-chunk lake side must remain valid without a narrow run.", + ) + var river_cap_northwest := _find_variant(generator, &"chunk_9995", 0) + var river_cap_northeast := _find_variant(generator, &"chunk_9995", 3) + var river_cap_southwest := _find_variant(generator, &"chunk_9995", 1) + var river_cap_southeast := _find_variant(generator, &"chunk_9995", 2) + var river_edge_top := _find_variant(generator, &"chunk_0024", 0) + var river_edge_variant_top := _find_variant(generator, &"chunk_0025", 0) + var river_edge_variant_bottom := _find_variant(generator, &"chunk_0025", 2) + var river_source_right := _find_variant(generator, &"chunk_0029", 0) + var river_source_left := _find_variant(generator, &"chunk_0030", 0) + var river_outlet_right := _find_variant(generator, &"chunk_0031", 0) + var river_outlet_left := _find_variant(generator, &"chunk_0032", 0) + var beach_east := _find_variant(generator, &"chunk_0002", 0) + _check( + ( + river_cap_northwest != null + and river_cap_northeast != null + and river_cap_southwest != null + and river_cap_southeast != null + and river_edge_top != null + and river_edge_variant_top != null + and river_edge_variant_bottom != null + and river_source_right != null + and river_source_left != null + and river_outlet_right != null + and river_outlet_left != null + and beach_east != null + ), + ( + "Every authored river-bank, source, outlet, and legacy-cap variant " + + "must be available." + ), + ) + if ( + river_cap_northwest != null + and river_cap_northeast != null + and river_cap_southwest != null + and river_cap_southeast != null + and river_edge_top != null + and river_edge_variant_top != null + and river_edge_variant_bottom != null + and river_source_right != null + and river_source_left != null + and river_outlet_right != null + and river_outlet_left != null + and beach_east != null + ): + _check( + generator._edges_are_compatible( + river_source_right, + TerrainChunkTopology.Edge.SOUTH, + river_source_left, + TerrainChunkTopology.Edge.NORTH, + ) + and generator._edges_are_compatible( + river_source_right, + TerrainChunkTopology.Edge.EAST, + river_edge_top, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + river_source_left, + TerrainChunkTopology.Edge.EAST, + river_edge_variant_bottom, + TerrainChunkTopology.Edge.WEST, + ), + "The paired cliff source must open directly into both river banks.", + ) + _check( + generator._edges_are_compatible( + river_edge_top, + TerrainChunkTopology.Edge.EAST, + river_outlet_right, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + river_edge_variant_bottom, + TerrainChunkTopology.Edge.EAST, + river_outlet_left, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + river_outlet_right, + TerrainChunkTopology.Edge.SOUTH, + river_outlet_left, + TerrainChunkTopology.Edge.NORTH, + ), + "Both river banks must terminate in the paired beach outlet.", + ) + _check( + generator._edges_are_compatible( + beach_east, + TerrainChunkTopology.Edge.SOUTH, + river_outlet_right, + TerrainChunkTopology.Edge.NORTH, + ) + and generator._edges_are_compatible( + river_outlet_left, + TerrainChunkTopology.Edge.SOUTH, + beach_east, + TerrainChunkTopology.Edge.NORTH, + ), + "The ocean outlet must remain straddled by ordinary beach tiles.", + ) + _check( + generator._edges_are_compatible( + river_cap_northwest, + TerrainChunkTopology.Edge.EAST, + river_edge_top, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + river_edge_top, + TerrainChunkTopology.Edge.EAST, + river_edge_variant_top, + TerrainChunkTopology.Edge.WEST, + ) + and generator._edges_are_compatible( + river_edge_variant_top, + TerrainChunkTopology.Edge.EAST, + river_cap_northeast, + TerrainChunkTopology.Edge.WEST, + ), + "Both river-bank visuals must chain exactly between composite caps.", + ) + _check( + generator._edges_are_compatible( + river_cap_northwest, + TerrainChunkTopology.Edge.SOUTH, + river_cap_southwest, + TerrainChunkTopology.Edge.NORTH, + ) + and generator._edges_are_compatible( + river_edge_top, + TerrainChunkTopology.Edge.SOUTH, + river_edge_variant_bottom, + TerrainChunkTopology.Edge.NORTH, + ) + and generator._edges_are_compatible( + river_cap_northeast, + TerrainChunkTopology.Edge.SOUTH, + river_cap_southeast, + TerrainChunkTopology.Edge.NORTH, + ), + "Opposing river banks and end caps must close the water channel.", + ) for stable_id: StringName in [ &"chunk_0005", &"chunk_0006", @@ -1106,10 +1543,10 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: &"chunk_0008", &"chunk_0014", &"chunk_0015", - &"chunk_0017", - &"chunk_0018", - &"chunk_0019", - &"chunk_0020", + &"chunk_9999", + &"chunk_9998", + &"chunk_9997", + &"chunk_9996", ]: for quarter_turns: int in 4: var coast_variant := _find_variant( @@ -1161,8 +1598,8 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: func _validate_generation_summary(generator: TerrainChunkGenerator) -> void: var summary := generator._build_summary() _check( - int(summary.get("variant_count", 0)) == 85, - "The current catalog must expose all 85 authored rotations.", + int(summary.get("variant_count", 0)) == 150, + "The current catalog must expose all 150 authored rotations.", ) _check( int(summary.get("solver_variant_count", 0)) diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 6a4dcd4..c226610 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -356,7 +356,14 @@ func _configure_fresh_water(records: Array[Dictionary]) -> void: var surface_size: Vector2 = record.get( "water_surface_size", Vector2.ZERO ) - if surface_size.x <= 0.0 or surface_size.y <= 0.0: + var surface_polygon: PackedVector2Array = record.get( + "water_surface_polygon", + PackedVector2Array(), + ) + if ( + (surface_size.x <= 0.0 or surface_size.y <= 0.0) + and surface_polygon.size() < 3 + ): continue var body := WATER_BODY_SCENE.instantiate() as WaterBodyAuthoring if body == null: @@ -379,7 +386,10 @@ func _configure_fresh_water(records: Array[Dictionary]) -> void: + Vector3.UP * WATER_HEIGHT ) body.rotation.y = angle - body.surface_size = surface_size + if surface_polygon.size() >= 3: + body.surface_polygon = surface_polygon + else: + body.surface_size = surface_size body.visual_surface_enabled = false body.water_material = _fresh_water_material() body.water_type = WaterType.Type.FRESH_WATER @@ -394,6 +404,8 @@ func _fresh_water_location_tags(tags: PackedStringArray) -> Array[StringName]: result.append(&"pond") if "river" in tags: result.append(&"river") + if "lake" in tags: + result.append(&"lake") return result diff --git a/world/generation/generated_world_region.tscn b/world/generation/generated_world_region.tscn index 3b81a39..47ee6bc 100644 --- a/world/generation/generated_world_region.tscn +++ b/world/generation/generated_world_region.tscn @@ -31,16 +31,23 @@ gatherable_anchor_root = NodePath("GatherableAnchors") unique_name_in_owner = true script = ExtResource("2_generator") catalog = ExtResource("3_catalog") -grid_size = Vector2i(15, 13) +grid_size = Vector2i(20, 20) generation_seed = 13001 generate_on_ready = false build_collision = true force_center_chunk_id = &"chunk_spawn" required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004") grass_sand_smoothing_enabled = true +lake_feature_enabled = true +lake_minimum_size = 7 +lake_maximum_size = 8 +river_feature_enabled = true +river_minimum_length = 7 +river_maximum_length = 8 elevated_cliff_feature_enabled = true +elevated_cliff_coastal_feature_required = true required_chunk_weight_multiplier = 64.0 -maximum_backtracks = 100000 +maximum_backtracks = 1000 [node name="WaterBodies" type="Node3D" parent="."] diff --git a/world/generation/terrain_chunk_generator.gd b/world/generation/terrain_chunk_generator.gd index 2e6b61c..cc49494 100644 --- a/world/generation/terrain_chunk_generator.gd +++ b/world/generation/terrain_chunk_generator.gd @@ -14,6 +14,8 @@ const SECONDARY_ELEVATED_FEATURE_RADIUS := 1 const SECONDARY_ELEVATED_FEATURE_CLEARANCE_RADIUS := ( SECONDARY_ELEVATED_FEATURE_RADIUS + 1 ) +const LAKE_FEATURE_CLEARANCE := 1 +const RIVER_FEATURE_CLEARANCE := 1 # Keep packed domain bits below the signed 64-bit sign bit. Catalogs larger # than this remain correct through the unpacked solver path. const MAX_PACKED_SOLVER_VARIANTS := 62 @@ -33,6 +35,31 @@ const MAX_PACKED_SOLVER_VARIANTS := 62 @export var smoothing_diagonal_chunk_id: StringName = &"chunk_0013" @export_range(1, 64, 1) var maximum_smoothing_placements := 16 @export_group("") +@export_group("Freshwater Lake Feature") +@export var lake_feature_enabled := false +@export var lake_edge_chunk_id: StringName = &"chunk_0017" +@export var lake_corner_chunk_id: StringName = &"chunk_0018" +@export var lake_fill_chunk_id: StringName = &"chunk_0019" +@export var lake_diagonal_chunk_id: StringName = &"chunk_0020" +@export var lake_narrow_edge_chunk_id: StringName = &"chunk_0021" +@export var lake_regular_to_narrow_chunk_id: StringName = &"chunk_0022" +@export var lake_narrow_to_regular_chunk_id: StringName = &"chunk_0023" +@export_range(3, 8, 1) var lake_minimum_length := 3 +@export_range(3, 8, 1) var lake_maximum_length := 5 +@export_range(5, 8, 1) var lake_minimum_size := 5 +@export_range(5, 8, 1) var lake_maximum_size := 6 +@export_group("") +@export_group("Freshwater River Feature") +@export var river_feature_enabled := false +@export var river_edge_chunk_id: StringName = &"chunk_0024" +@export var river_edge_variant_chunk_id: StringName = &"chunk_0025" +@export var river_source_right_chunk_id: StringName = &"chunk_0029" +@export var river_source_left_chunk_id: StringName = &"chunk_0030" +@export var river_outlet_right_chunk_id: StringName = &"chunk_0031" +@export var river_outlet_left_chunk_id: StringName = &"chunk_0032" +@export_range(5, 8, 1) var river_minimum_length := 5 +@export_range(5, 8, 1) var river_maximum_length := 5 +@export_group("") @export_group("Elevated Inland Feature") @export var elevated_cliff_feature_enabled := false @export var elevated_cliff_base_chunk_id: StringName = &"chunk_0000" @@ -40,19 +67,31 @@ const MAX_PACKED_SOLVER_VARIANTS := 62 @export var elevated_cliff_corner_chunk_id: StringName = &"chunk_0010" @export var elevated_cliff_edge_chunk_id: StringName = &"chunk_0011" @export var elevated_cliff_ramp_chunk_id: StringName = &"chunk_0012" +@export var elevated_cliff_third_tier_edge_chunk_id: StringName = &"chunk_0026" +@export var elevated_cliff_third_tier_corner_chunk_id: StringName = &"chunk_0027" +@export var elevated_cliff_third_tier_top_chunk_id: StringName = &"chunk_0028" @export var elevated_cliff_sea_edge_chunk_id: StringName = &"chunk_0014" @export var elevated_cliff_sea_corner_chunk_id: StringName = &"chunk_0015" -@export var elevated_cliff_sea_transition_right_chunk_id: StringName = &"chunk_0017" -@export var elevated_cliff_sea_transition_left_chunk_id: StringName = &"chunk_0018" +@export var elevated_cliff_sea_transition_right_chunk_id: StringName = &"chunk_9999" +@export var elevated_cliff_sea_transition_left_chunk_id: StringName = &"chunk_9998" @export var elevated_cliff_coast_base_chunk_id: StringName = &"chunk_0005" @export var elevated_cliff_beach_base_chunk_id: StringName = &"chunk_0002" -@export var elevated_cliff_beach_transition_right_chunk_id: StringName = &"chunk_0019" -@export var elevated_cliff_beach_transition_left_chunk_id: StringName = &"chunk_0020" +@export var elevated_cliff_beach_transition_right_chunk_id: StringName = &"chunk_9997" +@export var elevated_cliff_beach_transition_left_chunk_id: StringName = &"chunk_9996" +## Generated worlds can require the secondary coastal assembly so every +## island exposes at least one authored cliff face directly to the ocean. +@export var elevated_cliff_coastal_feature_required := false @export_range(0.0, 1.0, 0.05) var elevated_cliff_ramp_chance := 1.0 -## A smaller third tier reuses the existing corner geometry at the authored -## two-meter level interval. It is nested into the lower cliff assembly rather -## than occupying or replacing additional terrain-grid cells. +## Third-tier cliffs rise four meters in a single authored piece and have no +## ramp. They remain less common than the accessible second-tier feature. +@export_range(0.0, 1.0, 0.05) var elevated_cliff_third_tier_base_chance := 0.3 +## A smaller upper tier is nested into the main cliff assembly rather than +## occupying or replacing additional terrain-grid cells. @export_range(0.0, 1.0, 0.05) var elevated_cliff_third_level_chance := 0.7 +## A second-tier base may carry the taller authored cliff set above it. +@export_range(0.0, 1.0, 0.05) var elevated_cliff_third_tier_stack_chance := 0.2 +## Stacking the taller set on another taller base is intentionally rare. +@export_range(0.0, 1.0, 0.01) var elevated_cliff_double_third_tier_chance := 0.05 @export_range(0.1, 10.0, 0.1) var elevated_cliff_level_height := 2.0 @export_group("") @export_range(1.0, 100.0, 1.0) var required_chunk_weight_multiplier := 64.0 @@ -89,6 +128,17 @@ var _backtrack_count := 0 var _elevated_feature_center := Vector2i(-1, -1) var _secondary_elevated_feature_center := Vector2i(-1, -1) var _elevated_feature_reserved_grass_indices: Dictionary[int, bool] = {} +var _lake_feature_origin := Vector2i(-1, -1) +var _lake_feature_footprint_size := Vector2i.ZERO +var _lake_feature_uses_fill := false +var _lake_feature_uses_diagonal_perimeter := false +var _lake_feature_reserved_grass_indices: Dictionary[int, bool] = {} +var _river_feature_origin := Vector2i(-1, -1) +var _river_feature_footprint_size := Vector2i.ZERO +var _river_feature_flow_direction := Vector2i.ZERO +var _river_feature_reserved_grass_indices: Dictionary[int, bool] = {} +var _river_feature_candidate_order: Array[Dictionary] = [] +var _river_feature_candidate_index := -1 var _stacked_elevated_placements: Array[Dictionary] = [] @@ -108,7 +158,7 @@ func generate() -> bool: if not _prepare_elevated_feature_region(): _assign_placements(previous_placements) return false - if not _solve_cell(0): + if not _solve_prepared_base_layout(): _assign_placements(previous_placements) push_error( "Terrain generation could not solve a %dx%d grid with seed %d." @@ -119,9 +169,15 @@ func generate() -> bool: if not _apply_grass_sand_smoothing(): _assign_placements(previous_placements) return false + if not _apply_lake_feature(): + _assign_placements(previous_placements) + return false if not _apply_elevated_feature(): _assign_placements(previous_placements) return false + if not _apply_river_feature(): + _assign_placements(previous_placements) + return false if not _configure_stacked_elevated_feature(): _assign_placements(previous_placements) return false @@ -135,6 +191,19 @@ func generate() -> bool: return true +func _solve_prepared_base_layout() -> bool: + if _solve_cell(0): + return true + while _select_next_river_feature_region(): + _random.seed = generation_seed + _backtrack_count = 0 + _reset_placements(grid_size.x * grid_size.y) + _build_grid_solver_caches() + if _solve_cell(0): + return true + return false + + func generate_from_placement_keys(keys: PackedStringArray) -> bool: if not _prepare_catalog(): return false @@ -1191,7 +1260,7 @@ func _candidate_can_occupy_cell( ) -> bool: var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) if ( - _elevated_feature_reserved_grass_indices.has(index) + _cell_requires_reserved_grass(index) and candidate.definition.stable_id != elevated_cliff_base_chunk_id and not ( coordinate == Vector2i(grid_size.x / 2, grid_size.y / 2) @@ -1222,6 +1291,10 @@ func _prepare_elevated_feature_region() -> bool: _elevated_feature_reserved_grass_indices.clear() _stacked_elevated_placements.clear() if not elevated_cliff_feature_enabled: + if not _prepare_lake_feature_region(): + return false + if not _prepare_river_feature_region(): + return false _build_grid_solver_caches() return true for stable_id: StringName in [ @@ -1230,6 +1303,9 @@ func _prepare_elevated_feature_region() -> bool: elevated_cliff_corner_chunk_id, elevated_cliff_edge_chunk_id, elevated_cliff_ramp_chunk_id, + elevated_cliff_third_tier_edge_chunk_id, + elevated_cliff_third_tier_corner_chunk_id, + elevated_cliff_third_tier_top_chunk_id, elevated_cliff_sea_edge_chunk_id, elevated_cliff_sea_corner_chunk_id, elevated_cliff_sea_transition_right_chunk_id, @@ -1271,16 +1347,55 @@ func _prepare_elevated_feature_region() -> bool: return false var feature_random := RandomNumberGenerator.new() feature_random.seed = generation_seed ^ 0x2E1E7A7ED - _elevated_feature_center = candidates[ - feature_random.randi_range(0, candidates.size() - 1) - ] - # Solve the authored 5x5 plateau into a one-cell ring of ordinary level-one - # flat grass. The larger top provides a real landing beyond the ramp and - # leaves usable second-tier terrain around the optional third tier. - _reserve_elevated_feature_base( - _elevated_feature_center, - ELEVATED_FEATURE_CLEARANCE_RADIUS, - ) + var candidate_start := feature_random.randi_range(0, candidates.size() - 1) + var compatible_features_found := false + for candidate_offset: int in candidates.size(): + _elevated_feature_center = candidates[ + (candidate_start + candidate_offset) % candidates.size() + ] + _elevated_feature_reserved_grass_indices.clear() + # Solve the authored 5x5 plateau into a one-cell ring of ordinary + # level-one flat grass. The lake's own clearance may share that grass, + # but its actual basin may never replace the plateau or landing ring. + _reserve_elevated_feature_base( + _elevated_feature_center, + ELEVATED_FEATURE_CLEARANCE_RADIUS, + ) + if not ( + _prepare_lake_feature_region(false) + and _prepare_river_feature_region(false) + ): + continue + var secondary_candidates := _compatible_coastal_elevated_feature_centers() + if ( + elevated_cliff_coastal_feature_required + and secondary_candidates.is_empty() + ): + continue + if not secondary_candidates.is_empty(): + _secondary_elevated_feature_center = secondary_candidates[ + feature_random.randi_range(0, secondary_candidates.size() - 1) + ] + compatible_features_found = true + break + if not compatible_features_found: + push_error( + ( + "Terrain grid has no compatible inland cliff, lake, river, " + + "and required coastal cliff locations." + ) + ) + return false + if _secondary_elevated_feature_center.x >= 0: + _reserve_elevated_feature_base( + _secondary_elevated_feature_center, + SECONDARY_ELEVATED_FEATURE_CLEARANCE_RADIUS, + ) + _build_grid_solver_caches() + return true + + +func _compatible_coastal_elevated_feature_centers() -> Array[Vector2i]: var secondary_candidates := _coastal_elevated_feature_centers() var combined_clearance := ( ELEVATED_FEATURE_CLEARANCE_RADIUS @@ -1290,20 +1405,21 @@ func _prepare_elevated_feature_region() -> bool: func(candidate: Vector2i) -> bool: var separation := candidate - _elevated_feature_center return ( - absi(separation.x) > combined_clearance - or absi(separation.y) > combined_clearance + ( + absi(separation.x) > combined_clearance + or absi(separation.y) > combined_clearance + ) + and not _elevated_feature_overlaps_lake( + candidate, + SECONDARY_ELEVATED_FEATURE_CLEARANCE_RADIUS, + ) + and not _elevated_feature_overlaps_river( + candidate, + SECONDARY_ELEVATED_FEATURE_CLEARANCE_RADIUS, + ) ) ) - if not secondary_candidates.is_empty(): - _secondary_elevated_feature_center = secondary_candidates[ - feature_random.randi_range(0, secondary_candidates.size() - 1) - ] - _reserve_elevated_feature_base( - _secondary_elevated_feature_center, - SECONDARY_ELEVATED_FEATURE_CLEARANCE_RADIUS, - ) - _build_grid_solver_caches() - return true + return secondary_candidates func _coastal_elevated_feature_centers() -> Array[Vector2i]: @@ -1315,6 +1431,56 @@ func _coastal_elevated_feature_centers() -> Array[Vector2i]: ] +func _elevated_feature_overlaps_lake( + center: Vector2i, + clearance_radius: int, +) -> bool: + if _lake_feature_origin.x < 0: + return false + var lake_end := _lake_feature_origin + _lake_feature_footprint_size + for row: int in range( + center.y - clearance_radius, + center.y + clearance_radius + 1, + ): + for column: int in range( + center.x - clearance_radius, + center.x + clearance_radius + 1, + ): + if ( + column >= _lake_feature_origin.x + and column < lake_end.x + and row >= _lake_feature_origin.y + and row < lake_end.y + ): + return true + return false + + +func _elevated_feature_overlaps_river( + center: Vector2i, + clearance_radius: int, +) -> bool: + if _river_feature_origin.x < 0: + return false + var river_end := _river_feature_origin + _river_feature_footprint_size + for row: int in range( + center.y - clearance_radius, + center.y + clearance_radius + 1, + ): + for column: int in range( + center.x - clearance_radius, + center.x + clearance_radius + 1, + ): + if ( + column >= _river_feature_origin.x + and column < river_end.x + and row >= _river_feature_origin.y + and row < river_end.y + ): + return true + return false + + func _reserve_elevated_feature_base( center: Vector2i, clearance_radius: int, @@ -1335,6 +1501,481 @@ func _reserve_elevated_feature_base( ] = true +func _prepare_lake_feature_region(report_no_room_error := true) -> bool: + _lake_feature_origin = Vector2i(-1, -1) + _lake_feature_footprint_size = Vector2i.ZERO + _lake_feature_uses_fill = false + _lake_feature_uses_diagonal_perimeter = false + _lake_feature_reserved_grass_indices.clear() + if not lake_feature_enabled: + return true + var edge_definition := catalog.definition_for_id(lake_edge_chunk_id) + var corner_definition := catalog.definition_for_id(lake_corner_chunk_id) + var fill_definition := catalog.definition_for_id(lake_fill_chunk_id) + var diagonal_definition := catalog.definition_for_id(lake_diagonal_chunk_id) + var narrow_definition := catalog.definition_for_id(lake_narrow_edge_chunk_id) + var regular_to_narrow_definition := catalog.definition_for_id( + lake_regular_to_narrow_chunk_id + ) + var narrow_to_regular_definition := catalog.definition_for_id( + lake_narrow_to_regular_chunk_id + ) + if edge_definition == null or corner_definition == null: + push_error("Lake feature references missing edge or corner chunks.") + return false + if ( + edge_definition.participates_in_base_solver + or corner_definition.participates_in_base_solver + or ( + fill_definition != null + and fill_definition.participates_in_base_solver + ) + ): + push_error("Lake feature chunks must be excluded from the base solver.") + return false + if fill_definition != null: + var diagonal_perimeter_definitions: Array[TerrainChunkDefinition] = [ + diagonal_definition, + narrow_definition, + regular_to_narrow_definition, + narrow_to_regular_definition, + ] + for definition: TerrainChunkDefinition in diagonal_perimeter_definitions: + if definition == null: + push_error( + "Lake feature references a missing diagonal perimeter chunk." + ) + return false + if definition.participates_in_base_solver: + push_error( + "Lake diagonal perimeter chunks must be excluded from the base solver." + ) + return false + _lake_feature_uses_diagonal_perimeter = true + var lake_random := RandomNumberGenerator.new() + lake_random.seed = generation_seed ^ 0x1A4E5EED + var candidates: Array[Dictionary] = [] + if fill_definition != null: + var minimum_size := maxi(5, lake_minimum_size) + var maximum_size := maxi(minimum_size, lake_maximum_size) + var desired_size := lake_random.randi_range( + minimum_size, + maximum_size, + ) + for size: int in range(desired_size, minimum_size - 1, -1): + candidates = _lake_feature_candidates(Vector2i(size, size)) + if not candidates.is_empty(): + break + _lake_feature_uses_fill = true + else: + # The two-bank fallback keeps worlds valid while the authored interior + # lake-bed piece is unavailable. Cataloging lake_fill_chunk_id upgrades + # this automatically to the large square footprint above. + var minimum_length := maxi(3, lake_minimum_length) + var maximum_length := maxi(minimum_length, lake_maximum_length) + var desired_length := lake_random.randi_range( + minimum_length, + maximum_length, + ) + for length: int in range( + desired_length, + minimum_length - 1, + -1, + ): + candidates = _lake_feature_candidates( + Vector2i(2, length), + ) + candidates.append_array( + _lake_feature_candidates(Vector2i(length, 2)), + ) + if not candidates.is_empty(): + break + if candidates.is_empty(): + if report_no_room_error: + push_error( + "Terrain grid has no room for the configured lake feature." + ) + return false + var selected: Dictionary = candidates[ + lake_random.randi_range(0, candidates.size() - 1) + ] + _lake_feature_origin = selected.get("origin", Vector2i(-1, -1)) + _lake_feature_footprint_size = selected.get( + "size", + Vector2i.ZERO, + ) + for row: int in range( + _lake_feature_origin.y - LAKE_FEATURE_CLEARANCE, + _lake_feature_origin.y + + _lake_feature_footprint_size.y + + LAKE_FEATURE_CLEARANCE, + ): + for column: int in range( + _lake_feature_origin.x - LAKE_FEATURE_CLEARANCE, + _lake_feature_origin.x + + _lake_feature_footprint_size.x + + LAKE_FEATURE_CLEARANCE, + ): + _lake_feature_reserved_grass_indices[ + row * grid_size.x + column + ] = true + return true + + +func _lake_feature_candidates( + footprint_size: Vector2i, +) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if footprint_size.x < 2 or footprint_size.y < 2: + return result + var center := Vector2i(grid_size.x / 2, grid_size.y / 2) + for row: int in range( + LAKE_FEATURE_CLEARANCE + 1, + grid_size.y - footprint_size.y - LAKE_FEATURE_CLEARANCE, + ): + for column: int in range( + LAKE_FEATURE_CLEARANCE + 1, + grid_size.x - footprint_size.x - LAKE_FEATURE_CLEARANCE, + ): + var origin := Vector2i(column, row) + var blocked := false + var spawn_neighbor_overlap_count := 0 + for check_row: int in range( + origin.y, + origin.y + footprint_size.y, + ): + for check_column: int in range( + origin.x, + origin.x + footprint_size.x, + ): + var coordinate := Vector2i(check_column, check_row) + var index := check_row * grid_size.x + check_column + var center_distance := ( + absi(coordinate.x - center.x) + + absi(coordinate.y - center.y) + ) + if center_distance == 1: + spawn_neighbor_overlap_count += 1 + if ( + center_distance == 0 + or spawn_neighbor_overlap_count > 1 + or _elevated_feature_reserved_grass_indices.has(index) + ): + blocked = true + break + if blocked: + break + if not blocked: + result.append({ + "origin": origin, + "size": footprint_size, + }) + return result + + +func _prepare_river_feature_region(report_no_room_error := true) -> bool: + _river_feature_origin = Vector2i(-1, -1) + _river_feature_footprint_size = Vector2i.ZERO + _river_feature_flow_direction = Vector2i.ZERO + _river_feature_reserved_grass_indices.clear() + _river_feature_candidate_order.clear() + _river_feature_candidate_index = -1 + if not river_feature_enabled: + return true + for stable_id: StringName in [ + river_edge_chunk_id, + river_edge_variant_chunk_id, + river_source_right_chunk_id, + river_source_left_chunk_id, + river_outlet_right_chunk_id, + river_outlet_left_chunk_id, + ]: + var definition := catalog.definition_for_id(stable_id) + if definition == null: + push_error("River feature references missing chunk %s." % stable_id) + return false + if definition.participates_in_base_solver: + push_error("River feature chunks must be excluded from the base solver.") + return false + if not elevated_cliff_feature_enabled or _elevated_feature_center.x < 0: + if report_no_room_error: + push_error( + "Terrain river feature requires an elevated cliff source." + ) + return false + var river_random := RandomNumberGenerator.new() + river_random.seed = generation_seed ^ 0x71A3E22D + var minimum_length := maxi(5, river_minimum_length) + var maximum_length := maxi(minimum_length, river_maximum_length) + var desired_length := river_random.randi_range( + minimum_length, + maximum_length, + ) + var candidates: Array[Dictionary] = [] + for length: int in range(desired_length, minimum_length - 1, -1): + candidates = _river_feature_candidates(length) + if not candidates.is_empty(): + break + if candidates.is_empty(): + if report_no_room_error: + push_error("Terrain grid has no room for the configured river feature.") + return false + var candidate_start := river_random.randi_range(0, candidates.size() - 1) + for candidate_offset: int in candidates.size(): + _river_feature_candidate_order.append( + candidates[(candidate_start + candidate_offset) % candidates.size()] + ) + if not _select_river_feature_region(0): + push_error("Terrain river feature has no compatible reserved region.") + return false + return true + + +func _select_next_river_feature_region() -> bool: + if not river_feature_enabled: + return false + for candidate_index: int in range( + _river_feature_candidate_index + 1, + _river_feature_candidate_order.size(), + ): + if _select_river_feature_region(candidate_index): + return true + return false + + +func _select_river_feature_region(candidate_index: int) -> bool: + if ( + candidate_index < 0 + or candidate_index >= _river_feature_candidate_order.size() + ): + return false + var selected: Dictionary = _river_feature_candidate_order[candidate_index] + var origin: Vector2i = selected.get("origin", Vector2i(-1, -1)) + var footprint_size: Vector2i = selected.get("size", Vector2i.ZERO) + var flow_direction: Vector2i = selected.get( + "flow_direction", + Vector2i.ZERO, + ) + var source_coordinates := _river_source_coordinates( + origin, + footprint_size, + flow_direction, + ) + var source_lookup: Dictionary[Vector2i, bool] = {} + var source_exit_lookup: Dictionary[Vector2i, bool] = {} + for coordinate: Vector2i in source_coordinates: + source_lookup[coordinate] = true + source_exit_lookup[coordinate + flow_direction] = true + for row: int in range(origin.y, origin.y + footprint_size.y): + for column: int in range(origin.x, origin.x + footprint_size.x): + var coordinate := Vector2i(column, row) + var index := row * grid_size.x + column + if ( + ( + _elevated_feature_reserved_grass_indices.has(index) + and not source_lookup.has(coordinate) + and not source_exit_lookup.has(coordinate) + ) + or _lake_feature_reserved_grass_indices.has(index) + ): + return false + for coordinate: Vector2i in source_coordinates: + var index := coordinate.y * grid_size.x + coordinate.x + if not _elevated_feature_reserved_grass_indices.has(index): + return false + _river_feature_candidate_index = candidate_index + _river_feature_origin = origin + _river_feature_footprint_size = footprint_size + _river_feature_flow_direction = flow_direction + _river_feature_reserved_grass_indices.clear() + var river_end := _river_feature_origin + _river_feature_footprint_size + for row: int in range( + _river_feature_origin.y - RIVER_FEATURE_CLEARANCE, + river_end.y + RIVER_FEATURE_CLEARANCE, + ): + for column: int in range( + _river_feature_origin.x - RIVER_FEATURE_CLEARANCE, + river_end.x + RIVER_FEATURE_CLEARANCE, + ): + var coordinate := Vector2i(column, row) + if ( + not _coordinate_is_inside_grid(coordinate) + or _coordinate_is_on_boundary(coordinate) + ): + continue + _river_feature_reserved_grass_indices[ + row * grid_size.x + column + ] = true + return true + + +func _river_feature_candidates( + length: int, +) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if length < 5 or _elevated_feature_center.x < 0: + return result + var directions: Array[Vector2i] = [ + Vector2i.RIGHT, + Vector2i.DOWN, + Vector2i.LEFT, + Vector2i.UP, + ] + for flow_direction: Vector2i in directions: + for cross_offset: int in [-1, 0]: + var footprint_size := ( + Vector2i(length, 2) + if flow_direction.x != 0 + else Vector2i(2, length) + ) + var origin := Vector2i.ZERO + match flow_direction: + Vector2i.RIGHT: + origin = Vector2i( + _elevated_feature_center.x + ELEVATED_FEATURE_RADIUS, + _elevated_feature_center.y + cross_offset, + ) + Vector2i.LEFT: + origin = Vector2i( + _elevated_feature_center.x + - ELEVATED_FEATURE_RADIUS + - length + + 1, + _elevated_feature_center.y + cross_offset, + ) + Vector2i.DOWN: + origin = Vector2i( + _elevated_feature_center.x + cross_offset, + _elevated_feature_center.y + ELEVATED_FEATURE_RADIUS, + ) + Vector2i.UP: + origin = Vector2i( + _elevated_feature_center.x + cross_offset, + _elevated_feature_center.y + - ELEVATED_FEATURE_RADIUS + - length + + 1, + ) + if _river_feature_candidate_is_valid( + origin, + footprint_size, + flow_direction, + ): + result.append({ + "origin": origin, + "size": footprint_size, + "flow_direction": flow_direction, + }) + return result + + +func _river_feature_candidate_is_valid( + origin: Vector2i, + footprint_size: Vector2i, + flow_direction: Vector2i, +) -> bool: + var river_end := origin + footprint_size + var boundary_clearance := RIVER_FEATURE_CLEARANCE + 1 + if flow_direction.x != 0: + if ( + origin.y < boundary_clearance + or river_end.y > grid_size.y - boundary_clearance + ): + return false + if ( + flow_direction == Vector2i.RIGHT + and river_end.x != grid_size.x + ) or ( + flow_direction == Vector2i.LEFT + and origin.x != 0 + ): + return false + else: + if ( + origin.x < boundary_clearance + or river_end.x > grid_size.x - boundary_clearance + ): + return false + if ( + flow_direction == Vector2i.DOWN + and river_end.y != grid_size.y + ) or ( + flow_direction == Vector2i.UP + and origin.y != 0 + ): + return false + var center := Vector2i(grid_size.x / 2, grid_size.y / 2) + var source_lookup: Dictionary[Vector2i, bool] = {} + var source_exit_lookup: Dictionary[Vector2i, bool] = {} + for coordinate: Vector2i in _river_source_coordinates( + origin, + footprint_size, + flow_direction, + ): + source_lookup[coordinate] = true + source_exit_lookup[coordinate + flow_direction] = true + if source_lookup.size() != 2: + return false + for row: int in range(origin.y, river_end.y): + for column: int in range(origin.x, river_end.x): + var coordinate := Vector2i(column, row) + var index := row * grid_size.x + column + var center_distance := ( + absi(coordinate.x - center.x) + + absi(coordinate.y - center.y) + ) + if ( + center_distance <= 1 + or _lake_feature_reserved_grass_indices.has(index) + or ( + _elevated_feature_reserved_grass_indices.has(index) + and not source_lookup.has(coordinate) + and not source_exit_lookup.has(coordinate) + ) + ): + return false + for coordinate: Vector2i in source_lookup: + var index := coordinate.y * grid_size.x + coordinate.x + if not _elevated_feature_reserved_grass_indices.has(index): + return false + return true + + +func _river_source_coordinates( + origin: Vector2i, + footprint_size: Vector2i, + flow_direction: Vector2i, +) -> Array[Vector2i]: + var northwest := origin + var northeast := Vector2i( + origin.x + footprint_size.x - 1, + origin.y, + ) + var southwest := Vector2i( + origin.x, + origin.y + footprint_size.y - 1, + ) + var southeast := origin + footprint_size - Vector2i.ONE + match flow_direction: + Vector2i.RIGHT: + return [northwest, southwest] + Vector2i.LEFT: + return [northeast, southeast] + Vector2i.DOWN: + return [northwest, northeast] + Vector2i.UP: + return [southwest, southeast] + return [] + + +func _cell_requires_reserved_grass(index: int) -> bool: + return ( + _elevated_feature_reserved_grass_indices.has(index) + or _lake_feature_reserved_grass_indices.has(index) + or _river_feature_reserved_grass_indices.has(index) + ) + + func _build_grid_solver_caches() -> void: _neighbor_indices.clear() _neighbor_indices.resize(_placements.size()) @@ -1364,7 +2005,7 @@ func _build_grid_solver_caches() -> void: for candidate_index: int in _solver_variants.size(): var candidate := _solver_variants[candidate_index] if ( - _elevated_feature_reserved_grass_indices.has(index) + _cell_requires_reserved_grass(index) and candidate.definition.stable_id != elevated_cliff_base_chunk_id and not ( @@ -1420,7 +2061,7 @@ func _apply_grass_sand_smoothing() -> bool: var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) if ( _coordinate_is_on_boundary(coordinate) - or _elevated_feature_reserved_grass_indices.has(index) + or _cell_requires_reserved_grass(index) or ( absi(coordinate.x - center.x) + absi(coordinate.y - center.y) @@ -1492,18 +2133,372 @@ func _replacement_preserves_required_chunk( return _definition_placement_count(original.definition) > 1 +func _apply_lake_feature() -> bool: + if not lake_feature_enabled: + return true + if ( + _lake_feature_origin.x < 0 + or _lake_feature_footprint_size.x < 2 + or _lake_feature_footprint_size.y < 2 + ): + push_error("Lake feature has no reserved terrain region.") + return false + for spec: Dictionary in _lake_feature_placement_specs(): + var definition := catalog.definition_for_id(spec["id"] as StringName) + var variant := _authored_variant(definition, int(spec["turns"])) + if variant == null: + push_error( + "Lake feature cannot resolve %s rotation %d." + % [spec["id"], spec["turns"]] + ) + return false + var coordinate := spec["coordinate"] as Vector2i + _set_placement( + coordinate.y * grid_size.x + coordinate.x, + variant, + ) + var validation_error := _resolved_layout_validation_error(_placements) + if not validation_error.is_empty(): + push_error("Lake feature is invalid: " + validation_error) + return false + return true + + +func _lake_feature_placement_specs() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var northwest := _lake_feature_origin + var northeast := Vector2i( + _lake_feature_origin.x + _lake_feature_footprint_size.x - 1, + _lake_feature_origin.y, + ) + var southwest := Vector2i( + _lake_feature_origin.x, + _lake_feature_origin.y + _lake_feature_footprint_size.y - 1, + ) + var southeast := ( + _lake_feature_origin + + _lake_feature_footprint_size + - Vector2i.ONE + ) + if _lake_feature_uses_diagonal_perimeter: + return _diagonal_lake_feature_placement_specs( + northwest, + northeast, + southwest, + southeast, + ) + result.append({"coordinate": northwest, "id": lake_corner_chunk_id, "turns": 0}) + result.append({"coordinate": northeast, "id": lake_corner_chunk_id, "turns": 3}) + result.append({"coordinate": southwest, "id": lake_corner_chunk_id, "turns": 1}) + result.append({"coordinate": southeast, "id": lake_corner_chunk_id, "turns": 2}) + for row: int in range(northwest.y + 1, southwest.y): + result.append({ + "coordinate": Vector2i(northwest.x, row), + "id": lake_edge_chunk_id, + "turns": 0, + }) + result.append({ + "coordinate": Vector2i(northeast.x, row), + "id": lake_edge_chunk_id, + "turns": 2, + }) + for column: int in range(northwest.x + 1, northeast.x): + result.append({ + "coordinate": Vector2i(column, northwest.y), + "id": lake_edge_chunk_id, + "turns": 3, + }) + result.append({ + "coordinate": Vector2i(column, southwest.y), + "id": lake_edge_chunk_id, + "turns": 1, + }) + if _lake_feature_uses_fill: + for row: int in range(northwest.y + 1, southwest.y): + for column: int in range(northwest.x + 1, northeast.x): + result.append({ + "coordinate": Vector2i(column, row), + "id": lake_fill_chunk_id, + "turns": 0, + }) + return result + + +func _diagonal_lake_feature_placement_specs( + northwest: Vector2i, + northeast: Vector2i, + southwest: Vector2i, + southeast: Vector2i, +) -> Array[Dictionary]: + var result: Array[Dictionary] = [ + {"coordinate": northwest, "id": lake_diagonal_chunk_id, "turns": 0}, + {"coordinate": northeast, "id": lake_diagonal_chunk_id, "turns": 3}, + {"coordinate": southwest, "id": lake_diagonal_chunk_id, "turns": 1}, + {"coordinate": southeast, "id": lake_diagonal_chunk_id, "turns": 2}, + ] + var top_side := _lake_top_side_specs( + _lake_feature_footprint_size.x - 2 + ) + for offset: int in top_side.size(): + var top_spec: Dictionary = top_side[offset] + var reverse_spec: Dictionary = top_side[top_side.size() - 1 - offset] + result.append({ + "coordinate": Vector2i(northwest.x + 1 + offset, northwest.y), + "id": top_spec["id"], + "turns": top_spec["turns"], + }) + result.append({ + "coordinate": Vector2i(southwest.x + 1 + offset, southwest.y), + "id": reverse_spec["id"], + "turns": posmod(int(reverse_spec["turns"]) + 2, 4), + }) + result.append({ + "coordinate": Vector2i(northwest.x, northwest.y + 1 + offset), + "id": reverse_spec["id"], + "turns": posmod(int(reverse_spec["turns"]) + 1, 4), + }) + result.append({ + "coordinate": Vector2i(northeast.x, northeast.y + 1 + offset), + "id": top_spec["id"], + "turns": posmod(int(top_spec["turns"]) + 3, 4), + }) + if _lake_feature_uses_fill: + for row: int in range(northwest.y + 1, southwest.y): + for column: int in range(northwest.x + 1, northeast.x): + result.append({ + "coordinate": Vector2i(column, row), + "id": lake_fill_chunk_id, + "turns": 0, + }) + return result + + +func _lake_top_side_specs(interior_count: int) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if interior_count < 3: + return result + var uses_narrow_run := interior_count >= 4 + if uses_narrow_run: + result.append({"id": lake_narrow_edge_chunk_id, "turns": 0}) + result.append({"id": lake_narrow_to_regular_chunk_id, "turns": 0}) + var regular_count := interior_count - (4 if uses_narrow_run else 2) + for _index: int in regular_count: + result.append({"id": lake_edge_chunk_id, "turns": 3}) + result.append({"id": lake_regular_to_narrow_chunk_id, "turns": 0}) + if uses_narrow_run: + result.append({"id": lake_narrow_edge_chunk_id, "turns": 0}) + return result + + +func _apply_river_feature() -> bool: + if not river_feature_enabled: + return true + if ( + _river_feature_origin.x < 0 + or mini( + _river_feature_footprint_size.x, + _river_feature_footprint_size.y, + ) != 2 + ): + push_error("River feature has no reserved terrain region.") + return false + for spec: Dictionary in _river_feature_placement_specs(): + var definition := catalog.definition_for_id(spec["id"] as StringName) + var variant := _authored_variant(definition, int(spec["turns"])) + if variant == null: + push_error( + "River feature cannot resolve %s rotation %d." + % [spec["id"], spec["turns"]] + ) + return false + var coordinate := spec["coordinate"] as Vector2i + _set_placement( + coordinate.y * grid_size.x + coordinate.x, + variant, + ) + var validation_error := _resolved_layout_validation_error(_placements) + if not validation_error.is_empty(): + push_error("River feature is invalid: " + validation_error) + return false + return true + + +func _river_feature_placement_specs() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var northwest := _river_feature_origin + var northeast := Vector2i( + _river_feature_origin.x + _river_feature_footprint_size.x - 1, + _river_feature_origin.y, + ) + var southwest := Vector2i( + _river_feature_origin.x, + _river_feature_origin.y + _river_feature_footprint_size.y - 1, + ) + var southeast := ( + _river_feature_origin + + _river_feature_footprint_size + - Vector2i.ONE + ) + match _river_feature_flow_direction: + Vector2i.RIGHT: + result.assign([ + { + "coordinate": northwest, + "id": river_source_right_chunk_id, + "turns": 0, + }, + { + "coordinate": southwest, + "id": river_source_left_chunk_id, + "turns": 0, + }, + { + "coordinate": northeast, + "id": river_outlet_right_chunk_id, + "turns": 0, + }, + { + "coordinate": southeast, + "id": river_outlet_left_chunk_id, + "turns": 0, + }, + ]) + Vector2i.LEFT: + result.assign([ + { + "coordinate": northwest, + "id": river_outlet_left_chunk_id, + "turns": 2, + }, + { + "coordinate": southwest, + "id": river_outlet_right_chunk_id, + "turns": 2, + }, + { + "coordinate": northeast, + "id": river_source_left_chunk_id, + "turns": 2, + }, + { + "coordinate": southeast, + "id": river_source_right_chunk_id, + "turns": 2, + }, + ]) + Vector2i.DOWN: + result.assign([ + { + "coordinate": northwest, + "id": river_source_left_chunk_id, + "turns": 3, + }, + { + "coordinate": northeast, + "id": river_source_right_chunk_id, + "turns": 3, + }, + { + "coordinate": southwest, + "id": river_outlet_left_chunk_id, + "turns": 3, + }, + { + "coordinate": southeast, + "id": river_outlet_right_chunk_id, + "turns": 3, + }, + ]) + Vector2i.UP: + result.assign([ + { + "coordinate": northwest, + "id": river_outlet_right_chunk_id, + "turns": 1, + }, + { + "coordinate": northeast, + "id": river_outlet_left_chunk_id, + "turns": 1, + }, + { + "coordinate": southwest, + "id": river_source_right_chunk_id, + "turns": 1, + }, + { + "coordinate": southeast, + "id": river_source_left_chunk_id, + "turns": 1, + }, + ]) + _: + push_error("River feature has no cardinal flow direction.") + return [] + var river_random := RandomNumberGenerator.new() + river_random.seed = generation_seed ^ 0x4B1D63A7 + var phase := river_random.randi_range(0, 1) + var edge_ids: Array[StringName] = [ + river_edge_chunk_id, + river_edge_variant_chunk_id, + ] + if _river_feature_footprint_size.y == 2: + for offset: int in range(1, _river_feature_footprint_size.x - 1): + var edge_index := posmod(offset - 1 + phase, edge_ids.size()) + result.append({ + "coordinate": Vector2i(northwest.x + offset, northwest.y), + "id": edge_ids[edge_index], + "turns": 0, + }) + result.append({ + "coordinate": Vector2i(southwest.x + offset, southwest.y), + "id": edge_ids[posmod(edge_index + 1, edge_ids.size())], + "turns": 2, + }) + else: + for offset: int in range(1, _river_feature_footprint_size.y - 1): + var edge_index := posmod(offset - 1 + phase, edge_ids.size()) + result.append({ + "coordinate": Vector2i(northwest.x, northwest.y + offset), + "id": edge_ids[edge_index], + "turns": 1, + }) + result.append({ + "coordinate": Vector2i(northeast.x, northeast.y + offset), + "id": edge_ids[posmod(edge_index + 1, edge_ids.size())], + "turns": 3, + }) + return result + + func _apply_elevated_feature() -> bool: if not elevated_cliff_feature_enabled: return true if _elevated_feature_center.x < 0 or _elevated_feature_center.y < 0: push_error("Elevated cliff feature has no reserved base region.") return false + if ( + elevated_cliff_coastal_feature_required + and _secondary_elevated_feature_center.x < 0 + ): + push_error("Elevated cliff feature is missing its required coastal assembly.") + return false var feature_random := RandomNumberGenerator.new() feature_random.seed = generation_seed ^ 0x51A7C11FF + var tier_random := RandomNumberGenerator.new() + tier_random.seed = generation_seed ^ 0x4C1FF713D + # The authored river source is a third-tier cliff pair. A generated river + # therefore promotes its inland source feature to that matching elevation; + # rivers may never begin from an unrelated flat cap. + var use_third_tier := ( + river_feature_enabled + or tier_random.randf() < elevated_cliff_third_tier_base_chance + ) var placements := _elevated_feature_placement_specs( _elevated_feature_center, ELEVATED_FEATURE_RADIUS, feature_random, + use_third_tier, ) var grass_coast_count := int( _placement_counts.get(elevated_cliff_coast_base_chunk_id, 0) @@ -1511,10 +2506,10 @@ func _apply_elevated_feature() -> bool: var beach_coast_count := int( _placement_counts.get(elevated_cliff_beach_base_chunk_id, 0) ) - if ( - _secondary_elevated_feature_center.x >= 0 - and (grass_coast_count > 0 or beach_coast_count > 0) - ): + if _secondary_elevated_feature_center.x >= 0: + if grass_coast_count <= 0 and beach_coast_count <= 0: + push_error("Coastal cliff feature has no compatible coastline base.") + return false placements.append_array( _coastal_elevated_feature_placement_specs( _secondary_elevated_feature_center, @@ -1547,7 +2542,23 @@ func _elevated_feature_placement_specs( center: Vector2i, radius: int, feature_random: RandomNumberGenerator, + use_third_tier: bool = false, ) -> Array[Dictionary]: + var top_chunk_id := ( + elevated_cliff_third_tier_top_chunk_id + if use_third_tier + else elevated_cliff_top_chunk_id + ) + var corner_chunk_id := ( + elevated_cliff_third_tier_corner_chunk_id + if use_third_tier + else elevated_cliff_corner_chunk_id + ) + var edge_chunk_id := ( + elevated_cliff_third_tier_edge_chunk_id + if use_third_tier + else elevated_cliff_edge_chunk_id + ) var edge_specs: Array[Dictionary] = [ { "edge": TerrainChunkTopology.Edge.NORTH, @@ -1571,7 +2582,10 @@ func _elevated_feature_placement_specs( }, ] var ramp_edge: int = -1 - if feature_random.randf() < elevated_cliff_ramp_chance: + if ( + not use_third_tier + and feature_random.randf() < elevated_cliff_ramp_chance + ): var usable_edges: Array[int] = [] for edge_index: int in edge_specs.size(): var edge_spec := edge_specs[edge_index] @@ -1600,7 +2614,7 @@ func _elevated_feature_placement_specs( if on_horizontal_edge and on_vertical_edge: placements.append({ "coordinate": center + offset, - "id": elevated_cliff_corner_chunk_id, + "id": corner_chunk_id, "turns": _elevated_corner_turns(offset), }) continue @@ -1612,14 +2626,14 @@ func _elevated_feature_placement_specs( elevated_cliff_ramp_chunk_id if ramp_edge >= 0 and offset == edge_specs[ramp_edge]["offset"] - else elevated_cliff_edge_chunk_id + else edge_chunk_id ), "turns": edge_specs[edge_index]["turns"], }) continue placements.append({ "coordinate": center + offset, - "id": elevated_cliff_top_chunk_id, + "id": top_chunk_id, "turns": 0, }) return placements @@ -1634,6 +2648,7 @@ func _coastal_elevated_feature_placement_specs( center, SECONDARY_ELEVATED_FEATURE_RADIUS, feature_random, + false, ) for spec: Dictionary in placements: var coordinate := spec["coordinate"] as Vector2i @@ -1729,7 +2744,7 @@ func _configure_stacked_elevated_feature() -> bool: or elevated_cliff_third_level_chance <= 0.0 ): return true - var top_coordinate := _elevated_feature_center + var top_coordinate := _primary_elevated_top_coordinate() if top_coordinate.x < 0: return true var top_index := top_coordinate.y * grid_size.x + top_coordinate.x @@ -1737,22 +2752,47 @@ func _configure_stacked_elevated_feature() -> bool: top_index < 0 or top_index >= _placements.size() or _placements[top_index] == null - or _placements[top_index].definition.stable_id - != elevated_cliff_top_chunk_id ): push_error("Stacked cliff feature lost its central supporting top.") return false + var supporting_top_id := _placements[top_index].definition.stable_id + if supporting_top_id not in [ + elevated_cliff_top_chunk_id, + elevated_cliff_third_tier_top_chunk_id, + ]: + push_error("Stacked cliff feature lost its central supporting top.") + return false var stack_random := RandomNumberGenerator.new() stack_random.seed = generation_seed ^ 0x7312DC11F if stack_random.randf() >= elevated_cliff_third_level_chance: return true + var supporting_third_tier := ( + supporting_top_id == elevated_cliff_third_tier_top_chunk_id + ) + var use_third_tier_stack := ( + stack_random.randf() < ( + elevated_cliff_double_third_tier_chance + if supporting_third_tier + else elevated_cliff_third_tier_stack_chance + ) + ) + var stacked_corner_chunk_id := ( + elevated_cliff_third_tier_corner_chunk_id + if use_third_tier_stack + else elevated_cliff_corner_chunk_id + ) + var vertical_offset := ( + elevated_cliff_level_height * 2.0 + if supporting_third_tier + else elevated_cliff_level_height + ) var corner_definition := catalog.definition_for_id( - elevated_cliff_corner_chunk_id + stacked_corner_chunk_id ) if corner_definition == null: push_error( "Stacked cliff feature references missing corner chunk %s." - % elevated_cliff_corner_chunk_id + % stacked_corner_chunk_id ) return false # Four nearly half-cell-offset corners form a closed 2x2 ring. A slight @@ -1772,17 +2812,61 @@ func _configure_stacked_elevated_feature() -> bool: if variant == null: push_error( "Stacked cliff feature cannot resolve %s rotation %d." - % [elevated_cliff_corner_chunk_id, spec["turns"]] + % [stacked_corner_chunk_id, spec["turns"]] ) return false _stacked_elevated_placements.append({ "support_coordinate": top_coordinate, "offset": spec["offset"], + "vertical_offset": vertical_offset, "variant": variant, }) return true +func _primary_elevated_top_coordinate() -> Vector2i: + if _coordinate_has_primary_elevated_top(_elevated_feature_center): + return _elevated_feature_center + for index: int in _placements.size(): + var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) + if not _coordinate_has_primary_elevated_top(coordinate): + continue + var top_id := _placements[index].definition.stable_id + var surrounded_by_matching_tops := true + for row_offset: int in range(-1, 2): + for column_offset: int in range(-1, 2): + var neighbor := coordinate + Vector2i(column_offset, row_offset) + if not _coordinate_is_inside_grid(neighbor): + surrounded_by_matching_tops = false + break + var neighbor_index := neighbor.y * grid_size.x + neighbor.x + var neighbor_variant := _placements[neighbor_index] + if ( + neighbor_variant == null + or neighbor_variant.definition.stable_id != top_id + ): + surrounded_by_matching_tops = false + break + if not surrounded_by_matching_tops: + break + if surrounded_by_matching_tops: + _elevated_feature_center = coordinate + return coordinate + return Vector2i(-1, -1) + + +func _coordinate_has_primary_elevated_top(coordinate: Vector2i) -> bool: + if not _coordinate_is_inside_grid(coordinate): + return false + var index := coordinate.y * grid_size.x + coordinate.x + if index < 0 or index >= _placements.size() or _placements[index] == null: + return false + return _placements[index].definition.stable_id in [ + elevated_cliff_top_chunk_id, + elevated_cliff_third_tier_top_chunk_id, + ] + + func _variant_respects_ocean_boundary( variant: TerrainChunkVariant, coordinate: Vector2i, @@ -1900,9 +2984,16 @@ func _calculate_edge_compatibility( second: TerrainChunkVariant, second_edge: TerrainChunkTopology.Edge, ) -> bool: + var profile_tolerance := maxf( + edge_match_tolerance, + maxf( + first.definition.edge_profile_tolerance_override, + second.definition.edge_profile_tolerance_override, + ), + ) var profiles_match := first.profile(first_edge).matches( second.profile(second_edge), - edge_match_tolerance, + profile_tolerance, ) if not profiles_match and ( _variant_edge_has_connector( @@ -1919,7 +3010,7 @@ func _calculate_edge_compatibility( profiles_match = first.profile(first_edge).matches_above_height( second.profile(second_edge), 0.0, - edge_match_tolerance, + profile_tolerance, ) if not profiles_match: return false @@ -2457,13 +3548,18 @@ func _add_stacked_elevated_chunks(solution_root: Node3D) -> bool: index, ] var offset: Vector2 = record.get("offset", Vector2.ZERO) + var vertical_offset := float(record.get( + "vertical_offset", + elevated_cliff_level_height, + )) stacked_root.position = Vector3( offset.x * catalog.chunk_size, - elevated_cliff_level_height, + vertical_offset, offset.y * catalog.chunk_size, ) stacked_root.rotation.y = variant.rotation_radians() stacked_root.set_meta(&"terrain_stacked_elevation", true) + stacked_root.set_meta(&"terrain_stacked_elevation_height", vertical_offset) stacked_root.set_meta( &"terrain_chunk_id", variant.definition.stable_id, @@ -2676,6 +3772,10 @@ func stacked_elevated_placement_keys() -> PackedStringArray: for record: Dictionary in _stacked_elevated_placements: var variant := record.get("variant") as TerrainChunkVariant var offset: Vector2 = record.get("offset", Vector2.ZERO) + var vertical_offset := float(record.get( + "vertical_offset", + elevated_cliff_level_height, + )) if variant == null: continue result.append( @@ -2684,7 +3784,7 @@ func stacked_elevated_placement_keys() -> PackedStringArray: variant.stable_key(), offset.x, offset.y, - elevated_cliff_level_height, + vertical_offset, ] ) return result @@ -2708,6 +3808,7 @@ func placement_records() -> Array[Dictionary]: ), "water_surface_size": variant.definition.water_surface_size, "water_surface_offset": variant.definition.water_surface_offset, + "water_surface_polygon": variant.definition.water_surface_polygon, }) return result diff --git a/world/water_body_authoring.gd b/world/water_body_authoring.gd index f21805f..2430be0 100644 --- a/world/water_body_authoring.gd +++ b/world/water_body_authoring.gd @@ -3,6 +3,7 @@ class_name WaterBodyAuthoring extends Node3D const FishPoolType = preload("res://fish/fish_pool.gd") +const GENERATED_POLYGON_SHAPE_META := &"water_body_generated_polygon_shape" @export_group("Surface") ## Visible water footprint in meters. @@ -14,6 +15,13 @@ var surface_size: Vector2 = Vector2(10.0, 10.0): set(value): surface_size = Vector2(maxf(value.x, 0.1), maxf(value.y, 0.1)) _sync_owned_nodes() +## Optional exact local X/Z coverage. Three or more points replace the box +## collision footprint with triangulated, vertically extruded volumes. The +## visible PlaneMesh remains controlled by surface_size. +@export var surface_polygon := PackedVector2Array(): + set(value): + surface_polygon = value + _sync_owned_nodes() ## Derive fishing coverage from VisualWater's mesh bounds instead of resizing ## the mesh from Surface Size. Use this for imported or shared water surfaces. @export var derive_coverage_from_visual_mesh: bool = false: @@ -97,6 +105,7 @@ func _sync_owned_nodes() -> void: plane_mesh.size = surface_size visual_water.material_override = water_material var surface_footprint: Rect2 = _resolve_surface_footprint(visual_water) + var uses_polygon := surface_polygon.size() >= 3 var fishing_shape_node := ( get_node_or_null(fishing_shape_path) as CollisionShape3D @@ -112,9 +121,19 @@ func _sync_owned_nodes() -> void: fishing_region.surface_height_mode = ( FishableWaterRegion.SurfaceHeightMode.PARENT_GLOBAL_Y ) - if fishing_shape_node != null: - var fishing_shape := fishing_shape_node.shape as BoxShape3D - if fishing_shape != null: + if fishing_shape_node != null and fishing_region != null: + if uses_polygon and _sync_polygon_collision_shapes( + fishing_region, + fishing_shape_node, + fishing_depth, + ): + pass + else: + _clear_generated_polygon_shapes(fishing_region) + var fishing_shape := fishing_shape_node.shape as BoxShape3D + if fishing_shape == null: + fishing_shape = BoxShape3D.new() + fishing_shape_node.shape = fishing_shape # The authored root is the one water-surface height. Keep the fishable # volume entirely below that plane so visible water and interaction # can be moved together without a second height to tune. @@ -133,18 +152,28 @@ func _sync_owned_nodes() -> void: var recovery_region := ( get_node_or_null(recovery_region_path) as Area3D ) - if recovery_region != null: - recovery_region.position = Vector3( - surface_footprint.get_center().x, - -recovery_depth * 0.5, - surface_footprint.get_center().y, - ) var recovery_shape_node := ( get_node_or_null(recovery_shape_path) as CollisionShape3D ) - if recovery_shape_node != null: - var recovery_shape := recovery_shape_node.shape as BoxShape3D - if recovery_shape != null: + if recovery_region != null and recovery_shape_node != null: + if uses_polygon and _sync_polygon_collision_shapes( + recovery_region, + recovery_shape_node, + recovery_depth, + ): + recovery_region.position = Vector3.ZERO + else: + _clear_generated_polygon_shapes(recovery_region) + recovery_region.position = Vector3( + surface_footprint.get_center().x, + -recovery_depth * 0.5, + surface_footprint.get_center().y, + ) + recovery_shape_node.position = Vector3.ZERO + var recovery_shape := recovery_shape_node.shape as BoxShape3D + if recovery_shape == null: + recovery_shape = BoxShape3D.new() + recovery_shape_node.shape = recovery_shape recovery_shape.size = Vector3( surface_footprint.size.x, recovery_depth, @@ -155,6 +184,57 @@ func _sync_owned_nodes() -> void: update_configuration_warnings() +func _sync_polygon_collision_shapes( + region: CollisionObject3D, + primary_shape_node: CollisionShape3D, + depth: float, +) -> bool: + var triangle_indices := Geometry2D.triangulate_polygon(surface_polygon) + if triangle_indices.size() < 3: + return false + _clear_generated_polygon_shapes(region) + for offset: int in range(0, triangle_indices.size(), 3): + var shape_node := primary_shape_node + if offset > 0: + shape_node = CollisionShape3D.new() + shape_node.name = "GeneratedPolygonShape%d" % floori( + float(offset) / 3.0 + ) + shape_node.set_meta(GENERATED_POLYGON_SHAPE_META, true) + region.add_child(shape_node) + shape_node.position = Vector3.ZERO + shape_node.disabled = false + shape_node.shape = _extruded_triangle_shape( + triangle_indices, + offset, + depth, + ) + return true + + +func _extruded_triangle_shape( + triangle_indices: PackedInt32Array, + offset: int, + depth: float, +) -> ConvexPolygonShape3D: + var points := PackedVector3Array() + for point_offset: int in 3: + var point := surface_polygon[triangle_indices[offset + point_offset]] + points.append(Vector3(point.x, 0.0, point.y)) + points.append(Vector3(point.x, -depth, point.y)) + var shape := ConvexPolygonShape3D.new() + shape.points = points + return shape + + +func _clear_generated_polygon_shapes(region: CollisionObject3D) -> void: + for child: Node in region.get_children(): + if not child.has_meta(GENERATED_POLYGON_SHAPE_META): + continue + region.remove_child(child) + child.free() + + func _resolve_surface_footprint(visual_water: MeshInstance3D) -> Rect2: var authored_footprint := Rect2(-surface_size * 0.5, surface_size) if ( @@ -202,7 +282,14 @@ func _get_configuration_warnings() -> PackedStringArray: "VisualWater must provide a PlaneMesh when coverage is authored by size." ) var fishing_shape := get_node_or_null(fishing_shape_path) as CollisionShape3D - if fishing_shape == null or not fishing_shape.shape is BoxShape3D: + if fishing_shape == null: + warnings.append("FishingRegion must provide a collision shape.") + elif surface_polygon.size() >= 3: + if not fishing_shape.shape is ConvexPolygonShape3D: + warnings.append( + "FishingRegion must provide polygon coverage for Surface Polygon." + ) + elif not fishing_shape.shape is BoxShape3D: warnings.append("FishingRegion must provide a BoxShape3D.") if manage_recovery_coverage: var recovery_region := ( @@ -220,7 +307,14 @@ func _get_configuration_warnings() -> PackedStringArray: var recovery_shape := ( get_node_or_null(recovery_shape_path) as CollisionShape3D ) - if recovery_shape == null or not recovery_shape.shape is BoxShape3D: + if recovery_shape == null: + warnings.append("RecoveryRegion must provide a collision shape.") + elif surface_polygon.size() >= 3: + if not recovery_shape.shape is ConvexPolygonShape3D: + warnings.append( + "RecoveryRegion must provide polygon coverage for Surface Polygon." + ) + elif not recovery_shape.shape is BoxShape3D: warnings.append("RecoveryRegion must provide a BoxShape3D.") var fishing_region := get_node_or_null( fishing_region_path