Prototyping Part 5
Let's take this roguelike idea and run with it. What more do we need to implement in order to have a roguelike prototype?
Procedural map generation is an obvious requirement, though not particularly trivial to implement. The pacing and overall feel of a roguelike game is largely determined by its map generation algorithm. A prototype of such an algorithm might not be too difficult, but a lot of care should go into developing the final product.
We'll need to create a handful of different types of enemies to randomly place across the map if we want the game to have any sort of depth. I'm somewhat worried about how complex we can actually make enemies while still allowing the player to attack them effectively within the constraints of our point-and-click grid movement.
Player progression is a core tenet of roguelikes. Since the player has to start from the beginning each time they play the game, progression often takes the form of collectable or purchaseable items that increase the player's power over the course of a single "run". One obvious choice for our game would be collecting wisps that provide the player with different types of abilities or augmentations. Still, I don't necessarily want the player to defeat the enemies too quickly - I want the game to be about healing! I'd like the player to be able to unlock different healing abilities as they progress through the level, perhaps in the form of a talent tree. Ideally, the player should have some agency over their play style within an otherwise random game.
By the end of this chapter, I want to be able to hand my prototype out to friends and gather some feedback.
12.1 Procedural Generation
Different games utilize procedural generation to different extents. Games like Pokémon Mystery Dungeon generate rooms of random sizes using a grid as a guide, connect up the resulting rooms, and populate the rooms with enemies and items. The Binding of Isaac has a handful of pre-defined rooms, but randomly links them together to form the overall "dungeon". Dwarf Fortress, on the other hand, procedurally generates every single aspect of the entire game - the world, the characters, even the characters' backstories!
Spelunky always uses the same sized grid, but populates cells within the grid with pre-designed shards - though the procedural algorithm may dynamically modify pieces of those shards. Procedurally generated games tend to feel unnatural, since there is no one designing the levels. Spelunky addresses that concern by randomly combining designed chunks into a cohesive level.
Perhaps one of my favorite implementations of procedural generation is Enter the Gungeon. According to the post entitled Dungeon Generation in Enter The Gungeon by Boris the Brave, the developers of the game crafted a handful of graph structures to describe the possible layouts of each level. After randomly choosing a layout, the nodes in the graph are replaced with pre-designed rooms (similar to Spelunky). This combination of techniques allows the game to utilized "designed" elements without being constrained to a grid.
Random Level Design
Dynamically generating levels is no small task. I've been molded by the software industry to follow "agile" practices, which includes breaking down complex tasks into smaller pieces. Programming interviews refer to this as "dynamic programming", but the concept is the same. Let's begin by defining a handful of rooms, and then randomly choosing two of them to construct a "level".
I'll start by creating a new ProceduralPrototypeScene
with a desaturated yellow background (maybe it's a little greenish too). My first instinct is to create a couple of methods to generate the various rooms: generateRoomA()
and generateRoomB()
. These methods simply have a nested for-loop to generate the tiles of the room. They are identical other than the actual dimensions of the room in the loop conditionals. The different methods could contain all sorts of room-specific logic, but right now they are simply empty rooms with nothing special about them. The methods each have an origin
parameter, defining the bottom-left corner of the room. It's up to my scene creation logic to manually define the origin for each room, but there's no current way to keep track of the dimensions of already-existing rooms - already I can see the benefit of creating a Room
abstraction, so that's what I'll do.
The new Room
class just contains _width
and _height
members, and contains accessors for those values, as well as a layout()
method, which generates a quad of the appropriate size and location, given an origin
. I've created RoomA
and RoomB
subclasses, with different dimensions. The scene itself can now construct and layout the rooms by utilizing the exposed width and height accessors. It's not randomized by any means, but we're getting somewhere.
A few of things I notice about the scene's logic to layout the rooms relative to one another:
- Since each room requires an
origin
, it's necessary to keep track of a global origin value, and increment it using each room's width or height (depending on where you want the next room to be positioned). The first room can be arbitrarily placed, either at{ 0.0, 0.0 }
, or at{ -width / 2.0, -height / 2.0 }
(if you want the room to be centered at the origin, which is also an arbitrary decision). - Incrementing both the X and Y values of the origin results in the next room being positioned diagonally, relative to the current room. This is generally not desirable, but good to keep in mind. It seems obvious, but I'm just writing down my observations.
- Since the rooms use the origin as their bottom-left corners, the rooms aren't centered relative to one another. Instead, they are bottom/left-aligned.
- Regardless of the alignment, the rooms are still positioned in a grid, and I don't really like that.
- Even if I did like the grid layout, connecting the rooms with corridors would also be somewhat arbitrary. Due to the inconsistent sizing of the rooms, the easiest way to connect them would be to center the corridor according to the center of the smaller room. This would result in a constraint for all rooms to have odd numbered dimensions, since a corridor being placed between two tiles would break our grid-based path-finding.
In the description of Enter the Gungeon's dungeon generation techniques, Boris mentions that rooms are aligned relative to the doors within each room. Each room contains a handful of possible door locations, and doors are chosen somewhat randomly, favoring combinations of north/south or east/west. Using this type of alignment system fixes several of the above issues:
- Rooms will always be aligned to the path-finding grid, since door tiles will align with one another.
- Rooms are not necessarily aligned in a grid.
- Rooms are no longer center-aligned or bottom/left-aligned to one another. They are simply aligned relative to the doors which were chosen.
I'll add a std::vector<glm::ivec2>
named _doors
to the Room
, and change the constructors of the superclasses to provide the valid door locations for each room. I'll also update the layout()
method to draw lighter gray quads where the doors should be.
Assuming I can select two "compatible" doors (east/west or north/south combinations), I can align the doors by adding the position of door A to the origin, and subtracting the position of door B from the origin. Calling layout()
on room B with that origin will result in the rooms being aligned relative to the chosen doors.
Selecting which doors to align could be tricky, depending on how flexible we are in the number of possible doors each room can have. For now, let's just constrain it to one door per cardinal direction, and that should make things significantly easier. Assuming we always start with room A, we can simply generate a random number between 0 and 3 (inclusive) and choose a cardinal direction based on the result. From there, it's just a matter of aligning the door of the opposite direction from room B.
I'll add methods to retrieve the door positions for each cardinal direction to the Room
class, and update the subclasses to define door locations for each direction in their constructors. I've added a RoomC
class with different dimensions and door positions, just for a little extra variety. In the scene, I'll first generate two random numbers between 0 and 2 (inclusive) to choose which rooms should be constructed. Finally, I can lay the rooms out as I described above.
linguine/src/scenes/ProceduralPrototypeScene.h snippet
auto rand = std::random_device();
auto randomRoom = std::uniform_int_distribution(0, 2);
std::unique_ptr<Room> room1;
switch (randomRoom(rand)) {
case 0:
room1 = std::make_unique<RoomA>();
break;
case 1:
room1 = std::make_unique<RoomB>();
break;
case 2:
room1 = std::make_unique<RoomC>();
break;
default:
throw std::runtime_error("Unexpected RNG value");
}
std::unique_ptr<Room> room2;
switch (randomRoom(rand)) {
case 0:
room2 = std::make_unique<RoomA>();
break;
case 1:
room2 = std::make_unique<RoomB>();
break;
case 2:
room2 = std::make_unique<RoomC>();
break;
default:
throw std::runtime_error("Unexpected RNG value");
}
auto randomDirection = std::uniform_int_distribution(0, 3);
auto origin = glm::vec2(-room1->getWidth() / 2.0f, -room1->getHeight() / 2.0f);
room1->layout(getEntityManager(), serviceLocator, origin);
auto direction = randomDirection(rand);
switch (direction) {
case 0:
origin += room1->getNorthDoor();
origin -= room2->getSouthDoor();
break;
case 1:
origin += room1->getSouthDoor();
origin -= room2->getNorthDoor();
break;
case 2:
origin += room1->getEastDoor();
origin -= room2->getWestDoor();
break;
case 3:
origin += room1->getWestDoor();
origin -= room2->getEastDoor();
break;
default:
throw std::runtime_error("Unexpected RNG value");
}
room2->layout(getEntityManager(), serviceLocator, origin);
It's not pretty, but it works! Each time the game starts up, it selects two random rooms, centers the first one, and aligns the second one based on the randomly selected doors.
Integrating the Grid
The rooms that we randomly select are currently just visual entities, which does us no good since the player can't actually interact with them. Since the player's character relies on path finding for its movement, we'll need to integrate our path finding logic with the generated rooms.
Unfortunately, building this prototype must be boring to me, because I am struggling to make myself work on it. I'm excited by the actual idea, but implementing it just isn't an enticing challenge. Many of my projects have fizzled out due to my own boredom - I don't want this one to be added to that list.
If we were to only display a single room at a time, then each room could contain its own grid, and the player character would effectively be "teleported" between the grids when a new room is displayed. Alternatively, we could continue to display all rooms, but we would have to modify our "neighbor" determination logic to support the idea of separate grids for each room - using a grid of grids, perhaps.
On second thought, we could just treat our level as one giant grid, placing rooms in valid locations within that grid. The room layout logic would create obstructions in the grid, and each subsequent room would have to check the grid for obstructions in order to be considered valid. Furthermore, doors don't necessarily have to be aligned with one another - we could use the grid's path finding algorithm to construct corridors between the rooms as necessary. This technique would prevent rooms from overlapping, and greatly simplify the logic for character movement. As long as the rooms are enclosed by walls (obstacles on the grid), we don't have to worry about the characters navigating into the unoccupied void between the rooms.
I won't bore you with the details of the code, because it's nothing I haven't already demonstrated in previous chapters. The scene contains a 50x50 grid, which gets passed by reference into the Room
's layout()
method so that walls can be registered as obstacles. I skip creating a wall tile on cells that are designated as a door. Since no obstacle is created for those tiles, the path finding algorithm is free to navigate through the doors. From there, I've copied the player character composition code from the PointAndClickPrototypeScene
, and added the various systems that are required for character movement.
Following the Player Character
It's not particularly desireable to fit the entire level on the screen at any given time, but without doing so, the player is limited in how far they are allowed to explore. This is easily resolvable by moving the camera to follow the player character around as it moves.
The easiest way to achieve this is to simply adjust the Transform
of the camera entity to match the X and Y coordinates of the player character. While effective, it can lead to somewhat jerky movement, depending on the mechanics of the game. I'm going to use a method described as "asymptotic averaging", described in the "Smooth Motion" section of this GDC talk entitled _Math for Game Programmers: Juicing Your Cameras With Math by Squirrel Eiserloh. The basic idea is that, each frame, we will move the camera a percentage closer to the player character's position. Because we have a variable render frame rate, we'll do this in our fixed time step, so that fluctuations in frame rates won't actually change the rate at which the camera moves.
linguine/src/systems/CameraFollowSystem.cpp
#include "CameraFollowSystem.h"
#include "components/CameraFixture.h"
#include "components/PhysicalState.h"
#include "components/Player.h"
namespace linguine {
void CameraFollowSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<Player, PhysicalState>()->each([this](const Entity& playerEntity) {
auto playerState = playerEntity.get<PhysicalState>();
findEntities<CameraFixture, PhysicalState>()->each([playerState](const Entity& cameraEntity) {
auto cameraState = cameraEntity.get<PhysicalState>();
cameraState->currentPosition += (playerState->currentPosition - cameraState->currentPosition) * 0.1f;
});
});
}
} // namespace linguine
With the camera now following the player character, we can zoom in so that the player can't see the entire level, providing a sense of mystery and exploration.
Generating More Rooms
In theory, I should be able to keep track of the origin of the last placed room, and place as many rooms as I'd like in a loop. Of course, sometimes the algorithm might choose a direction where a room already exists, in which case, we should abandon placing the new room and try again.
Writing the loop is somewhat easy, so I won't go into too much detail there. Determining the failure condition for placing a new room is trickier than I anticipated. At first, I thought that I could just check to see if there were any walls in any of the tiles by simply checking for obstructions. This technique unfortunately leads to edge cases in which a smaller room can safely be placed within the bounds of a larger room, or the same room might be placed on top of a pre-existing version of itself.
Unfortunately this means we have to check all of the tiles in the proposed area. Rather than modifying our Grid
to have knowledge of our room generation process, I'll simply create a new RoomLayout
class responsible for keeping track of existing room tiles. This new class doesn't need any of the complex path finding logic that the Grid
is responsible for. Within it, I'll have a two-dimensional std::vector
of bool
values, each representing whether the cell is already occupied or not. I'll create two methods: an add()
method, which takes in a room's location and dimensions, in order to update the cells for that room; and an isOccupied()
method, which simply returns the status of a requested cell.
Back within the Room
's layout()
method, I'll add a parameter to receive the current RoomLayout
and check to see if the position for the room is valid. If it is, I'll add the current room to the RoomLayout
and return true, otherwise I'll return false.
In the map generation code, within the loop responsible for generating new rooms, I'll attempt to call the layout()
method on the randomized room. If it succeeds, I'll update the origin and a pointer to the previous room for the next iteration of the loop. If it does not succeed, then I won't update any variables, and the next iteration of the loop will simply try again.
I've also added a new SpawnRoom
, which is a small room that will always be created first, which the player will start in.
linguine/src/scenes/ProceduralPrototypeScene.h snippet
auto roomLayout = RoomLayout();
std::unique_ptr<Room> previousRoom = std::make_unique<SpawnRoom>();
auto origin = glm::ivec2(250, 250);
previousRoom->layout(getEntityManager(), serviceLocator, *_grid, roomLayout, origin);
auto playerStartPosition = origin + glm::ivec2(previousRoom->getWidth(), previousRoom->getHeight()) / 2;
auto rand = std::random_device();
auto randomRoom = std::uniform_int_distribution(0, 2);
auto randomDirection = std::uniform_int_distribution(0, 3);
auto roomCount = 0;
do {
std::unique_ptr<Room> currentRoom;
switch (randomRoom(rand)) {
case 0:
currentRoom = std::make_unique<RoomA>();
break;
case 1:
currentRoom = std::make_unique<RoomB>();
break;
case 2:
currentRoom = std::make_unique<RoomC>();
break;
default:
throw std::runtime_error("Unexpected RNG value");
}
auto newOrigin = origin;
switch (randomDirection(rand)) {
case 0:
newOrigin += previousRoom->getNorthDoor();
newOrigin -= currentRoom->getSouthDoor();
newOrigin += glm::ivec2(0, 1);
break;
case 1:
newOrigin += previousRoom->getSouthDoor();
newOrigin -= currentRoom->getNorthDoor();
newOrigin -= glm::ivec2(0, 1);
break;
case 2:
newOrigin += previousRoom->getEastDoor();
newOrigin -= currentRoom->getWestDoor();
newOrigin += glm::ivec2(1, 0);
break;
case 3:
newOrigin += previousRoom->getWestDoor();
newOrigin -= currentRoom->getEastDoor();
newOrigin -= glm::ivec2(1, 0);
break;
default:
throw std::runtime_error("Unexpected RNG value");
}
if (currentRoom->layout(getEntityManager(), serviceLocator, *_grid, roomLayout, newOrigin)) {
previousRoom = std::move(currentRoom);
origin = newOrigin;
++roomCount;
}
} while (roomCount < 10);
There's one major "bug" with the current level generation code. Any doors that don't lead to an adjacent room do not generate an obstacle in the Grid
, so the path finding algorithm will sometimes route through them to more quickly access another room. We need to basically close all of the doors that we don't end up using!
To achieve this, I decided to make a new enclose()
method in the Room
class, which generates the walls around the room, and block off any doors that aren't adjacent to another room's door. This method gets called on the previousRoom
, only after the currentRoom
has successfully called layout()
. In order to enclose the final generated room, we'll have to call enclose()
one last time outside of the loop.
I've adjusted a couple of other variables in order to make things slightly more aesthetically pleasing to me. They aren't particularly important, but I felt the need to mention that for some reason. I've stress-tested the generator with up to 100 rooms, but I'll leave it at 16 rooms for now. Interestingly, it's mildly satisfying to just traverse the randomly generated level. I wouldn't call it "fun", but it's hard to close the game without first reaching the end, so that must count for something, right?
12.2 Revamping Combat... Again
With a larger level to explore, I can't help but feel like adding enemies with our current combat system would actually take away from the experience, rather than complement it. I don't hate the healing aspect of it, but the automatic targeting and close range combat feels clunky and unsatisfying.
I've had quite a bit of time to ponder think things over, since I've been making painfully slow progress on this prototype. I think a lot of the clunkiness comes from mixing grid-constrained movement with close quarters combat. In most video games, short ranged attacks are often reserved for characters that are the most agile. I mentioned in the previous chapter that it felt natural to avoid taking incoming damage if possible. Rather than fix the problem, I just made the enemies die quicker so the player didn't have much time to think about it. I've been thinking about other ways to tackle that problem, and the best idea I've come up with so far is to simply make the damage unavoidable. Establishing that pattern early in the game will allow the player to focus on the real challenge of healing.
Spawning Enemies
Before we can actually revisit the combat system, we'll first need to spawn enemies into our randomly generated level. As with all of this procedural generation mumbo jumbo, there are a few different ways we can go about it. Since our rooms are predefined, we could simply add specific spawn points to those rooms as well. Of course, not everything has to be predetermined - we could also randomly generate a number of enemies to spawn per room, and randomly generate the tiles on which they should be spawned. Let's go with the latter idea and see what we can whip up.
Doing so isn't particularly difficult with our vague idea of enemies. For now, I haven't added any actual behavior to the enemies - they simply occupy a space within the room that they were spawned within. If the randomly generated space is already occupied, the loop will just try again. I've intentionally avoided placing enemies on the outside edges of the room, mostly to prevent the doors from being obstructed.
linguine/src/data/rooms/Room.h snippet
auto rand = std::random_device();
auto randomEnemyCount = std::uniform_int_distribution(1, 4);
auto randomPositionX = std::uniform_int_distribution(1, getWidth() - 2);
auto randomPositionY = std::uniform_int_distribution(1, getHeight() - 2);
auto enemyCount = randomEnemyCount(rand);
while (enemyCount > 0) {
auto x = randomPositionX(rand);
auto y = randomPositionY(rand);
auto gridPosition = origin + glm::ivec2(x, y);
if (!grid.hasObstruction(gridPosition)) {
auto enemyEntity = entityManager.create();
auto transform = enemyEntity->add<Transform>();
transform->scale = glm::vec3(0.9f);
transform->position = glm::vec3(grid.getWorldPosition(gridPosition), 1.0f);
auto physicalState = enemyEntity->add<PhysicalState>();
physicalState->previousPosition = glm::vec2(transform->position);
physicalState->currentPosition = physicalState->previousPosition;
auto gridPositionComponent = enemyEntity->add<GridPosition>();
gridPositionComponent->position = gridPosition;
gridPositionComponent->speed = 1.5f;
grid.addObstruction(gridPositionComponent->position, gridPositionComponent->dimensions);
auto drawable = enemyEntity->add<Drawable>();
drawable->feature = new ColoredFeature();
drawable->feature->meshType = Triangle;
drawable->feature->color = glm::vec3(1.0f, 0.0f, 0.0f);
drawable->renderable = renderer.create(std::unique_ptr<ColoredFeature>(drawable->feature));
drawable.setRemovalListener([drawable](const Entity e) {
drawable->renderable->destroy();
});
--enemyCount;
}
}
This mostly works, but there are several enemies inside of the SpawnRoom
, and sometimes they even spawn directly under the player. I'll add a new _hasEnemies
flag to the Room
constructor, and wrap the enemy generation logic in a conditional check. The SpawnRoom
will just set that flag to false
so that it's always empty. While I'm fiddling with the rooms, I'll go ahead and increase the size of them all slightly, to make room for the upcoming combat changes.
Unavoidable Attacks
Many roguelikes utilize precision aiming and evasive movement as their core mechanics. The player is encouraged to avoid taking damage while maximizing the amount of damage that they deal to the enemies. Makes perfect sense for those games. The core mechanics of our game, however, do not include aiming or shooting - those things should happen somewhat automatically, since the wisps provide those attacking capabilities, as long as the player has selected a target. Meanwhile, the enemies will be shooting at the player, with the wisps taking the damage, requiring the player to heal them. The enemy's projectiles should be unavoidable - if you move, they follow you like a heat-seeking missile. This is very similar to how ranged attacks work in World of Warcraft: you select your target, cast your spell, and the spell travels toward that target, even if it moves.
Currently, our Projectile
component contains a velocity
, which is a measure of speed and direction. The direction is irrelevant for this new system - the direction is always toward the target! We'll replace the velocity
with separate speed
and target
fields.
Similar to our MeleeAttack
component from before, I'll create a ProjectileAttack
component for entities that can fire projectiles. This component will contain the speed and power of any resulting projectiles, as well as the frequency between firing them, and an elapsed
time accumulator.
The EnemyAttackSystem
obviously needs to be updated to shoot projectiles. I basically just copied the code from the ProjectileFactory
, but I had to fix the factory to set the speed
instead of velocity
just to get the program to compile. I didn't actually end up using the factory at all.
The ProjectileSystem
had to be updated to "follow" the projectile's target, rather than using dead reckoning. This is just a matter of moving the position in the direction of the target on each frame. In the event the target no longer exists, I just destroy the projectile entity.
The ProjectileSpawnSystem
also had to be updated for the velocity
to speed
change, though I didn't actually include this system in the scene.
Perhaps the most impactful change that I've made was adding range
fields to the Targeting
and ProjectileAttack
components. I've updated the EnemyTargetingSystem
to only select a target if it is in range, and to deselect the target when it moves out of range. Similarly, I've updated the EnemyAttackSystem
to prevent firing a projectile if the target is not within range. These combined changes result in enemies that slowly move toward you, but only when they have noticed you. They also don't begin shooting at you until they are within attack range, even if they are actively moving toward you.
It's actually pretty entertaining, trying to outmaneuver the enemies and make your way through the doorways to the end of the dungeon. More often than not, I make my way into a doorway, only to get surrounded on both sides by enemies, trapping me in place. Since I don't currently have any way to defeat them (or take any damage from their projectiles), I'm just stuck indefinitely until I close the game.
HUD Rendering
Since our camera now moves around the world, it is no longer sufficient to place our HUD elements (such as the health of our wisps) at a static world location. Instead, we need a way to render these elements at a static location on the screen, regardless of the camera's position.
Ultimately, the reason that our existing render features won't work for UI elements is the use of the camera's view matrix, which changes based on the camera's position in the world. Our projection matrix is perfectly suited for rendering UI elements, but that is only the case because of our 2-dimensional world view. If we were rendering a 3-dimensional world with a perspective projection (rather than our current orthogonal projection), then we would likely want a separate projection matrix entirely for the UI elements.
We don't actually want to have to refactor our render features into a duplicate set of UI-specific features. Indeed, all we really want is to be able to render the world using multiple cameras at a time - one which can only see the "world", and another that can only see the UI!
To achieve this, I've created a new Layer
enum, which currently only contains World
and UI
values. I've added a _layer
field to the Renderable
class, and updated the Renderer
's create()
method to allow passing in a layer, but default to World
if one is not passed in. Similarly, I've added a layer
field to the Camera
struct.
In order to support multiple cameras, I've removed the getCamera()
method from the Renderer
, and replaced it with a new createCamera()
method, which adds a new camera to an internal std::vector
and returns its pointer to the caller.
Updating the backend MetalRenderer
was more work than I had anticipated. I updated the doDraw()
method to first iterate over each camera, and then call draw()
on each feature. In order to provide the specific camera which should be used, I updated the draw()
method of the feature renderers to take in a reference to the camera. Finally, I filtered the entities to be drawn based on the current camera's layer. This is where things got tricky.
The uniform buffers used by the feature renderers were being used by both cameras, leading to a ton of flickering on the screen as the buffers were overwritten while the GPU was rendering using their data. Recall that there are uniform buffers containing the view-projection matrix, which effectively represents the camera's position and viewport details. This means the perspective of the screen was rapidly switching between the view of the world and the view of the UI - not a pleasant experience! The solution is pretty simple: just create separate uniform buffers for each camera. To do this effectively, I added an ID to each camera so that the uniform buffers can be reused correctly. Since I don't want the ID to be mutable, I converted the Camera
from a struct to a class, and added the appropriate constructor and getter. Unfortunately, this wasn't my only issue.
Render passes are somewhat annoying to manage. A new render pass is created each time you create a new MTL::RenderCommandEncoder
, using the provided MTL::RenderPassDescriptor
. The descriptor has to define what to do with each "attachment" it utilizes: either clear it with a provided color, or load it in whatever state it was previously. Last time we were in this code, I decided to only clear it at the beginning of doDraw()
, and load it for every subsequent pass. This works perfectly fine for one camera, but technically the clear color is a parameter of the Camera
, so I was respecting the clear color for each one. In my case, this means I was never able to see anything rendered to the World
layer, because the color attachment was cleared between rendering the World
and UI
layers. I've updated the clear color of the Camera
class to be optional. If it's not set, then we configure the descriptor to simply load the attachment instead of clearing it.
At this point, everything looked fine, but selecting a tile for the player character to move toward was buggy - sometimes it worked, other times it didn't. This seemed an awful lot like the texture used for selection was "flickering". The reason was because the SelectableFeatureRenderer
actually manages its own render pass, and it was getting cleared between each iteration of the camera loop. I did something bad here, but I don't think I care enough to fix it: I configure the render pass descriptor to clear if the camera's ID is equal to 0. That should work, but I could definitely foresee a future where I screw this up. I'll just fix it if and when I get to that point.
Finally, I can add a UI background at the bottom screen. I'll tweak the CameraFollowSystem
a bit so that the player character remains centered in the portion of the screen which the UI does not obstruct. I've set the height of the UI camera to 1.0f
so that I can treat the scale of each quad as a percentage of the screen. The UI background is scaled and positioned to cover the bottom 30% of the screen, which means the remaining 70% of the screen will display a view into the world. Since the height of the world camera is 20.0f
, I'll need to adjust the CameraFollowSystem
's Y axis by 3.0f
to keep the player character centered within that view.
Healing from the HUD
I copied the code to create the health bars from the PointAndClickPrototypeScene
, but I omitted the creation of the actual orbitals. I don't think they serve any purpose other than to provide visual clutter of the prototype, though I might change my mind later. I just had to adjust the scale and position of the health bars to make them suitable for the size of our UI layer.
Of course, there's not much to heal since the projectiles aren't currently inflicting any damage to us. I copied the code to deal damage to the orbitals from the EnemyAttackSystem
and pasted it into the ProjectileSystem
. I modified the code a bit so that it only does damage to a random Friendly
unit if the target was also Friendly
, otherwise it deals the damage to the target itself. This should allow the player character to shoot projectiles back at the enemies.
Other than that, it was just a matter of adding the appropriate systems to the scene, copying over the GCD and "big heal" entities, and adjusting their scales and positions to fit on the new HUD.
Targeting Enemies
Previously, targeting was an automatic process for the player, and actually used a lot of the same code as the enemy AI. I'd like the player to have a bit more control, but not be completely out of luck if they are spending all their time focusing on healing. I'd like the player to be able to choose a target manually, so they can switch to a target which they consider a priority. However, if the player doesn't currently have a target, I think we should automatically target the first enemy that attacks the player.
Way back in chapter 4, we demonstrated the ability to rotate objects by manipulating the model matrix. In chapter 5, we created the RotatorSystem
to rotate many objects using an ECS query. In chapter 7, we adjusted the system to reverse rotations by utilizing user inputs. Finally, in chapter 9, we adjusted the system to utilize the fixed time step for its rotational adjustments. This simple system has been very handy along our journey, and now I'd like to utilize it now to signify which enemy the player is currently targeting.
Rather than reusing the Targeting
component (which contains different auto-targeting strategies), I've instead created a new PlayerTarget
component, which simply contains an optional target entity ID. The new component won't actually be added to the player entity - it will instead be added to a new entity, whose Transform
will move along with the actual target entity. I've completely rewritten the PlayerTargetingSystem
to switch the PlayerTarget
's entity ID whenever a Hostile
entity is Tapped
, disable the renderable when there is no target selected, and update the Transform
so that it moves along with the actual target.
linguine/src/systems/PlayerTargetingSystem.cpp
#include "PlayerTargetingSystem.h"
#include "components/Alive.h"
#include "components/Drawable.h"
#include "components/Hostile.h"
#include "components/PlayerTarget.h"
#include "components/Tapped.h"
#include "components/Transform.h"
namespace linguine {
void PlayerTargetingSystem::update(float deltaTime) {
findEntities<Hostile, Tapped>()->each([this](const Entity& hostileEntity) {
findEntities<PlayerTarget>()->each([&hostileEntity](const Entity& playerTargetEntity) {
auto playerTarget = playerTargetEntity.get<PlayerTarget>();
playerTarget->entityId = hostileEntity.getId();
});
});
findEntities<PlayerTarget, Drawable, Transform>()->each([this](const Entity& entity) {
auto playerTarget = entity.get<PlayerTarget>();
if (playerTarget->entityId) {
auto target = getEntityById(*playerTarget->entityId);
if (!target->has<Alive>()) {
playerTarget->entityId = {};
}
auto hasTarget = playerTarget->entityId.has_value();
auto drawable = entity.get<Drawable>();
drawable->renderable->setEnabled(hasTarget);
if (hasTarget) {
auto transform = entity.get<Transform>();
auto targetTransform = target->get<Transform>();
transform->position.x = targetTransform->position.x;
transform->position.y = targetTransform->position.y;
}
}
});
}
} // namespace linguine
I reverted the RotatorSystem
to a previous form, before it handled physics, input events, or played audio. Instead, I just want it to rotate an entity's Transform
according to the defined speed. I've added the RotatorSystem
to the scene, and added the Rotator
component to the new entity with a speed of 0.5f
.
In order for all of this to work, I also had to add the Selectable
render feature to the enemies, so that the player can actually select them. After doing so, the little spinning quad shows up on top of whichever enemy I tap on.
Manually selecting a target is the easy part, since there are well-defined components in place for us to hook into. However, if the player does not currently have a target, I'd like the game to automatically target the first enemy to attack the player. The "right" way to do this would be to have some sort of combat logging subsystem which reported all combat events, along with their originating actors and targets. Since the only attack that this prototype actually uses is projectiles, I'm just going to hard code this little detail directly into the ProjectileSystem
. I had to add an actor
field to the Projectile
component, and set it to the originating enemy's ID inside the EnemyAttackSystem
. After that, it's just a matter up setting the PlayerTarget
's entityId
if one doesn't exist yet.
linguine/src/systems/ProjectileSystem.cpp snippet
findEntities<PlayerTarget>()->each([&projectile](const Entity& playerTargetEntity) {
auto playerTarget = playerTargetEntity.get<PlayerTarget>();
if (!playerTarget->entityId) {
playerTarget->entityId = projectile->actor;
}
});
Automatic Attacks
The types of attacks that the player performs should be based on which types of wisps are currently in their party, but we don't currently have any concept of different "types" of wisps. Actually, we don't really have the concept of a "wisp" at all anymore, since I got rid of the orbital entities. In any case, the player's loadout should determine the types of attacks that are automatically performed on the player's behalf. I don't want to get into too much detail about the different types of attacks yet - I just want to have a single attack that is replicated 5 times (one for each of our equipped... let's call them "items" from now on).
The attack that I want to duplicate is, naturally, a simple projectile that gets fired at the current target at an interval, just like how our enemies currently work. The problem is that I can't just fire 5 projectiles at the same target simultaneously. I mean, I could, but they would follow the same path and be visually indistinguishable from one another. I also can't just batch them together into "bursts", since each item might shoot projectiles at a different rate.
The first solution that comes to mind is some sort of spell queueing system. Rather than a system that shoots all the projectiles at once, we could have a system that enqueues requests to shoot each projectile. Each spell in the queue will be executed, and the timer for that spell will begin only after it has been executed. There will be a delay between the executions of the spells in the queue, so that spells are reasonably spaced apart. This solution is fragile, because it could lead to a situation in which the queue is being filled faster than it can be drained.
In the last chapter, I mentioned that each equipped item (I was calling them "orbiters" back then) should just augment the player's abilities. Rather than dealing with a spell queueing system, I can simply declare that a player will only execute a single attack on some interval. Some items might increase the player's attack speed, others might increase the power of each projectile. More interesting items could fire multiple projectiles, or projectiles which "explode" after impacting the initial target. I want to make sure that the base player attack follows the selected target, and design the possible augmentations around that. So let's just start by implementing the player character's base attack.
I've re-implemented support in the PlayerAttackSystem
for firing projectiles from the player character. It's exactly what you would expect, and nearly the same as it was a couple of chapters ago before we ripped out projectile support, except now we're utilizing the PlayerTarget
rather than the player's Targeting
component. I added the system to the scene, and also had to add Health
and CircleCollider
components to the enemies for it to work properly.
I noticed myself repeating the same HP values when adding a Health
component to an entity:
The Entity
's add()
method actually supports passing in constructor arguments, but I've never really had a strong reason to use it before. One of the core programming principles is "don't repeat yourself" - I've been largely negligent to writing C.L.E.A.N. code during the prototyping phase, knowing that I'll be changing things a lot and throwing things away entirely. This just seems like low-hanging fruit though. A simple constructor makes this less tedious and less error-prone.
linguine/src/components/Health.h snippet
So now I can just pass the max health value in, and the entity's current health will start at the maximum:
This is actually a pretty enjoyable game! Even without collectables or any real "goal", the core game play is very satisfying. I feel compelled to find the end of the randomly generated dungeon, and the enemies are obstacles that can be defeated. Since the player character can kill them, they are no longer capable of surrounding you and trapping you in place. Attacking the enemies happens automatically, so as long as you keep healing, you will eventually succeed. The ability to switch targets to unblock a specific doorway is a big step up from the old "attack the closest target until it dies" system.
Sometimes when an enemy is destroyed, the obstacle on the grid does not correctly get removed. In the unfortunate case where the obstacle was placed in a doorway, the player is unable to progress to the end of the level, and has to restart the game. I'm pretty sure I know what's causing this: the LivenessSystem
is responsible for destroying entities and removing them from the grid when their health reaches 0, but it does so by rounding their current grid position. The GridPositionSystem
, on the other hand, actually moves the obstacles on the grid as soon as they start moving to a new cell. If an enemy gets destroyed before they have moved halfway to the new cell, then the LivenessSystem
will try to remove the obstacle from the old cell rather than the new one.
I believe the solution here is to just check to see if the entity's GridPosition
component has a transientDestination
set, and, if so, remove the obstacle from that location rather than rounding the grid position. If there is no transientDestination
, then we'll just use the old rounding logic.
linguine/src/systems/LivenessSystem.cpp snippet
if (gridPosition->transientDestination) {
_grid.removeObstruction(*gridPosition->transientDestination, gridPosition->dimensions);
} else {
_grid.removeObstruction(glm::round(gridPosition->position), gridPosition->dimensions);
}
I haven't been able to reproduce the issue since that change, so I'll consider it fixed for now.
Augmentations
Modifying the behavior of the player's attacks is a much more complex problem than auto-targeting or firing projectiles on a timer - primarily because of how open-ended of a problem it is. It might be helpful to list out some of the possibilities so that we can identify which parts of the game will require modifications.
- Increased attack speed:
- Currently controlled by the
frequency
value of theProjectileAttack
component. - Component is placed on the entity which fires the projectiles.
- Increased attack power:
- Controlled by the
power
value in theProjectileAttack
component. ProjectileAttack
component is placed on the entity which fires the projectiles.- The
power
value is copied into theProjectile
component of a new entity, representing the projectile itself in thePlayerAttackSystem
. - "Cleave" attacks, which fire a duplicate projectile at a different target:
- Theoretically, the
PlayerAttackSystem
could create multiple projectile entities at a time. - Secondary target selection is currently unaccounted for.
- "Explosive" attacks, which fire a ring of un-targeted projectiles when they collide with their target:
- The old
ExplosionAttack
component no longer has a system capable of executing it, but we could whip something up. - The
ProjectileSystem
currently expects theProjectile
component to contain atarget
for honing in on a moving destination. - The
Projectile
also no longer defines avelocity
or direction, can't currently support a "ring" of projectiles. - The ring of projectiles should not collide with the original target, or else the collision would be detected immediately when we spawn them.
- "Burst" attacks, which fire duplicate projectiles at the same target, with a delay:
- Easiest way to achieve this effect would be to temporarily lower the
frequency
value of theProjectileAttack
component. ProjectileAttack
component is placed on the entity which fires the projectiles.
It might also be interesting to implement some augmentations that aren't offensive, like increased movement speed, decreased damage taken, or modifiers to an item's chance of being the target of an attack. I'd like to just focus on the offensive side of things for now though.
A game like World of Warcraft implements these types of modifiers using a complex, but very thorough spell system, which not only defines the parameters for castable spells, but also "auras" that can be applied to a target. The game is so complex that players often utilize third-party websites to search for information regarding specific spells or auras.
I think a fully featured spell system is way beyond the scope of this prototype. While it would certainly be the most scalable way to add new abilities to the game over time, I don't think it's a good idea to spend time on it in the short term. Instead, I'll just have to add support for these augmentations in the most convenient ways possible.
There is one major topic that I haven't really addressed yet: how will the player collect new items? Do they drop from enemies? Are they randomly placed throughout the dungeon? Is there a maximum number of items that can be equipped at a time? Are the items completely random, or does the game allow some level of choice? Can equipped items be upgraded or replaced?
12.3 An Abrupt Pause
My dad has been in and out of the hospital for the last couple of years, in evidently worse condition each time he goes. A couple of months ago, he was diagnosed with a form of blood cancer, and he's had various complications since then. A bit more than a week ago, the doctor legitimately didn't think he would make it, and told us to expect the worst. I live several hours away from my family, so I made the trip as quickly as I could.
He looked absolutely awful and was so weak that he could barely speak. He had to be restrained because, in his confusion, he kept trying to get out of the hospital bed and even accidentally ripped an IV out of his neck. I spent the day with him, forgetting to feed myself until late into the night.
Somehow, he pulled through. I continued to visit him in the SICU until they eventually moved him to a normal room. Each day he recovered his strength - he began speaking normally again, cracking jokes, walking around, and throwing a ball around the hospital room with me. Less than a week later, I took him back to his home.
Over the course of my week-long visit, I didn't even touch a computer. That might not sound like such a big deal, but that is wildly out of character for me. I've been absolutely fixated on building this game over the last several months, and I'm sure I will fixate on it again eventually, but during the last week, all I wanted to do was be with my parents, even if my dad barely realized I was there.
My dad isn't immortal, and his health issues aren't going to just go away. I would love for him to be around forever, but that's just not realistic. All I can do is mentally prepare myself for the inevitable, and be available to him and my mother as much as I can.
I've felt absolutely overwhelmed for a few months now, in no small part due to the amount of effort I've been putting into this game. In an attempt to keep myself from breaking, I'm going to put this project on hold for a bit. At the moment, I have some catching up to do at work, since I disappeared for a week. There are also some video games I'd like to play - The Legend of Zelda: Tears of the Kingdom just came out, and the new World of Warcraft raid is open.
As usual, I'll close out this chapter by referencing the latest state of the code: you can find the current, somewhat playable game at this commit. I obviously didn't meet my goal of letting others play test the game, but perhaps it won't be such a stretch goal when I pick the project up again.