Its a astar grid with random id connected to other random id.
For every segment I place a random scene on both side of the segment.
I get the aabb and build a polygon for every asset so i can check for collision.
There are 2 types of settlement, villages with only houses, and city with big buildings in the center.
@tool
extends Node3D
@export_category("Skip")
@export var skip = false
# Input
@onready var debug_node = $"../Debug"
@onready var terrain_node = $"../Terrain"
@onready var settlement_node = $"../Settlement"
var terrain_noise : TerrainNoise
var settlements : Dictionary
# Internal
@export_category(" ")
@export var city_size = 2000
@export var village_size = 500
@export var city_street_length = 200
@export var village_street_length = 100 # Target length
@export_range(0.0, 0.49) var jitter = 0.4
@export var road_width = 20.0
#var astar_2d_village = AStar2D.new()
#var astar_2d_city = AStar3D.new()
@export var city_points = 10
@export var village_points = 5
@export var distance_to_generate = 100000
var camera : Camera3D
var last_camera_pos : Vector3
var is_ready = false
var assets = {
"house" = ["res://asset/urban/house_1/house_1.glb", "res://asset/urban/house_2/house_2.glb", "res://asset/urban/house_3/house_3.glb"],
"apartment" = ["res://asset/settlement/soviet_apartment_0.glb", "res://asset/urban/office_tower_1/office_tower_1.glb", "res://asset/urban/warehouse_0/warehouse_0.glb", "res://asset/urban/warehouse_1/warehouse_1.glb"]
}
# Output
@export_category("Output")
@export var settlements_output : Dictionary
func _ready() -> void:
if skip:
push_warning("settlement_building.gd skipped")
return
bootstrap()
func _process(delta: float) -> void:
if not is_ready:
return
if camera.global_position.distance_to(last_camera_pos) > 300:
update_settlements()
func bootstrap():
input()
if Engine.is_editor_hint():
camera = EditorInterface.get_editor_viewport_3d().get_camera_3d()
else:
get_viewport().get_camera_3d()
last_camera_pos = camera.global_position
update_settlements()
is_ready = true
func input():
terrain_noise = terrain_node.terrain_noise
settlements = settlement_node.settlements # WARNING Any change to settlement will also change the value in settlement_node, even with duplicate()
for grid_pos in settlements:
var settlement = settlements[grid_pos]
settlement["generated"] = false
func update_settlements():
for grid_pos in settlements:
var settlement = settlements[grid_pos]
var world_pos_3d = settlement.world_pos_3d
var world_pos_2d = settlement.world_pos_2d
var type = settlement.type
if world_pos_3d.distance_to(camera.global_position) < distance_to_generate and not settlement["generated"] == true:
settlement["generated"] = true
var data = generate_segments(world_pos_2d, type)
var segments = data[0]
var rect = data[1]
place_building(segments, type, rect)
func setup_astar(size: float, street_length : float) -> AStar2D:
var astar = AStar2D.new()
var grid_res = int(size / street_length)
var grid_step = size / float(grid_res)
var point_ids = {}
var id = 0
var center = (grid_res - 1) / 2.0
var max_dist = (grid_res - 1) / 2.0
# Prevent division by zero if grid_res is 1
if max_dist == 0:
max_dist = 1
for y in range(grid_res):
for x in range(grid_res):
# 1. Calculate distance from center (0.0 at center, 1.0 at edge)
var dist_x = abs(x - center)
var dist_y = abs(y - center)
# Use max() for a square falloff (edges are 1.0)
# Use Vector2(dist_x, dist_y).length() for circular falloff (corners are 1.0)
var t = max(dist_x, dist_y) / max_dist
# Clamp to ensure 0.0 - 1.0 range
t = clamp(t, 0.0, 1.0)
# 2. Apply the scale to the jitter
var current_jitter = t * jitter
# 3. Generate position using the calculated jitter
var grid_pos = Vector2(x + 0.5 + randf_range(-current_jitter, current_jitter), y + 0.5 + randf_range(-current_jitter, current_jitter)) * grid_step
astar.add_point(id, grid_pos)
point_ids[Vector2i(x, y)] = id
id += 1
for y in range(grid_res):
for x in range(grid_res):
var current_id = point_ids[Vector2i(x, y)]
for dy in range(-1, 2):
for dx in range(-1, 2):
if not point_ids.has(Vector2i(x + dx, y + dy)):
continue
if dy == 0 and dx == 0:
continue
if abs(dy) == 1 and abs(dx) == 1:
continue
var neighbor_id = point_ids[Vector2i(x + dx, y + dy)]
if not astar.are_points_connected(current_id, neighbor_id):
astar.connect_points(current_id, neighbor_id)
#debug_node.draw_line_2d([astar.get_point_position(current_id), astar.get_point_position(neighbor_id)])
return astar
func generate_segments(world_pos_2d : Vector2, type : String) -> Array:
var size = city_size if type == "city" else village_size
var num_point = city_points if type == "city" else village_points
var street_length = city_street_length if type == "city" else village_street_length
var astar = setup_astar(size, street_length)
var rect = Rect2(Vector2.ZERO, Vector2.ONE * size)
rect.position = world_pos_2d - Vector2.ONE * size / 2
debug_node.draw_rect2(rect)
var all_ids = astar.get_point_ids().duplicate() as Array
all_ids.shuffle()
var selected_ids = []
for i in range(min(num_point, all_ids.size())):
selected_ids.append(all_ids[i])
var all_segments = []
var segments_seen = {}
var ids_seen = {}
for id in selected_ids:
var other_id = -1
var retries = 0
while retries < 10:
other_id = selected_ids.pick_random()
if not other_id == id:
break
if retries >= 10:
printerr("settlement_building.gd: failed to connect point")
continue
if ids_seen.has([other_id, id]):
continue
ids_seen[[other_id, id]] = true
var path = astar.get_point_path(id, other_id)
for i in range(path.size() - 1):
var seg_start = path[i] + rect.position
var seg_end = path[i + 1] + rect.position
if segments_seen.has([seg_start, seg_end]) or segments_seen.has([seg_end, seg_start]):
continue
segments_seen[[seg_start, seg_end]] = true
all_segments.append([seg_start, seg_end])
debug_node.draw_line_2d([seg_start, seg_end])
return [all_segments, rect]
func place_building(segments: Array, type: String, rect: Rect2):
var all_polygons: Array = []
var half_road = road_width / 2.0
var gap_margin = 10.0
# =========================
# PHASE 1: Build ALL road polygons first (so every building checks the entire road network)
# =========================
for segment in segments:
var seg_start: Vector2 = segment[0]
var seg_end: Vector2 = segment[1]
var direction = (seg_end - seg_start).normalized()
var perp_left = Vector2(-direction.y, direction.x)
var perp_right = Vector2(direction.y, -direction.x)
# Road polygon (full width)
var road_polygon = [
seg_start + perp_left * half_road,
seg_start + perp_right * half_road,
seg_end + perp_right * half_road,
seg_end + perp_left * half_road,
]
draw_polygon(road_polygon)
all_polygons.append(road_polygon)
# =========================
# PHASE 2: Place buildings (collision checks now see EVERY road + previously placed buildings)
# =========================
for segment in segments:
var seg_start: Vector2 = segment[0]
var seg_end: Vector2 = segment[1]
var seg_length = seg_start.distance_to(seg_end)
var direction = (seg_end - seg_start).normalized()
var perp_left = Vector2(-direction.y, direction.x)
var perp_right = Vector2(direction.y, -direction.x)
# =========================
# ASSET SELECTION (per segment)
# =========================
var seg_center = seg_start.lerp(seg_end, 0.5)
var distance = seg_center.distance_to(rect.get_center())
var asset_type = "house"
if type == "city" and distance < 500:
asset_type = "apartment"
var array: Array = assets[asset_type]
# =========================
# BOTH SIDES OF ROAD
# =========================
var sides = [perp_left, perp_right]
for side_dir in sides:
var current_dist_along = 0.0
var target_angle = side_dir.angle() + PI / 2.0
while true:
# =========================
# PICK & LOAD ASSET (temp instance only to read mesh/AABB)
# =========================
var asset = array.pick_random()
var resource = load(asset)
var temp_instance = resource.instantiate()
var mesh_instance = temp_instance.get_child(0)
var mesh: Mesh = mesh_instance.mesh
var aabb = mesh.get_aabb()
var scale_factor = 1.5
var building_width = aabb.size.x * scale_factor
var building_depth = aabb.size.z * scale_factor
# Free temp instance immediately (we only needed its mesh data)
temp_instance.queue_free()
# =========================
# SPACE CHECK ALONG SEGMENT
# =========================
var space_needed = (building_width / 2.0) + gap_margin
if (current_dist_along + space_needed) > seg_length:
break
# =========================
# WORLD POSITION
# =========================
var along_offset = direction * (current_dist_along + building_width / 2.0)
var away_offset = side_dir * (half_road + (building_depth / 2.0) + gap_margin)
var world_pos = seg_start + along_offset + away_offset
# =========================
# BUILD ROTATED BUILDING POLYGON
# =========================
var scaled_size = aabb.size * scale_factor
var local_points = [
Vector2(-scaled_size.x / 2, -scaled_size.z / 2),
Vector2(-scaled_size.x / 2, scaled_size.z / 2),
Vector2(scaled_size.x / 2, scaled_size.z / 2),
Vector2(scaled_size.x / 2, -scaled_size.z / 2),
]
var building_polygon: Array = []
for point in local_points:
var rotated = point.rotated(target_angle)
building_polygon.append(Vector2(
world_pos.x + rotated.x,
world_pos.y + rotated.y
))
# =========================
# COLLISION CHECK (now against ALL roads + already placed buildings)
# =========================
var has_collision = false
for existing_poly in all_polygons:
var result = Geometry2D.intersect_polygons(building_polygon, existing_poly)
if result.size() > 0:
has_collision = true
break
if has_collision:
current_dist_along += building_width + gap_margin
continue
# =========================
# PLACE BUILDING
# =========================
var placed_instance = MeshInstance3D.new()
placed_instance.mesh = mesh
placed_instance.position = Vector3(world_pos.x, 0, world_pos.y)
placed_instance.position.y = terrain_noise.get_altitude(placed_instance.position)
placed_instance.rotation.y = -target_angle
add_child(placed_instance)
# Store polygon for future collision checks
all_polygons.append(building_polygon)
# Debug draw
draw_polygon(building_polygon)
# Move forward
current_dist_along += building_width + gap_margin
func draw_polygon(polygon: Array):
if polygon.size() < 2:
return
for i in range(polygon.size()):
var next_i = (i + 1) % polygon.size() # Wrap around to close the loop
debug_node.draw_line_2d([polygon[i], polygon[next_i]])