In the previous lesson, we set up the layered generation, wrote the drawing code, and generated seeds.
In this part, we’ll build upon that code to generate the planets, moons, travel routes, and asteroids.
In the second layer, we take the data from the previous layer, that is, the three points, and treat them as a triangle. We calculate that triangle’s area, and if it is above our threshold value, we span a planet at the triangle’s epicenter.
The planet’s radius is inversely proportional to the triangle’s area. In other words, the smaller the triangle, the larger the planet.
We introduce a new value, a threshold to decide whether we generate a planet in a given sector. And we add two new utility functions that calculate a triangle’s area and epicenter.
## The maximum area covered by the three seeding vertices for a planet to form. export var planet_generation_area_threshold := 5000.0 # Potentially generate a planet in a given sector. There can only be one planet # in a sector. func _generate_planets_at(sector: Vector2) -> void: if _sectors[sector].planet: return # We calculate the area created by the three seeded points. See the # `_calculate_triangle_area()` function below. var vertices: Array = _sectors[sector].seeds var area := _calculate_triangle_area(vertices[0], vertices[1], vertices[2]) # If the area is less than the generation threshold, we create a planet # based to the seeds' area. # We position the planet at the triangle's epicenter and give it a scale # inversely proportional to the triangle's area. if area < planet_generation_area_threshold: _sectors[sector].planet = { position = _calculate_triangle_epicenter(vertices[0], vertices[1], vertices[2]), scale = 0.5 + area / (planet_generation_area_threshold / 2.0) } ## Returns the area of a triangle. func _calculate_triangle_area(a: Vector2, b: Vector2, c: Vector2) -> float: # The formula to calculate a triangle's area is as follows. We take each # point's `x` position and multiply it by the difference between the other # points' vertical position. Finally, we divide the value by two. return abs(a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2.0 ## Returns the epicenter of a triangle. func _calculate_triangle_epicenter(a: Vector2, b: Vector2, c: Vector2) -> Vector2: # The epicenter of a triangle is the sum of its points' position divided by # three. return (a + b + c) / 3.0
Next, we want to add moons around planets. To do so, we start from the planet’s position and calculate a random offset to place the moon in its orbit. We then roll a dice, that is to say, we call the randf()
function, and we add moons randomly up to a maximum count.
We start by defining two new exported variables that control moon generation and implement _generate_moons_at()
.
## The probability of a moon being generated next to a planet. export var moon_generation_chance := 1.1 / 3.0 ## The maximum number of moons we can generate in one sector. export var max_moon_count := 5 # If there is a planet in a given sector, we may spawn one or more moons around it. func _generate_moons_at(sector: Vector2) -> void: if _sectors[sector].moons != []: return # We check for an existing planet in the sector generated by the previous # layer, and if there is none, there won't be a moon orbiting around it, so # we return from the function. var planet: Dictionary = _sectors[sector].planet if not planet: return _rng.seed = make_seed_for(sector.x, sector.y, "moons") # We keep generating random numbers and moons as long as we fall within the # moon generation chance value. If the random number is above that threshold # or we reach the maximum number of moons, we end the loop. var moon_count := 0 while _rng.randf() < moon_generation_chance or moon_count == max_moon_count: # For each moon, we start from the planet centre, and we generate a # random position along a circle that's larger than the planet. var random_offset: Vector2 = ( Vector2.UP.rotated(_rng.randf_range(-PI, PI)) * planet.scale * PLANET_BASE_SIZE * 3.0 ) moon_count += 1 # The Moon's position is the planet's center with the added offset, and # in this example, we don't randomize the Moon's scale. It is always a # third of the planet's. _sectors[sector].moons.append( {position = planet.position + random_offset, scale = planet.scale / 3.0} )
If two neighboring sectors both have a planet, we want to connect them via a travel route.
To do so, we loop over the neighbors and store dictionaries with source
and destination
keys if we find a planet.
# The fourth layer attempts to connect planets in neighbouring sectors. The # condition for a travel route to form is to have a planet in this sector and in # one of the eight surrounding ones. func _generate_travel_lanes_at(sector: Vector2) -> void: if _sectors[sector].travel_lanes: return # If there is no planet, there is no travel route to generate, so we return. var planet: Dictionary = _sectors[sector].planet if not planet: return # If there is a planet here, we loop over each neighboring sector and create # a travel route that connects this planet's position to the neighboring # one. for neighbor in NEIGHBORS: var neighbor_sector: Vector2 = sector + neighbor if not _sectors[neighbor_sector].planet: continue var neighbor_position: Vector2 = _sectors[neighbor_sector].planet.position _sectors[sector].travel_lanes.append( {source = planet.position, destination = neighbor_position} )
If there is a planet in a sector with no moons or travel routes, we generate an asteroid belt around the planet. To do so, again, we keep rolling dices as long as we don’t have too many asteroids and the generated number is below a threshold.
We place asteroids randomly in a disc around the planet.
This is similar to the _generate_moons_at()
function, only the rules to generate asteroids and some values differ.
## The probability of asteroids being generated next to a planet. export var asteroid_generation_chance := 3.0 / 4.0 ## The maximum number of asteroids we can generate in one sector. export var max_asteroid_count := 10 # The last layer generates asteroids. This one has more conditions than the # previous ones and makes use of multiple layers' data. # # The conditions to spawn an asteroid belt are: # # 1. This sector has a planet. # 2. This sector does not have any moon. # 3. The sector does not have any trade route. The idea is that if it did, # miners and travellers who have already mined and depleted the asteroids. func _generate_asteroids_at(sector: Vector2) -> void: if _sectors[sector].asteroids: return # Here, we check for the presence of a planet and the absence of moons and # travel routes. var planet: Dictionary = _sectors[sector].planet if not planet or _sectors[sector].moons or _sectors[sector].travel_lanes: return _rng.seed = make_seed_for(sector.x, sector.y, "asteroids") # The generation here is similar to the moons, except the values are # different. Instead of placing the asteroids along a circle surrounding the # planet, we scatter them in a larger area. var count := 0 while _rng.randf() < asteroid_generation_chance and count < max_asteroid_count: count += 1 var random_offset: Vector2 = ( Vector2.UP.rotated(_rng.randf_range(-PI, PI)) * planet.scale * PLANET_BASE_SIZE * _rng.randf_range(3.0, 4.0) ) _sectors[sector].asteroids.append( {position = planet.position + random_offset, scale = planet.scale / 5.0} )
At this point, you can test the game and visualize the procedural generation. You should see planets in beige, moons in blue, and asteroids in red.
Now, we have one task left: generating new sectors as the player explores the world.
Our generate()
method doesn’t work like the parent class’s, so we need to also override _update_sectors()
and _update_along_axis()
accordingly. The code’s mostly the same as before, except we don’t generate new sectors in _update_along_axis()
. Instead, we only clear sectors’ outside the player’s range and call generate()
separately to re-generate the world.
Performance-wise, this isn’t the most optimized way of doing things, but this code is in the spirit of keeping the code prototyping-friendly. The way we wrote it, we can freely modify the layers’ functions and update the generation.
# Since our generation algorithm is more complex than the default generation, we # override `_update_along_axis()` to only delete and then call generate again to # fill in any created gaps in the layers. func _update_sectors(difference: Vector2) -> void: _update_along_axis(AXIS_X, difference.x) _update_along_axis(AXIS_Y, difference.y) generate() # As our `generate()` method is different from the parent class, we need to # override the `_update_along_axis()` method as well so it does not directly # generate individual new sectors. # That is because we need to process entire layers at a time. # Most of the code is the same as in the previous lessons, so I will only # comment on the changes. func _update_along_axis(axis: int, difference: float) -> void: if difference == 0 or (axis != AXIS_X and axis != AXIS_Y): return var axis_current := _current_sector.x if axis == AXIS_X else _current_sector.y var other_axis_min := ( (_current_sector.y if axis == AXIS_X else _current_sector.x) - _half_sector_count ) var other_axis_max := ( (_current_sector.y if axis == AXIS_X else _current_sector.x) + _half_sector_count ) var axis_modifier: int = difference <= 0 for sector_index in range(1, abs(difference) + 1): var axis_key := int(axis_current + (_half_sector_count - axis_modifier) * -sign(difference)) for other in range(other_axis_min, other_axis_max): var key := Vector2( axis_key if axis == AXIS_X else other, other if axis == AXIS_X else axis_key ) # As we only store dictionaries representing the world state, # instead of instantiating and nodes, we only have to erase the # corresponding keys in our `_sectors` dictionary. _sectors.erase(key) if axis == AXIS_X: _current_sector.x += difference else: _current_sector.y += difference
Lastly, we add the same code as for the noise-based world generators to update sectors as the player moves around.
onready var _player := $Player # Finally, the physics process code to update the generation as the player moves # is the same as usual. func _physics_process(_delta: float) -> void: var sector_offset := Vector2.ZERO var sector_location := _current_sector * sector_size if _player.global_position.distance_squared_to(sector_location) > _total_sector_count: sector_offset = (_player.global_position - sector_location) / sector_size sector_offset.x = int(sector_offset.x) sector_offset.y = int(sector_offset.y) _update_sectors(sector_offset) _grid_drawer.move_grid_to(_current_sector)
Here’s the complete LayeredWorldGenerator.gd
script, without comments.
class_name LayeredWorldGenerator extends WorldGenerator const PLANET_BASE_SIZE := 96 const MOON_BASE_SIZE := 32 const ASTEROID_BASE_SIZE := 16 const NEIGHBORS := [ Vector2(1, 0), Vector2(1, 1), Vector2(0, 1), Vector2(-1, 1), Vector2(-1, 0), Vector2(-1, 1), Vector2(0, -1), Vector2(1, -1) ] const LAYERS = { seeds = [], planet = {}, moons = [], travel_lanes = [], asteroids = [], } export var show_debug := true setget _set_show_debug export var sector_margin_proportion := 0.1 export var planet_generation_area_threshold := 5000.0 export var moon_generation_chance := 1.1 / 3.0 export var max_moon_count := 5 export var asteroid_generation_chance := 3.0 / 4.0 export var max_asteroid_count := 10 onready var _sector_margin := sector_size * sector_margin_proportion onready var _player := $Player onready var _grid_drawer := $GridDrawer func _ready() -> void: generate() _grid_drawer.setup(sector_size, sector_axis_count) _grid_drawer.visible = show_debug func _physics_process(_delta: float) -> void: var sector_offset := Vector2.ZERO var sector_location := _current_sector * sector_size if _player.global_position.distance_squared_to(sector_location) > _total_sector_count: sector_offset = (_player.global_position - sector_location) / sector_size sector_offset.x = int(sector_offset.x) sector_offset.y = int(sector_offset.y) _update_sectors(sector_offset) _grid_drawer.move_grid_to(_current_sector) func _draw() -> void: for data in _sectors.values(): if show_debug and data.seeds: for point in data.seeds: draw_circle(point, 12, Color(0.5, 0.5, 0.5, 0.5)) if data.planet: draw_circle(data.planet.position, PLANET_BASE_SIZE * data.planet.scale, Color.bisque) for moon in data.moons: draw_circle(moon.position, MOON_BASE_SIZE * moon.scale, Color.aquamarine) for path in data.travel_lanes: var start: Vector2 = path.source var end: Vector2 = path.destination draw_line(start, end, Color.cornflower, 6.0) for asteroid in data.asteroids: draw_circle(asteroid.position, ASTEROID_BASE_SIZE * asteroid.scale, Color.orangered) func generate() -> void: var index := -1 for layer in LAYERS: index += 1 for x in range( _current_sector.x - _half_sector_count + index, _current_sector.x + _half_sector_count - index ): for y in range( _current_sector.y - _half_sector_count + index, _current_sector.y + _half_sector_count - index ): var sector = Vector2(x, y) match layer: "seeds": if not _sectors.has(sector): _sectors[sector] = LAYERS.duplicate(true) _generate_seeds_at(sector) "planet": _generate_planets_at(sector) "moons": _generate_moons_at(sector) "travel_lanes": _generate_travel_lanes_at(sector) "asteroids": _generate_asteroids_at(sector) update() func _update_sectors(difference: Vector2) -> void: _update_along_axis(AXIS_X, difference.x) _update_along_axis(AXIS_Y, difference.y) generate() func _update_along_axis(axis: int, difference: float) -> void: if difference == 0 or (axis != AXIS_X and axis != AXIS_Y): return var axis_current := _current_sector.x if axis == AXIS_X else _current_sector.y var other_axis_min := ( (_current_sector.y if axis == AXIS_X else _current_sector.x) - _half_sector_count ) var other_axis_max := ( (_current_sector.y if axis == AXIS_X else _current_sector.x) + _half_sector_count ) var axis_modifier: int = difference <= 0 for sector_index in range(1, abs(difference) + 1): var axis_key := int(axis_current + (_half_sector_count - axis_modifier) * -sign(difference)) for other in range(other_axis_min, other_axis_max): var key := Vector2( axis_key if axis == AXIS_X else other, other if axis == AXIS_X else axis_key ) _sectors.erase(key) if axis == AXIS_X: _current_sector.x += difference else: _current_sector.y += difference func _generate_seeds_at(sector: Vector2) -> void: if _sectors[sector].seeds: return _rng.seed = make_seed_for(sector.x, sector.y, "seeds") var half_size := Vector2(_half_sector_size, _half_sector_size) var margin := Vector2(_sector_margin, _sector_margin) var top_left := sector * sector_size - half_size + margin var bottom_right := sector * sector_size + half_size - margin var seeds := [] for _i in range(3): var seed_position := Vector2( _rng.randf_range(top_left.x, bottom_right.x), _rng.randf_range(top_left.y, bottom_right.y) ) seeds.append(seed_position) _sectors[sector].seeds = seeds func _generate_planets_at(sector: Vector2) -> void: if _sectors[sector].planet: return var vertices: Array = _sectors[sector].seeds var area := _calculate_triangle_area(vertices[0], vertices[1], vertices[2]) if area < planet_generation_area_threshold: _sectors[sector].planet = { position = _calculate_triangle_epicenter(vertices[0], vertices[1], vertices[2]), scale = 0.5 + area / (planet_generation_area_threshold / 2.0) } func _generate_moons_at(sector: Vector2) -> void: if _sectors[sector].moons != []: return var planet: Dictionary = _sectors[sector].planet if not planet: return _rng.seed = make_seed_for(sector.x, sector.y, "moons") var moon_count := 0 while _rng.randf() < moon_generation_chance or moon_count == max_moon_count: var random_offset: Vector2 = ( Vector2.UP.rotated(_rng.randf_range(-PI, PI)) * planet.scale * PLANET_BASE_SIZE * 3.0 ) moon_count += 1 _sectors[sector].moons.append( {position = planet.position + random_offset, scale = planet.scale / 3.0} ) func _generate_travel_lanes_at(sector: Vector2) -> void: if _sectors[sector].travel_lanes: return var planet: Dictionary = _sectors[sector].planet if not planet: return for neighbor in NEIGHBORS: var neighbor_sector: Vector2 = sector + neighbor if not _sectors[neighbor_sector].planet: continue var neighbor_position: Vector2 = _sectors[neighbor_sector].planet.position _sectors[sector].travel_lanes.append( {source = planet.position, destination = neighbor_position} ) func _generate_asteroids_at(sector: Vector2) -> void: if _sectors[sector].asteroids: return var planet: Dictionary = _sectors[sector].planet if not planet or _sectors[sector].moons or _sectors[sector].travel_lanes: return _rng.seed = make_seed_for(sector.x, sector.y, "asteroids") var count := 0 while _rng.randf() < asteroid_generation_chance and count < max_asteroid_count: count += 1 var random_offset: Vector2 = ( Vector2.UP.rotated(_rng.randf_range(-PI, PI)) * planet.scale * PLANET_BASE_SIZE * _rng.randf_range(3.0, 4.0) ) _sectors[sector].asteroids.append( {position = planet.position + random_offset, scale = planet.scale / 5.0} ) func _calculate_triangle_area(a: Vector2, b: Vector2, c: Vector2) -> float: return abs(a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2.0 func _calculate_triangle_epicenter(a: Vector2, b: Vector2, c: Vector2) -> Vector2: return (a + b + c) / 3.0 func _set_show_debug(value: bool) -> void: show_debug = value if not is_inside_tree(): yield(self, "ready") _grid_drawer.visible = show_debug update()