Expand generated terrain and coastal elevations

This commit is contained in:
Alexander Sellite 2026-08-21 21:33:30 -04:00
parent 6d0e996583
commit 339812763d
28 changed files with 906 additions and 171 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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.",
)

View file

@ -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"]

View file

@ -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"]

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")])

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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.

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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 != &""