diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index e00178a..60ef84c 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -13,7 +13,7 @@ const GeneratedRiverPool: FishPool = preload( "res://fish/pools/generated_river_pool.tres" ) const FIRST_SEED := 13001 -const SECOND_SEED := 13004 +const SECOND_SEED := 13002 const EXPECTED_PROP_IDS: Array[StringName] = [ &"prop_bridge", &"prop_dock", @@ -110,23 +110,47 @@ func _validate_generated_region( 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_0002") >= 1) assert(_placement_count(placements, "chunk_0003") >= 1) - assert(_placement_count(placements, "chunk_0016") >= 1) assert(_placement_count(placements, "chunk_0004") >= 1) - assert(_placement_count(placements, "chunk_0005") >= 1) - assert(_placement_count(placements, "chunk_0006") >= 1) - assert(_placement_count(placements, "chunk_0007") >= 1) - assert(_placement_count(placements, "chunk_0008") >= 1) + 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 > 0) != (grass_coast_count > 0)) + if beach_count > 0: + assert(beach_count == 48 and grass_coast_count == 0) + assert(beach_corner_count == 4 and grass_corner_count == 0) + assert(_placement_count(placements, "chunk_0009") == 9) + assert(_placement_count(placements, "chunk_0010") == 4) + assert( + _placement_count(placements, "chunk_0011") + + _placement_count(placements, "chunk_0012") + == 12 + ) + assert(_placement_count(placements, "chunk_0012") == 1) + for stable_id: String in [ + "chunk_0014", + "chunk_0015", + "chunk_0017", + "chunk_0018", + ]: + assert(_placement_count(placements, stable_id) == 0) + else: + assert(grass_coast_count == 44 and beach_count == 0) + assert(grass_corner_count == 3 and beach_corner_count == 0) + assert(_placement_count(placements, "chunk_0009") == 10) + assert(_placement_count(placements, "chunk_0010") == 5) + assert( + _placement_count(placements, "chunk_0011") + + _placement_count(placements, "chunk_0012") + == 14 + ) + 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") == 1) + assert(_placement_count(placements, "chunk_0018") == 1) assert(_placement_count(placements, "chunk_0013") >= 1) - assert(_placement_count(placements, "chunk_0009") == 1) - assert(_placement_count(placements, "chunk_0010") == 4) - assert( - _placement_count(placements, "chunk_0011") - + _placement_count(placements, "chunk_0012") - == 4 - ) - assert(_placement_count(placements, "chunk_0012") <= 1) assert( generator.get_generated_chunks_root().get_child_count() == expected_chunk_count @@ -164,6 +188,7 @@ func _validate_generated_region( _validate_ocean_facing_record(record, generator.grid_size) if "fresh_water" in tags: fresh_placement_count += 1 + _validate_complete_coastline(generator) assert(fresh_root.get_child_count() == fresh_placement_count) for child: Node in fresh_root.get_children(): var fresh := child as WaterBodyAuthoring @@ -191,6 +216,7 @@ func _validate_generated_region( assert(decorations != null and decorations.get_child_count() > 0) assert(anchors != null) assert(anchors.get_spawn_positions().size() > 0) + _validate_tree_gatherable_anchors(region, decorations, anchors) var palm_clusters: Dictionary[int, Array] = {} var tree_scales: Array[float] = [] var tree_yaws: Dictionary[float, bool] = {} @@ -295,7 +321,7 @@ func _validate_generated_region( ) assert(not palm_clusters.is_empty()) for members: Array in palm_clusters.values(): - assert(members.size() >= 2 and members.size() <= 3) + assert(members.size() >= 1 and members.size() <= 3) for first_index: int in members.size(): for second_index: int in range(first_index + 1, members.size()): var first := members[first_index] as Node3D @@ -314,14 +340,14 @@ func _validate_generated_region( region, &"biome_forest", &"grass_tree", - ) >= 2 + ) >= 4 ) assert( _decoration_biome_group_count( region, &"biome_pine_forest", &"grass_tree", - ) >= 2 + ) >= 4 ) @@ -329,7 +355,15 @@ func _validate_elevated_cliff_feature( generator: TerrainChunkGenerator, ) -> void: var elevated_coordinates: Dictionary[Vector2i, StringName] = {} - var top_coordinate := Vector2i(-1, -1) + var top_coordinates: Array[Vector2i] = [] + var ramp_records: Array[Dictionary] = [] + var coastal_ids: Array[StringName] = [ + &"chunk_0014", + &"chunk_0015", + &"chunk_0017", + &"chunk_0018", + ] + var has_coastal_feature := false for record: Dictionary in generator.placement_records(): var stable_id: StringName = record.get("stable_id", &"") if stable_id not in [ @@ -337,23 +371,100 @@ func _validate_elevated_cliff_feature( &"chunk_0010", &"chunk_0011", &"chunk_0012", + &"chunk_0014", + &"chunk_0015", + &"chunk_0017", + &"chunk_0018", ]: continue var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) - assert(coordinate.x > 0 and coordinate.x < generator.grid_size.x - 1) - assert(coordinate.y > 0 and coordinate.y < generator.grid_size.y - 1) + if stable_id in coastal_ids: + has_coastal_feature = true + assert( + coordinate.x in [0, generator.grid_size.x - 1] + or coordinate.y in [0, generator.grid_size.y - 1] + ) + else: + 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": - top_coordinate = coordinate - assert(elevated_coordinates.size() == 9) - assert(top_coordinate != Vector2i(-1, -1)) - for row_offset: int in range(-1, 2): - for column_offset: int in range(-1, 2): + 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) + for candidate: Vector2i in top_coordinates: + var neighboring_top_count := 0 + for row_offset: int in range(-1, 2): + for column_offset: int in range(-1, 2): + neighboring_top_count += int( + elevated_coordinates.get( + candidate + Vector2i(column_offset, row_offset), + &"", + ) == &"chunk_0009" + ) + if neighboring_top_count == 9: + feature_center = candidate + break + assert(feature_center != Vector2i(-1, -1)) + for row_offset: int in range(-2, 3): + for column_offset: int in range(-2, 3): assert( elevated_coordinates.has( - top_coordinate + Vector2i(column_offset, row_offset) + feature_center + Vector2i(column_offset, row_offset) ) ) + 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 + assert(secondary_top != Vector2i(-1, -1)) + for row_offset: int in range(-1, 2): + for column_offset: int in range(-1, 2): + assert( + elevated_coordinates.has( + secondary_top + Vector2i(column_offset, row_offset) + ) + ) + var found_deep_top_landing := false + for ramp_record: Dictionary in ramp_records: + var ramp_coordinate: Vector2i = ramp_record.get( + "coordinate", Vector2i(-1, -1) + ) + var ramp_turns := int(ramp_record.get("rotation_quarters", 0)) + var high_edge := TerrainChunkTopology.rotated_edge( + TerrainChunkTopology.Edge.WEST, + ramp_turns, + ) + var high_offset := TerrainChunkTopology.grid_offset(high_edge) + assert( + elevated_coordinates.get(ramp_coordinate + high_offset, &"") + == &"chunk_0009" + ) + found_deep_top_landing = ( + found_deep_top_landing + or elevated_coordinates.get( + ramp_coordinate + high_offset * 2, + &"", + ) == &"chunk_0009" + ) + var low_coordinate := ramp_coordinate - high_offset + var low_index := ( + low_coordinate.y * generator.grid_size.x + low_coordinate.x + ) + assert( + generator.placement_keys()[low_index].begins_with("chunk_0000@") + or generator.placement_keys()[low_index].begins_with("chunk_spawn@") + ) + assert(found_deep_top_landing) var generated := generator.get_generated_chunks_root() assert(generated != null) @@ -367,25 +478,33 @@ func _validate_elevated_cliff_feature( &"chunk_0010", &"chunk_0011", &"chunk_0012", + &"chunk_0014", + &"chunk_0015", + &"chunk_0017", + &"chunk_0018", ]: continue + if stable_id in [&"chunk_0014", &"chunk_0015"]: + assert(chunk_root.get_node_or_null("TerrainBaseLayer") == null) + 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 is_transition := stable_id in [&"chunk_0017", &"chunk_0018"] var base_mesh := TerrainChunkAnalyzer.find_primary_mesh( base_layer, - &"chunk_0000", + &"chunk_0005" if is_transition else &"chunk_0000", ) var overlay_mesh := TerrainChunkAnalyzer.find_primary_mesh( overlay, - stable_id, + &"chunk_0015" if is_transition else stable_id, ) assert(base_mesh != null and base_mesh.mesh != null) assert(overlay_mesh != null and overlay_mesh.mesh != null) assert(base_mesh.has_node("TerrainBaseLayerCollision")) assert(overlay_mesh.has_node("TerrainCollision")) - assert(layered_count == 9) + assert(layered_count == (31 if has_coastal_feature else 25)) var stacked_keys := generator.stacked_elevated_placement_keys() assert(stacked_keys.size() in [0, 4]) if stacked_keys.is_empty(): @@ -441,6 +560,28 @@ func _validate_ocean_facing_record( ) +func _validate_complete_coastline(generator: TerrainChunkGenerator) -> void: + for record: Dictionary in generator.placement_records(): + var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) + var ocean_edges := int(record.get("ocean_facing_edges", 0)) + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + var outside := ( + neighbor.x < 0 + or neighbor.y < 0 + or neighbor.x >= generator.grid_size.x + or neighbor.y >= generator.grid_size.y + ) + assert( + ((ocean_edges & (1 << edge_value)) != 0) == outside + ) + + func _ocean_direction(ocean_edges: int) -> Vector3: var result := Vector3.ZERO for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -522,13 +663,19 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: + catalog.definition_for_id(&"prop_tree_3").selection_weight ) assert(forest_rule.allows_prop(large_tree)) - assert(large_tree.selection_weight > small_tree_weight) + assert(large_tree.selection_weight <= small_tree_weight) var pine_forest := region.get_biome_catalog().definition_for_id( &"biome_pine_forest" ) var pine_rule := pine_forest.prop_rule_for_group(&"grass_tree") assert(pine_rule.allows_prop(large_pine)) - assert(large_pine.selection_weight > pine.selection_weight) + assert( + is_equal_approx(large_pine.selection_weight, pine.selection_weight) + ) + assert(forest_rule.minimum_placements == 4) + assert(forest_rule.placement_attempts_per_chunk == 4) + assert(pine_rule.minimum_placements == 4) + assert(pine_rule.placement_attempts_per_chunk == 4) func _validate_biome_catalog( @@ -623,6 +770,57 @@ func _validate_decoration_transform( assert(absf(ground_position.y - prop.global_position.y) <= 0.01) +func _validate_tree_gatherable_anchors( + region: GeneratedWorldRegion, + decorations: Node3D, + anchors: GatherableAnchorSet3D, +) -> void: + var eligible_props: Array[Node3D] = [] + for child: Node in decorations.get_children(): + var prop := child as Node3D + if prop == null: + continue + var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) + var definition := region.get_prop_catalog().definition_for_id(prop_id) + if definition != null and definition.gatherable_anchor_height > 0.0: + eligible_props.append(prop) + var positions := anchors.get_spawn_positions() + assert(positions.size() == eligible_props.size()) + for prop: Node3D in eligible_props: + var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) + var definition := region.get_prop_catalog().definition_for_id(prop_id) + var visual_scale := float( + prop.get_meta(&"terrain_prop_visual_scale", 1.0) + ) + var nearest_anchor := Vector3(INF, INF, INF) + var nearest_distance_squared := INF + for position: Vector3 in positions: + var distance_squared := position.distance_squared_to( + prop.global_position + ) + if distance_squared < nearest_distance_squared: + nearest_distance_squared = distance_squared + nearest_anchor = position + assert(nearest_anchor.is_finite()) + var horizontal_distance := Vector2( + nearest_anchor.x - prop.global_position.x, + nearest_anchor.z - prop.global_position.z, + ).length() + assert( + horizontal_distance + >= definition.gatherable_anchor_surface_radius() * visual_scale + ) + assert( + absf( + nearest_anchor.y + - ( + prop.global_position.y + + definition.gatherable_anchor_height * visual_scale + ) + ) <= 0.001 + ) + + func _find_mesh_instance(root_node: Node) -> MeshInstance3D: if root_node is MeshInstance3D: return root_node as MeshInstance3D @@ -662,13 +860,15 @@ func _validate_authored_chunk_surfaces( var generated := generator.get_generated_chunks_root() var found_beach := false var found_stream := false - var found_alternate_stream := false var found_pond := false var found_grass_ocean_edge := false var found_grass_beach_transition := false var found_grass_ocean_corner := false var found_beach_ocean_corner := false var found_grass_sand_diagonal := false + var beach_family := ( + _placement_count(generator.placement_keys(), "chunk_0002") > 0 + ) for chunk_root: Node in generated.get_children(): if chunk_root.name.begins_with("chunk_0002r"): var beach := chunk_root.find_child( @@ -695,9 +895,7 @@ func _validate_authored_chunk_surfaces( assert("dirt" in stream_materials) assert("grass_lite" in stream_materials) assert("dirt_wall" in stream_materials) - if stream_id == &"chunk_0016": - found_alternate_stream = true - else: + if stream_id == &"chunk_0003": found_stream = true elif chunk_root.name.begins_with("chunk_0004r"): var pond := chunk_root.find_child( @@ -761,15 +959,20 @@ func _validate_authored_chunk_surfaces( assert("grass_lite" in diagonal_materials) assert("sand" in diagonal_materials) found_grass_sand_diagonal = true - assert(found_beach) assert(found_stream) - assert(found_alternate_stream) assert(found_pond) - assert(found_grass_ocean_edge) - assert(found_grass_beach_transition) - assert(found_grass_ocean_corner) - assert(found_beach_ocean_corner) assert(found_grass_sand_diagonal) + if beach_family: + assert(found_beach) + assert(found_beach_ocean_corner) + assert(not found_grass_ocean_edge) + assert(not found_grass_ocean_corner) + else: + assert(not found_beach) + assert(not found_beach_ocean_corner) + assert(found_grass_ocean_edge) + assert(found_grass_ocean_corner) + assert(not found_grass_beach_transition) func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray: diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd index 94b557e..fbd78c1 100644 --- a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd @@ -104,7 +104,7 @@ func _run() -> void: func _make_generator() -> TerrainChunkGenerator: var generator := TerrainChunkGenerator.new() generator.catalog = CATALOG - generator.grid_size = Vector2i(7, 7) + generator.grid_size = Vector2i(9, 9) generator.generation_seed = 13001 generator.generate_on_ready = false generator.build_collision = false @@ -113,13 +113,8 @@ func _make_generator() -> TerrainChunkGenerator: [ "chunk_spawn", "chunk_0001", - "chunk_0002", "chunk_0003", "chunk_0004", - "chunk_0005", - "chunk_0006", - "chunk_0007", - "chunk_0008", ] ) generator.grass_sand_smoothing_enabled = true diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn index 34a74e2..b29d4f8 100644 --- a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn @@ -21,12 +21,12 @@ shadow_enabled = true [node name="TerrainChunkGenerator" type="Node3D" parent="."] script = ExtResource("2_generator") catalog = ExtResource("3_catalog") -grid_size = Vector2i(11, 9) +grid_size = Vector2i(15, 13) generation_seed = 13001 build_collision = true show_chunk_labels = false force_center_chunk_id = &"chunk_spawn" -required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007", "chunk_0008") +required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0003", "chunk_0004") grass_sand_smoothing_enabled = true elevated_cliff_feature_enabled = true diff --git a/tests/terrain_chunk_generator_validation.gd b/tests/terrain_chunk_generator_validation.gd index deb097b..5aa7219 100644 --- a/tests/terrain_chunk_generator_validation.gd +++ b/tests/terrain_chunk_generator_validation.gd @@ -21,18 +21,15 @@ const EXPECTED_IDS: Array[String] = [ "chunk_0014", "chunk_0015", "chunk_0016", + "chunk_0017", + "chunk_0018", "chunk_spawn", ] const REQUIRED_IDS: Array[String] = [ "chunk_spawn", "chunk_0001", - "chunk_0002", "chunk_0003", "chunk_0004", - "chunk_0005", - "chunk_0006", - "chunk_0007", - "chunk_0008", ] const TEST_SEED := 13001 const CONNECTOR_STRESS_SEED := 287 @@ -55,6 +52,8 @@ const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = { &"chunk_0014": 4, &"chunk_0015": 4, &"chunk_0016": 4, + &"chunk_0017": 4, + &"chunk_0018": 4, &"chunk_spawn": 1, } @@ -97,8 +96,11 @@ func _validate_catalog() -> void: var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007") var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008") var grass_sand_diagonal := CATALOG.definition_for_id(&"chunk_0013") + 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 stream_variant := CATALOG.definition_for_id(&"chunk_0016") var cliff_top := CATALOG.definition_for_id(&"chunk_0009") var cliff_corner := CATALOG.definition_for_id(&"chunk_0010") @@ -115,8 +117,11 @@ func _validate_catalog() -> void: and grass_ocean_corner != null and beach_ocean_corner != null and grass_sand_diagonal != null + and pond != null and cliff_sea_edge != null and cliff_sea_corner != null + and cliff_sea_transition_right != null + and cliff_sea_transition_left != null and stream_variant != null and cliff_top != null and cliff_corner != null @@ -135,8 +140,11 @@ func _validate_catalog() -> void: and grass_ocean_corner != null and beach_ocean_corner != null and grass_sand_diagonal != null + and pond != null and cliff_sea_edge != null and cliff_sea_corner != null + and cliff_sea_transition_right != null + and cliff_sea_transition_left != null and stream_variant != null and cliff_top != null and cliff_corner != null @@ -157,8 +165,11 @@ func _validate_catalog() -> void: "Grass must permit the inland side of a flat-sand transition.", ) _check( - "coast" in sand.required_neighbor_tags, - "Every flat-sand chunk must remain attached to an authored beach.", + ( + "sand" in sand.required_neighbor_tags + and "coast" in sand.required_neighbor_tags + ), + "Every flat-sand chunk must remain attached to sand or authored coast.", ) _check( spawn.minimum_required_neighbors == 3, @@ -281,6 +292,12 @@ func _validate_catalog() -> void: "The diagonal's authored edges must retain two sand and two grass sides.", ) var stream := CATALOG.definition_for_id(&"chunk_0003") + _check( + pond.water_inlet_edges == pond.water_outlet_edges + and pond.water_inlet_edges + == (1 << int(TerrainChunkTopology.Edge.NORTH)), + "A pond must terminate either end of an inland freshwater chain.", + ) _check( stream != null and is_equal_approx(stream.selection_weight, 0.3) @@ -331,6 +348,34 @@ func _validate_catalog() -> void: + "overlay without an inland grass base." ), ) + for transition: TerrainChunkDefinition in [ + cliff_sea_transition_right, + cliff_sea_transition_left, + ]: + _check( + transition.overlay_only + and transition.base_layer_scene != null + and transition.base_layer_mesh_name == &"chunk_0005" + and not transition.must_be_interior + and transition.prefers_map_boundary + and "transition" in transition.tags, + ( + "Each raised-coast endpoint must layer the deep corner " + + "over an authored level-one coastline." + ), + ) + _check( + cliff_sea_transition_right.base_layer_quarter_turns == 3 + and cliff_sea_transition_right.ocean_facing_edges + == (1 << int(TerrainChunkTopology.Edge.SOUTH)), + "The right coastal transition must align its south-facing base coast.", + ) + _check( + cliff_sea_transition_left.base_layer_quarter_turns == 0 + and cliff_sea_transition_left.ocean_facing_edges + == (1 << int(TerrainChunkTopology.Edge.EAST)), + "The left coastal transition must align its east-facing base coast.", + ) _check( cliff_sea_edge.ocean_facing_edges == (1 << int(TerrainChunkTopology.Edge.EAST)), @@ -486,21 +531,17 @@ func _validate_generation() -> void: var first := _make_generator() root.add_child(first) var first_solved := first.generate() - _check(first_solved, "Generator must solve the prototype 7x7 grid.") + _check(first_solved, "Generator must solve the prototype 9x9 grid.") if not first_solved: first.free() return var first_keys := first.placement_keys() - _check(first_keys.size() == 49, "Generator must place exactly 49 chunks.") + _check(first_keys.size() == 81, "Generator must place exactly 81 chunks.") for stable_id: String in REQUIRED_IDS: _check( _placements_contain(first_keys, stable_id), "Generated diagnostic must contain %s." % stable_id, ) - _check( - _placements_contain(first_keys, "chunk_0013"), - "Generated terrain must replace sharp grass-sand corners with diagonals.", - ) _check( _all_neighbor_edges_match(first), "Every generated neighboring edge must match.", @@ -510,7 +551,7 @@ func _validate_generation() -> void: "Generated layouts must contain exactly one spawn chunk.", ) _check( - first_keys[24].begins_with("chunk_spawn@"), + first_keys[40].begins_with("chunk_spawn@"), "The spawn chunk must occupy the center cell.", ) _validate_layout_rules(first) @@ -837,18 +878,24 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: "The beach corner's west side must join a rotated straight beach.", ) var cliff_top_zero := _find_variant(generator, &"chunk_0009", 0) + var cliff_edge_zero := _find_variant(generator, &"chunk_0011", 0) + var cliff_edge_south := _find_variant(generator, &"chunk_0011", 3) var cliff_sea_edge_zero := _find_variant(generator, &"chunk_0014", 0) var cliff_sea_edge_south := _find_variant(generator, &"chunk_0014", 3) var cliff_sea_corner_zero := _find_variant(generator, &"chunk_0015", 0) _check( cliff_top_zero != null + and cliff_edge_zero != null + and cliff_edge_south != null and cliff_sea_edge_zero != null and cliff_sea_edge_south != null and cliff_sea_corner_zero != null, - "The second-tier sea edge variants must be available.", + "The inland and coastal second-tier cliff variants must be available.", ) if ( cliff_top_zero != null + and cliff_edge_zero != null + and cliff_edge_south != null and cliff_sea_edge_zero != null and cliff_sea_edge_south != null and cliff_sea_corner_zero != null @@ -892,6 +939,42 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: ), "The second-tier sea corner must join two straight sea edges.", ) + _check( + generator._edges_are_compatible( + cliff_sea_edge_zero, + TerrainChunkTopology.Edge.NORTH, + cliff_edge_zero, + TerrainChunkTopology.Edge.SOUTH, + ) + and generator._edges_are_compatible( + cliff_sea_edge_zero, + TerrainChunkTopology.Edge.SOUTH, + cliff_edge_zero, + TerrainChunkTopology.Edge.NORTH, + ), + ( + "A coastal cliff edge must transition directly into its " + + "inland edge counterpart." + ), + ) + _check( + generator._edges_are_compatible( + cliff_sea_corner_zero, + TerrainChunkTopology.Edge.NORTH, + cliff_edge_zero, + TerrainChunkTopology.Edge.SOUTH, + ) + and generator._edges_are_compatible( + cliff_sea_corner_zero, + TerrainChunkTopology.Edge.WEST, + cliff_edge_south, + TerrainChunkTopology.Edge.EAST, + ), + ( + "A coastal cliff corner must transition directly into " + + "ordinary inland cliff edges." + ), + ) var flat_grass := _find_variant(generator, &"chunk_0000", 0) var flat_sand := _find_variant(generator, &"chunk_0001", 0) _check( @@ -981,6 +1064,8 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: &"chunk_0008", &"chunk_0014", &"chunk_0015", + &"chunk_0017", + &"chunk_0018", ]: for quarter_turns: int in 4: var coast_variant := _find_variant( @@ -1032,8 +1117,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)) == 69, - "The current catalog must expose all 69 authored rotations.", + int(summary.get("variant_count", 0)) == 77, + "The current catalog must expose all 77 authored rotations.", ) _check( int(summary.get("solver_variant_count", 0)) @@ -1080,7 +1165,7 @@ func _find_variant( func _make_generator() -> TerrainChunkGenerator: var generator := TerrainChunkGenerator.new() generator.catalog = CATALOG - generator.grid_size = Vector2i(7, 7) + generator.grid_size = Vector2i(9, 9) generator.generation_seed = TEST_SEED generator.generate_on_ready = false generator.build_collision = false @@ -1185,6 +1270,23 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void: ), "Every coastline chunk must direct its ocean edge outside the grid.", ) + var ocean_edges := placement.rotated_edge_mask( + placement.definition.ocean_facing_edges + ) + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_coordinate := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + var outside := not generator._coordinate_is_inside_grid( + neighbor_coordinate + ) + _check( + ((ocean_edges & (1 << edge_value)) != 0) == outside, + "Every map-boundary edge must use authored coastline geometry.", + ) if placement.definition.stable_id == &"chunk_0006": var touches_beach := false var touches_grass_edge := false @@ -1257,7 +1359,7 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void: ) if placement.definition.stable_id != &"chunk_0001": continue - var touches_coast := false + var touches_sand_or_coast := false for edge_value: int in TerrainChunkTopology.Edge.values(): var neighbor_coordinate := ( coordinate @@ -1271,11 +1373,14 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void: neighbor_coordinate.y * generator.grid_size.x + neighbor_coordinate.x ] - if "coast" in neighbor.definition.tags: - touches_coast = true + if ( + "sand" in neighbor.definition.tags + or "coast" in neighbor.definition.tags + ): + touches_sand_or_coast = true _check( - touches_coast, - "Every flat-sand chunk must remain directly attached to a beach.", + touches_sand_or_coast, + "Every flat-sand chunk must remain attached to sand or coast.", ) diff --git a/world/generation/biomes/definitions/biome_forest.tres b/world/generation/biomes/definitions/biome_forest.tres index c0764e2..ba8a54e 100644 --- a/world/generation/biomes/definitions/biome_forest.tres +++ b/world/generation/biomes/definitions/biome_forest.tres @@ -6,11 +6,11 @@ [sub_resource type="Resource" id="Rule_grass_tree"] script = ExtResource("2_rule") procedural_group = &"grass_tree" -placement_chance = 8200 -adjacency_bonus = 1200 -maximum_density = 8500 -minimum_placements = 2 -placement_attempts_per_chunk = 3 +placement_chance = 9400 +adjacency_bonus = 600 +maximum_density = 9500 +minimum_placements = 4 +placement_attempts_per_chunk = 4 allowed_prop_ids = PackedStringArray("prop_tree_large", "prop_tree_1", "prop_tree_2", "prop_tree_3") [sub_resource type="Resource" id="Rule_grass_detail"] diff --git a/world/generation/biomes/definitions/biome_pine_forest.tres b/world/generation/biomes/definitions/biome_pine_forest.tres index 6582a40..0a1f7e8 100644 --- a/world/generation/biomes/definitions/biome_pine_forest.tres +++ b/world/generation/biomes/definitions/biome_pine_forest.tres @@ -6,11 +6,11 @@ [sub_resource type="Resource" id="Rule_grass_tree"] script = ExtResource("2_rule") procedural_group = &"grass_tree" -placement_chance = 8200 -adjacency_bonus = 1200 -maximum_density = 8500 -minimum_placements = 2 -placement_attempts_per_chunk = 3 +placement_chance = 9400 +adjacency_bonus = 600 +maximum_density = 9500 +minimum_placements = 4 +placement_attempts_per_chunk = 4 allowed_prop_ids = PackedStringArray("prop_pine_large", "prop_pine") [sub_resource type="Resource" id="Rule_grass_detail"] diff --git a/world/generation/chunks/definitions/chunk_0001.tres b/world/generation/chunks/definitions/chunk_0001.tres index 23469f4..516ea38 100644 --- a/world/generation/chunks/definitions/chunk_0001.tres +++ b/world/generation/chunks/definitions/chunk_0001.tres @@ -13,6 +13,6 @@ allowed_rotation_mask = 15 selection_weight = 1.5 tags = PackedStringArray("land", "walkable", "sand", "flat") allowed_neighbor_tags = PackedStringArray("sand", "grass") -required_neighbor_tags = PackedStringArray("coast") +required_neighbor_tags = PackedStringArray("sand", "coast") minimum_required_neighbors = 1 preferred_neighbor_tags = PackedStringArray("coast", "sand") diff --git a/world/generation/chunks/definitions/chunk_0002.tres b/world/generation/chunks/definitions/chunk_0002.tres index 77545a7..8cf381a 100644 --- a/world/generation/chunks/definitions/chunk_0002.tres +++ b/world/generation/chunks/definitions/chunk_0002.tres @@ -10,7 +10,7 @@ label = "beach" packed_scene = ExtResource("2_scene") primary_mesh_name = &"chunk_0002" selection_weight = 0.75 -maximum_placements = 20 +maximum_placements = 64 tags = PackedStringArray("land", "walkable", "sand", "coast", "slope", "beach_ocean_edge") allowed_neighbor_tags = PackedStringArray("sand", "grass") required_neighbor_tags = PackedStringArray("sand") diff --git a/world/generation/chunks/definitions/chunk_0004.tres b/world/generation/chunks/definitions/chunk_0004.tres index e56842e..7cf54b4 100644 --- a/world/generation/chunks/definitions/chunk_0004.tres +++ b/world/generation/chunks/definitions/chunk_0004.tres @@ -14,5 +14,6 @@ tags = PackedStringArray("land", "walkable", "fresh_water", "pond") allowed_neighbor_tags = PackedStringArray("grass") preferred_neighbor_tags = PackedStringArray("grass") water_inlet_edges = 1 +water_outlet_edges = 1 water_surface_size = Vector2(7, 8) water_surface_offset = Vector2(-0.32, -1.03) diff --git a/world/generation/chunks/definitions/chunk_0005.tres b/world/generation/chunks/definitions/chunk_0005.tres index faa266d..e34add5 100644 --- a/world/generation/chunks/definitions/chunk_0005.tres +++ b/world/generation/chunks/definitions/chunk_0005.tres @@ -10,7 +10,7 @@ label = "grass ocean edge" packed_scene = ExtResource("2_scene") primary_mesh_name = &"chunk_0005" selection_weight = 1.0 -maximum_placements = 20 +maximum_placements = 64 tags = PackedStringArray("land", "walkable", "grass", "coast", "cliff", "grass_ocean_edge") allowed_neighbor_tags = PackedStringArray("grass", "sand", "coast") north_allowed_neighbor_tags = PackedStringArray("coast") diff --git a/world/generation/chunks/definitions/chunk_0009.tres b/world/generation/chunks/definitions/chunk_0009.tres index 4be6d07..8f1f19c 100644 --- a/world/generation/chunks/definitions/chunk_0009.tres +++ b/world/generation/chunks/definitions/chunk_0009.tres @@ -14,7 +14,7 @@ base_layer_scene = ExtResource("3_base_grass") base_layer_mesh_name = &"chunk_0000" overlay_only = true selection_weight = 0.22 -maximum_placements = 8 +maximum_placements = 32 tags = PackedStringArray("land", "walkable", "grass", "elevation_2", "elevation_2_top") allowed_neighbor_tags = PackedStringArray("elevation_2") required_neighbor_tags = PackedStringArray("elevation_2") diff --git a/world/generation/chunks/definitions/chunk_0014.tres b/world/generation/chunks/definitions/chunk_0014.tres index c3a0d87..e0a1aa1 100644 --- a/world/generation/chunks/definitions/chunk_0014.tres +++ b/world/generation/chunks/definitions/chunk_0014.tres @@ -14,11 +14,12 @@ selection_weight = 0.28 maximum_placements = 32 tags = PackedStringArray("land", "walkable", "grass", "coast", "cliff", "edge", "elevation_2", "elevation_2_border", "elevation_2_sea_edge") allowed_neighbor_tags = PackedStringArray("elevation_2") -north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") -south_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") +south_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") west_allowed_neighbor_tags = PackedStringArray("elevation_2_top") required_neighbor_tags = PackedStringArray("elevation_2_top") minimum_required_neighbors = 1 preferred_neighbor_tags = PackedStringArray("elevation_2", "coast") prefers_map_boundary = true ocean_facing_edges = 2 +buried_cliff_seam_edges = 5 diff --git a/world/generation/chunks/definitions/chunk_0015.tres b/world/generation/chunks/definitions/chunk_0015.tres index d7f0a5a..7a03e49 100644 --- a/world/generation/chunks/definitions/chunk_0015.tres +++ b/world/generation/chunks/definitions/chunk_0015.tres @@ -14,10 +14,11 @@ selection_weight = 0.12 maximum_placements = 16 tags = PackedStringArray("land", "walkable", "grass", "coast", "cliff", "corner", "elevation_2", "elevation_2_border", "elevation_2_sea_edge") allowed_neighbor_tags = PackedStringArray("elevation_2") -north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") -west_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") required_neighbor_tags = PackedStringArray("elevation_2_sea_edge") minimum_required_neighbors = 2 preferred_neighbor_tags = PackedStringArray("elevation_2", "coast") prefers_map_boundary = true ocean_facing_edges = 6 +buried_cliff_seam_edges = 9 diff --git a/world/generation/chunks/definitions/chunk_0017.tres b/world/generation/chunks/definitions/chunk_0017.tres new file mode 100644 index 0000000..f7552ea --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0017.tres @@ -0,0 +1,29 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=4 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0015.glb" id="2_scene"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0005.glb" id="3_base_coast"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0017" +label = "second-tier grass sea transition right" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0015" +base_layer_scene = ExtResource("3_base_coast") +base_layer_mesh_name = &"chunk_0005" +base_layer_quarter_turns = 3 +overlay_only = true +selection_weight = 0.12 +maximum_placements = 8 +tags = PackedStringArray("land", "walkable", "grass", "coast", "cliff", "corner", "transition", "elevation_2", "elevation_2_border", "elevation_2_sea_edge") +allowed_neighbor_tags = PackedStringArray("elevation_2") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") +east_allowed_neighbor_tags = PackedStringArray("grass") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") +required_neighbor_tags = PackedStringArray("elevation_2_border") +minimum_required_neighbors = 2 +preferred_neighbor_tags = PackedStringArray("elevation_2", "coast") +prefers_map_boundary = true +ocean_facing_edges = 4 +buried_cliff_seam_edges = 9 diff --git a/world/generation/chunks/definitions/chunk_0018.tres b/world/generation/chunks/definitions/chunk_0018.tres new file mode 100644 index 0000000..f15421b --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0018.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=4 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0015.glb" id="2_scene"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0005.glb" id="3_base_coast"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0018" +label = "second-tier grass sea transition left" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0015" +base_layer_scene = ExtResource("3_base_coast") +base_layer_mesh_name = &"chunk_0005" +overlay_only = true +selection_weight = 0.12 +maximum_placements = 8 +tags = PackedStringArray("land", "walkable", "grass", "coast", "cliff", "corner", "transition", "elevation_2", "elevation_2_border", "elevation_2_sea_edge") +allowed_neighbor_tags = PackedStringArray("elevation_2") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") +south_allowed_neighbor_tags = PackedStringArray("grass") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge", "elevation_2_border") +required_neighbor_tags = PackedStringArray("elevation_2_border") +minimum_required_neighbors = 2 +preferred_neighbor_tags = PackedStringArray("elevation_2", "coast") +prefers_map_boundary = true +ocean_facing_edges = 2 +buried_cliff_seam_edges = 9 diff --git a/world/generation/chunks/terrain_chunk_catalog.tres b/world/generation/chunks/terrain_chunk_catalog.tres index 7d577a3..1b3cdf9 100644 --- a/world/generation/chunks/terrain_chunk_catalog.tres +++ b/world/generation/chunks/terrain_chunk_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="TerrainChunkCatalog" load_steps=21 format=3] +[gd_resource type="Resource" script_class="TerrainChunkCatalog" load_steps=23 format=3] [ext_resource type="Script" path="res://world/generation/terrain_chunk_catalog.gd" id="1_catalog"] [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0000.tres" id="2_grass"] @@ -20,8 +20,10 @@ [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0014.tres" id="18_cliff_sea_edge"] [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0015.tres" id="19_cliff_sea_corner"] [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0016.tres" id="20_stream_variant"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0017.tres" id="21_cliff_sea_transition_right"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0018.tres" id="22_cliff_sea_transition_left"] [resource] script = ExtResource("1_catalog") chunk_size = 10.0 -definitions = Array[ExtResource("7_definition_script")]([ExtResource("2_grass"), ExtResource("3_sand"), ExtResource("4_beach"), ExtResource("5_stream"), ExtResource("20_stream_variant"), ExtResource("6_pond"), ExtResource("9_grass_ocean_edge"), ExtResource("10_grass_beach_transition"), ExtResource("11_grass_ocean_corner"), ExtResource("12_beach_ocean_corner"), ExtResource("17_grass_sand_diagonal"), ExtResource("13_cliff_top"), ExtResource("14_cliff_corner"), ExtResource("15_cliff_edge"), ExtResource("16_cliff_ramp"), ExtResource("18_cliff_sea_edge"), ExtResource("19_cliff_sea_corner"), ExtResource("8_spawn")]) +definitions = Array[ExtResource("7_definition_script")]([ExtResource("2_grass"), ExtResource("3_sand"), ExtResource("4_beach"), ExtResource("5_stream"), ExtResource("20_stream_variant"), ExtResource("6_pond"), ExtResource("9_grass_ocean_edge"), ExtResource("10_grass_beach_transition"), ExtResource("11_grass_ocean_corner"), ExtResource("12_beach_ocean_corner"), ExtResource("17_grass_sand_diagonal"), ExtResource("13_cliff_top"), ExtResource("14_cliff_corner"), ExtResource("15_cliff_edge"), ExtResource("16_cliff_ramp"), ExtResource("18_cliff_sea_edge"), ExtResource("19_cliff_sea_corner"), ExtResource("21_cliff_sea_transition_right"), ExtResource("22_cliff_sea_transition_left"), ExtResource("8_spawn")]) diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 94a04dd..6a4dcd4 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -35,6 +35,7 @@ const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10 const PROP_MINIMUM_GROUND_CLEARANCE := 0.05 const PROP_CHANCE_SCALE := 10000 const PROP_SELECTION_WEIGHT_SCALE := 1000 +const GATHERABLE_ANCHOR_SURFACE_CLEARANCE := 0.02 const PROCEDURAL_PROP_GROUPS: Array[StringName] = [ &"grass_tree", &"grass_detail", @@ -665,10 +666,21 @@ func _instantiate_prop( if definition.gatherable_anchor_height > 0.0: var anchor := Marker3D.new() anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count() - anchor.position = prop.position + Vector3( - 0.0, - definition.gatherable_anchor_height * visual_scale, - 0.0, + var local_anchor_position := ( + definition.collision_offset * visual_scale + + Vector3( + 0.0, + definition.gatherable_anchor_height * visual_scale, + -( + definition.gatherable_anchor_surface_radius() + * visual_scale + + GATHERABLE_ANCHOR_SURFACE_CLEARANCE + ), + ) + ) + anchor.position = prop.position + local_anchor_position.rotated( + Vector3.UP, + yaw, ) _tree_anchors.add_child(anchor) return true diff --git a/world/generation/generated_world_region.tscn b/world/generation/generated_world_region.tscn index 7987172..dec37ae 100644 --- a/world/generation/generated_world_region.tscn +++ b/world/generation/generated_world_region.tscn @@ -36,7 +36,7 @@ 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", "chunk_0005", "chunk_0006", "chunk_0007", "chunk_0008") +required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0003", "chunk_0004") grass_sand_smoothing_enabled = true elevated_cliff_feature_enabled = true required_chunk_weight_multiplier = 64.0 diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index aa5a638..26e9cb0 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -10,7 +10,7 @@ label = "small pine" packed_scene = ExtResource("2_scene") procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") -selection_weight = 0.7 +selection_weight = 1.0 minimum_spawn_chunk_distance = 2 clearance_radius = 1.55 minimum_visual_scale = 0.65 diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres index 05e4d2d..47c8577 100644 --- a/world/generation/props/definitions/prop_pine_large.tres +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -10,7 +10,7 @@ label = "large pine" packed_scene = ExtResource("2_scene") procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") -selection_weight = 3.0 +selection_weight = 1.0 minimum_spawn_chunk_distance = 2 clearance_radius = 1.65 minimum_visual_scale = 0.75 diff --git a/world/generation/props/definitions/prop_tree_large.tres b/world/generation/props/definitions/prop_tree_large.tres index b902944..ebf6f2f 100644 --- a/world/generation/props/definitions/prop_tree_large.tres +++ b/world/generation/props/definitions/prop_tree_large.tres @@ -16,7 +16,7 @@ label = "large deciduous tree" packed_scene = ExtResource("2_scene") procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") -selection_weight = 12.0 +selection_weight = 3.0 minimum_spawn_chunk_distance = 2 clearance_radius = 2.5 minimum_visual_scale = 0.75 diff --git a/world/generation/terrain_chunk_analyzer.gd b/world/generation/terrain_chunk_analyzer.gd index 84895b1..8be5a3d 100644 --- a/world/generation/terrain_chunk_analyzer.gd +++ b/world/generation/terrain_chunk_analyzer.gd @@ -51,7 +51,18 @@ static func create_variants( boundary_points.append_array( _collect_boundary_points( base_layer_mesh.mesh, - _transform_relative_to(base_layer_mesh, base_layer_root), + Transform3D( + Basis( + Vector3.UP, + float(definition.base_layer_quarter_turns) + * PI + * 0.5, + ), + Vector3.ZERO, + ) * _transform_relative_to( + base_layer_mesh, + base_layer_root, + ), chunk_size, edge_epsilon, ) diff --git a/world/generation/terrain_chunk_definition.gd b/world/generation/terrain_chunk_definition.gd index ca38dc3..01e7255 100644 --- a/world/generation/terrain_chunk_definition.gd +++ b/world/generation/terrain_chunk_definition.gd @@ -11,6 +11,10 @@ extends Resource @export_group("Layered Terrain") @export var base_layer_scene: PackedScene @export var base_layer_mesh_name: StringName +## Optional canonical rotation applied to a layered base before the authored +## chunk variant rotation. Coastal transition overlays use this to align one +## shared coastline scene with either downhill side of a corner. +@export_range(0, 3, 1) var base_layer_quarter_turns := 0 ## Overlay-only chunks replace a reserved base-terrain placement after the ## ordinary terrain solve. Inland overlays normally provide base_layer_scene; ## complete coastal overlays can instead carry their own descent to sea level. @@ -72,6 +76,10 @@ var allowed_rotation_mask := 15 ## in this mask must face outside the generated grid; it may never meet another ## terrain chunk. @export_flags("North", "East", "South", "West") var ocean_facing_edges := 0 +## Lateral seams where a deep coastal cliff may join its ordinary inland +## counterpart. Geometry below level-one ground is intentionally ignored on +## these authored edges because the neighboring base terrain buries it. +@export_flags("North", "East", "South", "West") var buried_cliff_seam_edges := 0 @export_flags("North", "East", "South", "West") var water_inlet_edges := 0 @export_flags("North", "East", "South", "West") var water_outlet_edges := 0 ## Optional generated water footprint in the chunk's unrotated local X/Z plane. diff --git a/world/generation/terrain_chunk_edge_profile.gd b/world/generation/terrain_chunk_edge_profile.gd index 20f23bb..fc10266 100644 --- a/world/generation/terrain_chunk_edge_profile.gd +++ b/world/generation/terrain_chunk_edge_profile.gd @@ -18,6 +18,38 @@ func matches(other: TerrainChunkEdgeProfile, tolerance: float) -> bool: return false var first_surface := _upper_surface_points() var second_surface := other._upper_surface_points() + return _surfaces_match(first_surface, second_surface, tolerance) + + +## Compares only the visible raised portion of two edge profiles. Authored +## deep-water cliff pieces continue below ordinary ground level; when their +## lateral end meets an inland cliff, that lower extension is buried by the +## neighboring base terrain and must not create a false seam mismatch. +func matches_above_height( + other: TerrainChunkEdgeProfile, + minimum_height: float, + tolerance: float, +) -> bool: + if other == null: + return false + var first_surface := _points_above_height( + _upper_surface_points(), + minimum_height, + tolerance, + ) + var second_surface := _points_above_height( + other._upper_surface_points(), + minimum_height, + tolerance, + ) + return _surfaces_match(first_surface, second_surface, tolerance) + + +func _surfaces_match( + first_surface: PackedVector2Array, + second_surface: PackedVector2Array, + tolerance: float, +) -> bool: if first_surface.size() < 2 or second_surface.size() < 2: return false if ( @@ -43,6 +75,18 @@ func matches(other: TerrainChunkEdgeProfile, tolerance: float) -> bool: return true +func _points_above_height( + surface: PackedVector2Array, + minimum_height: float, + tolerance: float, +) -> PackedVector2Array: + var result := PackedVector2Array() + for point: Vector2 in surface: + if point.y > minimum_height + tolerance: + result.append(point) + return result + + func _upper_surface_points() -> PackedVector2Array: var result := PackedVector2Array() for point: Vector2 in points: diff --git a/world/generation/terrain_chunk_generator.gd b/world/generation/terrain_chunk_generator.gd index 6555568..c8ab4e2 100644 --- a/world/generation/terrain_chunk_generator.gd +++ b/world/generation/terrain_chunk_generator.gd @@ -7,6 +7,13 @@ const CONSTRAINT_PROFILE_QUANTIZATION := 0.001 const CANDIDATE_WEIGHT_SCALE := 10000.0 const LARGE_GRID_LIGHTWEIGHT_THRESHOLD := 128 const LARGE_GRID_PROPAGATION_INTERVAL := 8 +const ELEVATED_FEATURE_RADIUS := 2 +const ELEVATED_FEATURE_CLEARANCE_RADIUS := ELEVATED_FEATURE_RADIUS + 1 +const ELEVATED_FEATURE_BOUNDARY_MARGIN := ELEVATED_FEATURE_CLEARANCE_RADIUS + 1 +const SECONDARY_ELEVATED_FEATURE_RADIUS := 1 +const SECONDARY_ELEVATED_FEATURE_CLEARANCE_RADIUS := ( + SECONDARY_ELEVATED_FEATURE_RADIUS + 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,7 +40,12 @@ 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_range(0.0, 1.0, 0.05) var elevated_cliff_ramp_chance := 0.75 +@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_coast_base_chunk_id: StringName = &"chunk_0005" +@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. @@ -72,6 +84,7 @@ var _random := RandomNumberGenerator.new() var _generated_chunks: Node3D 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 _stacked_elevated_placements: Array[Dictionary] = [] @@ -186,7 +199,7 @@ func _resolved_layout_validation_error( var current := layout[index] if not _variant_respects_ocean_boundary(current, coordinate): return ( - "cell %d has an ocean-facing edge directed into the terrain grid." + "cell %d does not exactly match the authored ocean boundary." % index ) if coordinate.x + 1 < grid_size.x: @@ -1177,6 +1190,13 @@ func _candidate_can_occupy_cell( if ( _elevated_feature_reserved_grass_indices.has(index) and candidate.definition.stable_id != elevated_cliff_base_chunk_id + and not ( + coordinate == Vector2i(grid_size.x / 2, grid_size.y / 2) + and force_center_chunk_id != &"" + and candidate.definition.stable_id == force_center_chunk_id + and "grass" in candidate.definition.tags + and "flat" in candidate.definition.tags + ) ): return false var center := Vector2i(grid_size.x / 2, grid_size.y / 2) @@ -1195,6 +1215,7 @@ func _candidate_can_occupy_cell( func _prepare_elevated_feature_region() -> bool: _elevated_feature_center = Vector2i(-1, -1) + _secondary_elevated_feature_center = Vector2i(-1, -1) _elevated_feature_reserved_grass_indices.clear() _stacked_elevated_placements.clear() if not elevated_cliff_feature_enabled: @@ -1206,49 +1227,106 @@ func _prepare_elevated_feature_region() -> bool: elevated_cliff_corner_chunk_id, elevated_cliff_edge_chunk_id, elevated_cliff_ramp_chunk_id, + elevated_cliff_sea_edge_chunk_id, + elevated_cliff_sea_corner_chunk_id, + elevated_cliff_sea_transition_right_chunk_id, + elevated_cliff_sea_transition_left_chunk_id, + elevated_cliff_coast_base_chunk_id, ]: if catalog.definition_for_id(stable_id) == null: push_error("Elevated cliff feature references missing chunk %s." % stable_id) return false - if grid_size.x < 11 or grid_size.y < 9: + if grid_size.x < 11 or grid_size.y < 11: push_error( - "Elevated cliff feature requires at least an 11x9 terrain grid." + "Elevated cliff feature requires at least an 11x11 terrain grid." ) return false var forced_center := Vector2i(grid_size.x / 2, grid_size.y / 2) var candidates: Array[Vector2i] = [] - for row: int in range(2, grid_size.y - 2): - for column: int in range(2, grid_size.x - 2): + for row: int in range( + ELEVATED_FEATURE_BOUNDARY_MARGIN, + grid_size.y - ELEVATED_FEATURE_BOUNDARY_MARGIN, + ): + for column: int in range( + ELEVATED_FEATURE_BOUNDARY_MARGIN, + grid_size.x - ELEVATED_FEATURE_BOUNDARY_MARGIN, + ): var candidate := Vector2i(column, row) if ( - absi(candidate.x - forced_center.x) <= 2 - and absi(candidate.y - forced_center.y) <= 2 + absi(candidate.x - forced_center.x) + <= ELEVATED_FEATURE_RADIUS + and absi(candidate.y - forced_center.y) + <= ELEVATED_FEATURE_RADIUS ): continue candidates.append(candidate) if candidates.is_empty(): - push_error("Terrain grid has no inland 3x3 cliff feature location.") + push_error("Terrain grid has no inland 5x5 cliff feature location.") 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 3x3 cliff assembly into a one-cell ring of ordinary - # level-one flat grass. Every raised piece still occupies one of the inner - # nine grass cells; the outer ring prevents a stream, pond, or coast profile - # from being exposed directly against its downhill edge. - for row_offset: int in range(-2, 3): - for column_offset: int in range(-2, 3): - var coordinate := ( - _elevated_feature_center - + Vector2i(column_offset, row_offset) + # 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 secondary_candidates := _coastal_elevated_feature_centers() + var combined_clearance := ( + ELEVATED_FEATURE_CLEARANCE_RADIUS + + SECONDARY_ELEVATED_FEATURE_CLEARANCE_RADIUS + ) + secondary_candidates = secondary_candidates.filter( + func(candidate: Vector2i) -> bool: + var separation := candidate - _elevated_feature_center + return ( + absi(separation.x) > combined_clearance + or absi(separation.y) > combined_clearance ) + ) + 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 + + +func _coastal_elevated_feature_centers() -> Array[Vector2i]: + return [ + Vector2i(1, 1), + Vector2i(grid_size.x - 2, 1), + Vector2i(1, grid_size.y - 2), + Vector2i(grid_size.x - 2, grid_size.y - 2), + ] + + +func _reserve_elevated_feature_base( + center: Vector2i, + clearance_radius: int, +) -> void: + for row_offset: int in range(-clearance_radius, clearance_radius + 1): + for column_offset: int in range( + -clearance_radius, + clearance_radius + 1, + ): + var coordinate := center + Vector2i(column_offset, row_offset) + if ( + not _coordinate_is_inside_grid(coordinate) + or _coordinate_is_on_boundary(coordinate) + ): + continue _elevated_feature_reserved_grass_indices[ coordinate.y * grid_size.x + coordinate.x ] = true - _build_grid_solver_caches() - return true func _build_grid_solver_caches() -> void: @@ -1283,6 +1361,14 @@ func _build_grid_solver_caches() -> void: _elevated_feature_reserved_grass_indices.has(index) and candidate.definition.stable_id != elevated_cliff_base_chunk_id + and not ( + coordinate == center + and force_center_chunk_id != &"" + and candidate.definition.stable_id + == force_center_chunk_id + and "grass" in candidate.definition.tags + and "flat" in candidate.definition.tags + ) ): continue if ( @@ -1402,35 +1488,25 @@ func _apply_elevated_feature() -> bool: if _elevated_feature_center.x < 0 or _elevated_feature_center.y < 0: push_error("Elevated cliff feature has no reserved base region.") return false - var edge_specs: Array[Dictionary] = [ - {"offset": Vector2i(0, -1), "turns": 1}, - {"offset": Vector2i(1, 0), "turns": 0}, - {"offset": Vector2i(0, 1), "turns": 3}, - {"offset": Vector2i(-1, 0), "turns": 2}, - ] var feature_random := RandomNumberGenerator.new() feature_random.seed = generation_seed ^ 0x51A7C11FF - var ramp_edge := -1 - if feature_random.randf() < elevated_cliff_ramp_chance: - ramp_edge = feature_random.randi_range(0, edge_specs.size() - 1) - var placements: Array[Dictionary] = [ - {"offset": Vector2i(-1, -1), "id": elevated_cliff_corner_chunk_id, "turns": 2}, - {"offset": Vector2i(1, -1), "id": elevated_cliff_corner_chunk_id, "turns": 1}, - {"offset": Vector2i(0, 0), "id": elevated_cliff_top_chunk_id, "turns": 0}, - {"offset": Vector2i(-1, 1), "id": elevated_cliff_corner_chunk_id, "turns": 3}, - {"offset": Vector2i(1, 1), "id": elevated_cliff_corner_chunk_id, "turns": 0}, - ] - for edge_index: int in edge_specs.size(): - var edge_spec := edge_specs[edge_index] - placements.append({ - "offset": edge_spec["offset"], - "id": ( - elevated_cliff_ramp_chunk_id - if edge_index == ramp_edge - else elevated_cliff_edge_chunk_id - ), - "turns": edge_spec["turns"], - }) + var placements := _elevated_feature_placement_specs( + _elevated_feature_center, + ELEVATED_FEATURE_RADIUS, + feature_random, + ) + if ( + _secondary_elevated_feature_center.x >= 0 + and int( + _placement_counts.get(elevated_cliff_coast_base_chunk_id, 0) + ) > 0 + ): + placements.append_array( + _coastal_elevated_feature_placement_specs( + _secondary_elevated_feature_center, + feature_random, + ) + ) for spec: Dictionary in placements: var definition := catalog.definition_for_id(spec["id"] as StringName) var variant := _authored_variant(definition, int(spec["turns"])) @@ -1440,7 +1516,7 @@ func _apply_elevated_feature() -> bool: % [spec["id"], spec["turns"]] ) return false - var coordinate := _elevated_feature_center + (spec["offset"] as Vector2i) + var coordinate := spec["coordinate"] as Vector2i _set_placement( coordinate.y * grid_size.x + coordinate.x, variant, @@ -1452,6 +1528,174 @@ func _apply_elevated_feature() -> bool: return true +func _elevated_feature_placement_specs( + center: Vector2i, + radius: int, + feature_random: RandomNumberGenerator, +) -> Array[Dictionary]: + var edge_specs: Array[Dictionary] = [ + { + "edge": TerrainChunkTopology.Edge.NORTH, + "offset": Vector2i(0, -radius), + "turns": 1, + }, + { + "edge": TerrainChunkTopology.Edge.EAST, + "offset": Vector2i(radius, 0), + "turns": 0, + }, + { + "edge": TerrainChunkTopology.Edge.SOUTH, + "offset": Vector2i(0, radius), + "turns": 3, + }, + { + "edge": TerrainChunkTopology.Edge.WEST, + "offset": Vector2i(-radius, 0), + "turns": 2, + }, + ] + var ramp_edge: int = -1 + if 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] + var landing_coordinate := ( + center + + (edge_spec["offset"] as Vector2i) + + TerrainChunkTopology.grid_offset( + edge_spec["edge"] as TerrainChunkTopology.Edge + ) + ) + if ( + _coordinate_is_inside_grid(landing_coordinate) + and not _coordinate_is_on_boundary(landing_coordinate) + ): + usable_edges.append(edge_index) + if not usable_edges.is_empty(): + ramp_edge = usable_edges[ + feature_random.randi_range(0, usable_edges.size() - 1) + ] + var placements: Array[Dictionary] = [] + for row_offset: int in range(-radius, radius + 1): + for column_offset: int in range(-radius, radius + 1): + var offset := Vector2i(column_offset, row_offset) + var on_horizontal_edge := absi(column_offset) == radius + var on_vertical_edge := absi(row_offset) == radius + if on_horizontal_edge and on_vertical_edge: + placements.append({ + "coordinate": center + offset, + "id": elevated_cliff_corner_chunk_id, + "turns": _elevated_corner_turns(offset), + }) + continue + if on_horizontal_edge or on_vertical_edge: + var edge_index := _elevated_edge_index(offset, radius) + placements.append({ + "coordinate": center + offset, + "id": ( + elevated_cliff_ramp_chunk_id + if ramp_edge >= 0 + and offset == edge_specs[ramp_edge]["offset"] + else elevated_cliff_edge_chunk_id + ), + "turns": edge_specs[edge_index]["turns"], + }) + continue + placements.append({ + "coordinate": center + offset, + "id": elevated_cliff_top_chunk_id, + "turns": 0, + }) + return placements + + +func _coastal_elevated_feature_placement_specs( + center: Vector2i, + feature_random: RandomNumberGenerator, +) -> Array[Dictionary]: + var placements := _elevated_feature_placement_specs( + center, + SECONDARY_ELEVATED_FEATURE_RADIUS, + feature_random, + ) + for spec: Dictionary in placements: + var coordinate := spec["coordinate"] as Vector2i + var outside_edge_count := _outside_edge_count(coordinate) + if outside_edge_count == 2: + spec["id"] = elevated_cliff_sea_corner_chunk_id + elif ( + outside_edge_count == 1 + and spec["id"] == elevated_cliff_corner_chunk_id + ): + spec["id"] = _coastal_transition_id( + coordinate, + int(spec["turns"]), + ) + elif outside_edge_count == 1 and ( + spec["id"] == elevated_cliff_edge_chunk_id + or spec["id"] == elevated_cliff_ramp_chunk_id + ): + spec["id"] = elevated_cliff_sea_edge_chunk_id + return placements + + +func _coastal_transition_id( + coordinate: Vector2i, + quarter_turns: int, +) -> StringName: + for stable_id: StringName in [ + elevated_cliff_sea_transition_right_chunk_id, + elevated_cliff_sea_transition_left_chunk_id, + ]: + var definition := catalog.definition_for_id(stable_id) + var variant := _authored_variant(definition, quarter_turns) + if ( + variant != null + and _variant_respects_ocean_boundary(variant, coordinate) + ): + return stable_id + push_error( + "Coastal cliff feature has no transition for %s rotation %d." + % [coordinate, quarter_turns] + ) + return &"" + + +func _outside_edge_count(coordinate: Vector2i) -> int: + var result := 0 + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + if not _coordinate_is_inside_grid(neighbor): + result += 1 + return result + + +func _elevated_edge_index(offset: Vector2i, radius: int) -> int: + if offset.y == -radius: + return 0 + if offset.x == radius: + return 1 + if offset.y == radius: + return 2 + return 3 + + +func _elevated_corner_turns(offset: Vector2i) -> int: + if offset.x < 0 and offset.y < 0: + return 2 + if offset.x > 0 and offset.y < 0: + return 1 + if offset.x < 0 and offset.y > 0: + return 3 + return 0 + + func _configure_stacked_elevated_feature() -> bool: _stacked_elevated_placements.clear() if ( @@ -1459,17 +1703,19 @@ func _configure_stacked_elevated_feature() -> bool: or elevated_cliff_third_level_chance <= 0.0 ): return true - var top_coordinate := Vector2i(-1, -1) - for index: int in _placements.size(): - var placement := _placements[index] - if ( - placement != null - and placement.definition.stable_id == elevated_cliff_top_chunk_id - ): - top_coordinate = Vector2i(index % grid_size.x, index / grid_size.x) - break + var top_coordinate := _elevated_feature_center if top_coordinate.x < 0: return true + var top_index := top_coordinate.y * grid_size.x + top_coordinate.x + if ( + 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 stack_random := RandomNumberGenerator.new() stack_random.seed = generation_seed ^ 0x7312DC11F if stack_random.randf() >= elevated_cliff_third_level_chance: @@ -1515,25 +1761,28 @@ func _variant_respects_ocean_boundary( variant: TerrainChunkVariant, coordinate: Vector2i, ) -> bool: + var ocean_edges := variant.rotated_edge_mask( + variant.definition.ocean_facing_edges + ) if ( variant.definition.must_be_interior and _coordinate_is_on_boundary(coordinate) ): return false - var ocean_edges := variant.rotated_edge_mask( - variant.definition.ocean_facing_edges - ) for edge_value: int in TerrainChunkTopology.Edge.values(): - if (ocean_edges & (1 << edge_value)) == 0: - continue + var edge := edge_value as TerrainChunkTopology.Edge var ocean_coordinate := ( coordinate - + TerrainChunkTopology.grid_offset( - edge_value as TerrainChunkTopology.Edge - ) + + TerrainChunkTopology.grid_offset(edge) ) + var edge_faces_ocean := (ocean_edges & (1 << edge_value)) != 0 if _coordinate_is_inside_grid(ocean_coordinate): - return false + if edge_faces_ocean: + return false + continue + if edge_faces_ocean: + continue + return false return true @@ -1616,10 +1865,28 @@ func _calculate_edge_compatibility( second: TerrainChunkVariant, second_edge: TerrainChunkTopology.Edge, ) -> bool: - if not first.profile(first_edge).matches( + var profiles_match := first.profile(first_edge).matches( second.profile(second_edge), edge_match_tolerance, + ) + if not profiles_match and ( + _variant_edge_has_connector( + first, + first.definition.buried_cliff_seam_edges, + first_edge, + ) + or _variant_edge_has_connector( + second, + second.definition.buried_cliff_seam_edges, + second_edge, + ) ): + profiles_match = first.profile(first_edge).matches_above_height( + second.profile(second_edge), + 0.0, + edge_match_tolerance, + ) + if not profiles_match: return false var first_inlet := _variant_edge_has_connector( first, @@ -2195,6 +2462,9 @@ func _instantiate_chunk_root( var layered_root := Node3D.new() layered_root.name = "LayeredTerrainChunk" base_layer_visual.name = "TerrainBaseLayer" + base_layer_visual.rotation.y = ( + float(definition.base_layer_quarter_turns) * PI * 0.5 + ) terrain_visual.name = "TerrainOverlay" layered_root.add_child(base_layer_visual) layered_root.add_child(terrain_visual) diff --git a/world/generation/terrain_chunk_variant.gd b/world/generation/terrain_chunk_variant.gd index ca45812..f2f9e65 100644 --- a/world/generation/terrain_chunk_variant.gd +++ b/world/generation/terrain_chunk_variant.gd @@ -48,14 +48,18 @@ func topology_signature(quantization: float) -> String: func constraint_signature(quantization: float) -> String: - return "%s|neighbors:%s|surfaces:%s|ocean:%d|in:%d|out:%d" % [ + return ( + "%s|neighbors:%s|surfaces:%s|ocean:%d|buried:%d|in:%d|out:%d" + % [ topology_signature(quantization), _neighbor_rule_signature(), _surface_rule_signature(), rotated_edge_mask(definition.ocean_facing_edges), + rotated_edge_mask(definition.buried_cliff_seam_edges), rotated_edge_mask(definition.water_inlet_edges), rotated_edge_mask(definition.water_outlet_edges), - ] + ] + ) func _neighbor_rule_signature() -> String: diff --git a/world/generation/terrain_prop_catalog.gd b/world/generation/terrain_prop_catalog.gd index 0bc1ca7..f475563 100644 --- a/world/generation/terrain_prop_catalog.gd +++ b/world/generation/terrain_prop_catalog.gd @@ -73,6 +73,14 @@ func validation_errors() -> PackedStringArray: "%s defines more than one collision shape." % definition.stable_id ) + if ( + definition.gatherable_anchor_height > 0.0 + and definition.gatherable_anchor_surface_radius() <= 0.0 + ): + errors.append( + "%s is gatherable but has no trunk-surface radius." + % definition.stable_id + ) if ( definition.collision_box_size.x < 0.0 or definition.collision_box_size.y < 0.0 diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index bfe3677..1479371 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -49,6 +49,9 @@ extends Resource @export var collision_offset := Vector3.ZERO ## Values above zero add this prop to the tree-gathering anchor set. @export_range(0.0, 20.0, 0.05) var gatherable_anchor_height := 0.0 +## Optional distance from the prop origin to the visible trunk surface. A +## zero value derives the distance from the authored collision shape. +@export_range(0.0, 5.0, 0.05) var gatherable_anchor_radius := 0.0 func supports_chunk_tags(chunk_tags: PackedStringArray) -> bool: @@ -74,5 +77,15 @@ func has_box_collision() -> bool: ) +func gatherable_anchor_surface_radius() -> float: + if gatherable_anchor_radius > 0.0: + return gatherable_anchor_radius + if has_cylinder_collision(): + return collision_radius + if has_box_collision(): + return maxf(collision_box_size.x, collision_box_size.z) * 0.5 + return 0.0 + + func is_procedural() -> bool: return procedural_group != &""