Imagine the player’s ship fires a laser beam so powerful it can destroy asteroids or planets. When the player does that, if they later revisit a world sector, you’d want them to find the place the way they left it.
That’s persistence, and that’s what we’ll work on in this lesson.
Not the laser; just the ability to save and restore changes. By the end, you’ll be able to left-click in a sector to add a planet there and right-click in a sector to remove an existing planet from it.
We will store all the modifications in a dictionary. As we already use a dictionary for sectors, it makes sense to do the same here: we can use the same keys to store changes made in a given sector.
However, we need a separate dictionary from the _sectors
variable because the LayeredWorldGenerator
clears it periodically. Unlike it, we need changes to stay in memory.
To get started, duplicate the LayeredWorldGenerator.tscn
scene and name the new one PersistentWorldGenerator.tscn
.
Open it, rename the root node, clear the script on the root node, attach a new one to it, PersistentWorldGenerator.gd
, and open the script editor.
We want the player to add or remove planets using the left and right mouse buttons, respectively. We need a way to map an input to those two actions and a place to store modifications.
We start by defining an enum and a member variable to do so.
## Extends the layered generation algorithm and adds the ability to make ## persistent changes in the world. class_name PersistentWorldGenerator extends LayeredWorldGenerator ## This enum represents the two actions our player has access to. enum Actions { ADD_PLANET, REMOVE_PLANET } ## This dictionary stores all the modifications the player made to the world. ## For each modified sector, it holds a dictionary with the form ## `sector: {action, position (optional)}`. var _modifications := {}
The bulk of our code is handling the player’s input. We want to check for left-click and right-click. We do this in the _unhandled_input()
callback.
func _unhandled_input(event: InputEvent) -> void: # Here, we return early if the event is not a mouse button pressed. This # filters most events we are not interested in, like keyboard key presses. if not event is InputEventMouseButton or not event.pressed: return # We need to know where the player clicks, which sector that corresponds to, # and ensure that they clicked on a visible sector if the camera is zoomed # out. var click_position: Vector2 = get_global_mouse_position() var sector := _find_sector(click_position) # If the sector does not exist, we have nothing to do, so we return early. if not _sectors.has(sector): return # If the player left-clicks on a sector that does not have a planet, we add # an entry to the `modifications` dictionary. if event.button_index == BUTTON_LEFT and not _sectors[sector].planet: if not _modifications.has(sector): _modifications[sector] = {} _modifications[sector].action = Actions.ADD_PLANET _modifications[sector].position = click_position # Every time we make a modification, we regenerate the world. _reset_world() # If the player right-clicks in a sector with a planet, then we register in # the dictionary that we want to remove it. elif event.button_index == BUTTON_RIGHT and _sectors[sector].planet: if not _modifications.has(sector): _modifications[sector] = {} _modifications[sector].action = Actions.REMOVE_PLANET _reset_world()
The code above calls two methods we have yet to define, _reset_world()
and _find_sector()
. We add them next.
# As mentioned above, the `_reset_world()` function clears all the sectors and # regenerates the world from scratch. func _reset_world() -> void: _sectors = {} generate() ## Returns the grid coordinates of the sector given a global position inside ## that sector. func _find_sector(world_position: Vector2) -> Vector2: return Vector2( int((world_position.x - _half_sector_size) / sector_size), int((world_position.y - _half_sector_size) / sector_size) )
The int()
function calls in _find_sector()
remove the decimal part of the calculated numbers. An alternative option is to use Vector2.floor()
instead, but in this case, it does not make the code much shorter or more readable.
Also, note how we reset the world whenever we make a change and regenerate it in _reset_world()
. This is not the most optimized thing to do, but changing one sector can cause changes in the neighbors, propagating to more sectors.
Optimizing that is not trivial and complicates the code. Like before, until the world looks the way you want, we recommend not writing too much optimization code that would make it slower for you to iterate over your algorithm.
When it becomes time to optimize your code, you could consider a flood fill algorithm, which would update sectors starting from a modified one and grow outwards in a loop until changes can’t propagate anymore.
Let’s now apply those modifications to the world. We want to modify the way we generate planets in this script. To do so, we override the _generate_planets_at()
function. As long as we respect the data format we use for planets in the parent class, other layers will generate fine with that change.
If there is no entry in the modifications
dictionary, we generate the planet like before, using the seeds
triangle area.
However, if we find an action
in the modifications
dictionary, we apply it.
# Overrides the parent class's `_generate_planets_at()` method, taking into # account the player's changes. func _generate_planets_at(sector: Vector2) -> void: var seeds: Array = _sectors[sector].seeds var area: float = _calculate_triangle_area(seeds[0], seeds[1], seeds[2]) # Here, we check if our modifications dictionary has an entry for the current sector. If so, it must have an `action` key that we extract into this variable. var action: int = _modifications[sector].action if _modifications.has(sector) else -1 # We first check for the two possible actions added in this script. We either want to add or remove a planet. if action == Actions.ADD_PLANET: # If the action is to add a planet, we add a planet where the player left-clicked in the sector. Here, we don't have special conditions to randomize the scale or ensure the planet is not too close to the sector's borders. _sectors[sector].planet = {position = _modifications[sector].position, scale = 1.0} # We could skip that condition as we initialize all planets to an empty dictionary in the parent class. Still, if the player wanted to remove a planet, this code ensures that we output no planet data for the sector, even if we modify the `LayeredWorldGenerator` script. elif action == Actions.REMOVE_PLANET: _sectors[sector].planet = {} # This code is the same as in the parent class and generates a planet from the `seeds`. elif area < planet_generation_area_threshold: _sectors[sector].planet = { position = _calculate_triangle_epicenter(seeds[0], seeds[1], seeds[2]), scale = 1.0 - area / (planet_generation_area_threshold / 5.0) }
With that, you can use your mouse and click on sectors to add or remove planets. Keep in mind you can only have one planet in a given sector, so clicking in different places inside the same sector won’t add a new planet or even move it until you delete the existing one.
And this brings the series to an end.
Over the past nine lessons, we learned that we could use different noises to achieve varied effects.
We implemented white and blue noise to see how they could lead to different object distributions.
We created a system that allowed us to generate sectors on-the-fly as the player explores the universe.
We extended it by implementing a layer-based generation system and saw how we could make persistent changes to the world.
With that, you should have the foundations you need to build elaborate worlds. As mentioned in the series, there is no silver bullet when it comes to procedural content generation. You want to think about the kind of world you want to create and experiment with algorithms until your code produces the expected result.
You will need to combine algorithms presented in the course and explore beyond them by experimenting directly in Godot or searching for more online.
I hope this series was insightful, and I wish you the best on your journey making procedural games. Below, you’ll find the code reference for this lesson.
Here’s the complete PersistentWorldGenerator.gd
script.
class_name PersistentWorldGenerator extends LayeredWorldGenerator enum Actions { ADD_PLANET, REMOVE_PLANET } var _modifications := {} func _unhandled_input(event: InputEvent) -> void: if not event is InputEventMouseButton or not event.pressed: return var click_position: Vector2 = get_global_mouse_position() var sector := _find_sector(click_position) if not _sectors.has(sector): return if event.button_index == BUTTON_LEFT and not _sectors[sector].planet: if not _modifications.has(sector): _modifications[sector] = {} _modifications[sector].action = Actions.ADD_PLANET _modifications[sector].position = click_position _reset_world() elif event.button_index == BUTTON_RIGHT and _sectors[sector].planet: if not _modifications.has(sector): _modifications[sector] = {} _modifications[sector].action = Actions.REMOVE_PLANET _reset_world() func _reset_world() -> void: _sectors = {} generate() func _find_sector(world_position: Vector2) -> Vector2: return Vector2( int((world_position.x - _half_sector_size) / sector_size), int((world_position.y - _half_sector_size) / sector_size) ) func _generate_planets_at(sector: Vector2) -> void: var seeds: Array = _sectors[sector].seeds var area: float = _calculate_triangle_area(seeds[0], seeds[1], seeds[2]) var action: int = _modifications[sector].action if _modifications.has(sector) else -1 if action == Actions.ADD_PLANET: _sectors[sector].planet = {position = _modifications[sector].position, scale = 1.0} elif action == Actions.REMOVE_PLANET: _sectors[sector].planet = {} elif area < planet_generation_area_threshold: _sectors[sector].planet = { position = _calculate_triangle_epicenter(seeds[0], seeds[1], seeds[2]), scale = 1.0 - area / (planet_generation_area_threshold / 5.0) }