Generating the remaining layers

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.

Spawning planets based on the seeding points

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

Adding moons around planets

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

Connecting planets with travel routes

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

Generating asteroid belts

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.

Updating generation when the player moves

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)

Code reference

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