Prototyping Part 3
Sometimes things just don't work out, but it's important that we keep up the momentum. Too often have I gotten to this point, felt completely demoralized, and given up on a project entirely. This time around, I won't give up so easily. I am absolutely determined to press on.
So what's next? We determined that moving the health bars around the screen is generally a bad idea, but leaving all of the entities in place is underwhelming. We could separate the health bars from the actual units. The units could fly around the screen, while their health would be reflected by a static health bar. Since the player no longer has to tap on a moving target, the units could move at much faster speeds. This is more in line with how healing works in MMORPGs - the player certainly can select a unit to heal in the world, but it's much more common for them to just use the static health bars.
Alternatively, we could leave the units coupled with their static health bars, but make the enemies more dynamic, spawning in waves and moving around the screen, specifically targeting different friendly units. This sounds a lot like a tower defense game - a genre characterized by the player protecting their base from waves of enemies, often by placing "towers" in strategic locations. The towers are sometimes able to be repaired by the player, but that's not generally the focus of the game. Tower defense games like Plants vs. Zombies have done very well on mobile platforms, which makes it a tempting genre to pursue, but that also means there is some fierce competition out there. I'll pursue this idea for now, and maybe revisit the other idea later.
10.1 Two Steps Back
I'll start by setting the Engine
to load our good old ProgressPrototypeScene
. I don't have a strong vision for how to convert this scene into something resembling a tower defense game, which makes it hard to even get started on implementing any changes. I suppose it would be a good idea to brainstorm a bit first. Let's start my naming off some of the primary components of the tower defense genre, so that we can discuss ways to apply them to a game about filling up progress bars.
- Player agency - the player has creative freedom over which towers they use, and where they place them.
- Resource management - the player's ability to place new towers is constrained by the amount of resources they have. Different classes of towers might have different resource costs.
- Waves of enemies - each level consists of many waves of enemies which must be defeated by the towers that the player chooses. Decisions in tower placement for earlier waves might not be the most optimal for later waves.
- Spatial awareness - enemies can make use of different paths to reach the player's base, which the player must be aware of and protect.
The player should be able to choose which types of "towers" they use - in our case, a tower is what we've been referring to as a "friendly entity". Up to this point, we've only had one "type" of friendly entity, though we've proven that it's rather trivial to have multiple friendly entities at once. It stands to reason that we could easily create different types of friendly entities as well.
Resource management has often been on my mind throughout the prototyping phase of this project. In fact, we already have one type of resource that the player has to manage: the cooldown period of our spells. In MMORPGs, healers often use "mana" or "magic power" to cast their spells, but they are also bound by resources such as spell cooldowns as well. WoW healers often have secondary resources that they must manage in addition to mana, such as a Paladin's "holy power". As a game dev, what you name the resources is somewhat arbitrary, but they need to make sense in the context of your theme, and be made obvious to the player.
In the context of our game, an entity's health can be considered a resource which must be purchased using a spell which is not currently "on cooldown" (a phrase used to describe a spell whose cooldown period has not completed yet - I'll likely be using this phrase a lot). We could additionally add mana costs to our spells to make the purchase price of health even higher.
If we support the notion of the player dynamically adding friendly entities to their group, then doing so should also have some sort of resource cost.
As for enemy waves, our ProgressPrototypeScene
already uses an EntitySpawnSystem
capable of creating new waves of enemies. There's really not much more required here, but the enemies should definitely be more dynamic than they are currently.
On the opposite extreme, our game currently has absolutely no concept of spatial limitations, or any need to be aware of distance at all. I can imagine that different types of friendly enemies might have different attack ranges which can be useful against specific types of enemies.
The typical win condition for a tower defense game is simply defeating every wave of enemies before any of them reach your base. If any of them reach your base, then you lose. Our game doesn't currently have a "base" for the enemies to reach, but it's easy to imagine that the enemies wouldn't have much trouble reaching a hypothetical base if all of the friendly entities defending it were defeated.
The Implication
I was wondering when this would come up, but there's a strong implication that having different types of entities will require providing some explanation to the player regarding the capabilities of each type of entity. That may seem obvious, but the part that is bugging me is that it will involve actual words to be rendered on the screen!
There is a technical challenge to text rendering, but the part I fear most is localization - translating all of the text in the game to all the different languages I might want to support. Supporting different languages also means supporting the rendering of the character set for each language. It's a lot of work, especially for someone building their own engine.
But I'm going to ignore all of that for now.
10.2 Placing "Towers"
I haven't been able to work on the game in almost a week, and I'm highly concerned about losing momentum. I know myself, and if I don't touch a project for too long, then I will completely lose interest. I feel like I've come too far with this project to let it fizzle out, so I'm making it a point to keep working on it, if only for a few minutes each day. The lack of focus and "heads down" development can be frustrating, but at least I'm thinking about it.
Since I've been thinking about the game rather than building features, I've been considering ways to apply our healing-based game play to the tower defense genre. I've been listening to a YouTube playlist entitled Game Design Principles - Tower Defense, presented by Bill "LtRandolph" Clark. He does a fantastic job giving a high-level overview of tower defense components and mechanics. Episode 5 of the series talks about combining tower defense mechanics with other genres, which is exactly what I'm attempting to accomplish.
One of the hallmark features of a tower defense game is the ability for the player to place various types of towers at strategic positions. In our game, a "tower" is actually a friendly unit. We can define several different types of units that the player may choose from. The types that comes to my mind are heavily inspired by MMORPGs:
- "Tank" units, which are capable of drawing enemy fire away from other units. Deals low damage to enemies, but has a lot of health.
- Cleave units, which have short attack range, deal medium amounts of damage per target, but are capable of attacking several targets at once. Has medium health due to their close proximity to enemies.
- Sniper units, which have very long attack range and deal very high damage, but can only attack one enemy at a time, with relatively long intervals between attacks. Has low health since they can be far away from enemies.
The thing that will differentiate our game from most tower defense games is the focus on healing our "towers". Placing new towers will lead to more outgoing damage, but poses the risk of potentially taking more incoming damage that must be healed through. Furthermore, our game will be differentiated from MMORPGs by the fact that the group makeup is dynamic, rather than pre-selected.
A common criticism of the tower defense genre is that the player often does nothing while they watch their towers defend against waves of enemies. My idea is to fill that downtime with healing mechanics, but still allow the player some breathing room to create new units when they feel it is necessary. My biggest concern is that the game won't focus enough on the healing aspect, but I'll just have to play around with the prototype.
There are still some major unknowns, such as how the healing mechanics will work, or how the enemies will behave. I'll just have to worry about those things later. For now, I just need to allow the player to spawn new units on demand.
The Playing Field
I know I said I was going to start from the ProgressPrototypeScene
, but I've changed my mind, and I'll be creating a new TowerPrototypeScene
instead. Within this scene, I want to create a grid of tiles. Each tile should be selectable by the player in order to spawn a new unit at that location.
Creating the field of tiles is somewhat trivial - just a bit of math inside of a nested for
loop. I've added a new empty Tile
component to the tile entities so that I can query for them from a new TileSelectionSystem
. When a tile is Tapped
, I can destroy the tile entity, and construct a unit in its place. I'll just copy the friendly unit creation logic from the ProgressPrototypeSystem
.
linguine/src/scenes/TowerPrototypeScene.h snippet
constexpr auto boardWidth = 5;
constexpr auto boardHeight = 10;
constexpr auto entityWidth = 1.25f;
constexpr auto entityHeight = 1.25f;
for (auto x = 0; x < boardWidth; ++x) {
for (auto y = 0; y < boardHeight; ++y) {
auto entity = createEntity();
entity->add<Tile>();
auto transform = entity->add<Transform>();
transform->position = glm::vec3((-static_cast<float>(boardWidth) / 2.0f + 0.5f + static_cast<float>(x)) * entityWidth,
(-static_cast<float>(boardHeight) / 2.0f + 0.5f + static_cast<float>(y)) * entityHeight,
1.0f);
transform->scale = glm::vec3(0.9f);
auto drawable = entity->add<Drawable>();
drawable->feature = new ColoredFeature();
drawable->feature->meshType = Quad;
drawable->feature->color = glm::vec3(0.06f, 0.06f, 0.06f);
drawable->renderable = renderer.create(std::unique_ptr<ColoredFeature>(drawable->feature));
drawable.setRemovalListener([drawable](const Entity e) {
drawable->renderable->destroy();
});
auto selectable = entity->add<Selectable>();
selectable->feature = new SelectableFeature();
selectable->feature->entityId = entity->getId();
selectable->feature->meshType = Quad;
selectable->renderable = renderer.create(std::unique_ptr<SelectableFeature>(selectable->feature));
selectable.setRemovalListener([selectable](const Entity e) {
selectable->renderable->destroy();
});
}
}
linguine/src/systems/TileSelectionSystem.cpp
#include "TileSelectionSystem.h"
#include "components/Alive.h"
#include "components/CircleCollider.h"
#include "components/Friendly.h"
#include "components/Health.h"
#include "components/PhysicalState.h"
#include "components/Progressable.h"
#include "components/Selectable.h"
#include "components/Tapped.h"
#include "components/Tile.h"
#include "components/Transform.h"
#include "components/Unit.h"
namespace linguine {
void TileSelectionSystem::update(float deltaTime) {
findEntities<Tile, Transform, Tapped>()->each([this](Entity& entity) {
auto position = entity.get<Transform>()->position;
entity.destroy();
auto unitEntity = createEntity();
unitEntity->add<Friendly>();
auto transform = unitEntity->add<Transform>();
transform->position = position;
auto unit = unitEntity->add<Unit>();
unit->attackSpeed = 0.85f;
auto physicalState = unitEntity->add<PhysicalState>();
physicalState->previousPosition = glm::vec2(transform->position);
physicalState->currentPosition = physicalState->previousPosition;
unitEntity->add<CircleCollider>();
auto progressable = unitEntity->add<Progressable>();
progressable->feature = new ProgressFeature();
progressable->feature->meshType = Quad;
progressable->renderable = _renderer.create(std::unique_ptr<ProgressFeature>(progressable->feature));
progressable.setRemovalListener([progressable](const Entity e) {
progressable->renderable->destroy();
});
auto selectable = unitEntity->add<Selectable>();
selectable->feature = new SelectableFeature();
selectable->feature->entityId = unitEntity->getId();
selectable->feature->meshType = Quad;
selectable->renderable = _renderer.create(std::unique_ptr<SelectableFeature>(selectable->feature));
selectable.setRemovalListener([selectable](const Entity e) {
selectable->renderable->destroy();
});
auto health = unitEntity->add<Health>();
health->current = 1'000;
health->max = 1'000;
unitEntity->add<Alive>();
});
}
} // namespace linguine
Now What?
There are so many directions I could go right now, it's almost paralyzing. Let's come up with some options.
Different Unit Types
This is core to the game play of a tower defense game. Different units could provide a lot of depth to the game play, and allow for player agency and creativity. We'd have to whip up a user interface so that the player could choose between different types of units. There should probably be a limit to how frequently they can create new units, whether it be time-based or some other resource (or both). In any case, there's nothing for my units to attack (or be attacked by), so any differences in those units would go unnoticed right now.
Healing Mechanics
I would like to have more than just one or two types of healing spells in the game. I'd actually like for healing to make up for most of the game play. I'm still not sure how that would play out on a touch screen with limited input options. I could provide yet-another-interface to choose which heal to cast, separate from the interface to choose which unit to create. Yet again, without enemies attacking my units, there's nothing to iterate on yet.
Enemies
This seems like the obvious next step, since the other options I've mentioned have a hard dependency on the existence of enemy units to begin with.
10.3 Enemy Behavior
We built an EnemySpawnSystem
a while back which just spawned waves of static enemies. The tower defense genre is all about preventing dynamic enemies from reaching your base, so it seems obvious that we'll want to add some behavior to our enemy units.
I'm not entirely convinced that protecting a hypothetical "base" should be the player's priority, but I'm also not sure what the losing condition should be. In an MMORPG, you "lose" when everyone in your group dies. If a single group member outlives the boss, then the group is considered successful. I had assumed I would use the same logic in my game, but there's no strict requirement to do so. Still though, in a game about healing, the player shouldn't "lose" a level if enemies reach the base without any units dying - the player did their job as a healer, after all.
MMORPGs are pretty weird to think about. Enemies don't really have a "goal". Most of them just stand around doing nothing until a group of players comes along and antagonizes them. If they manage to defeat the aggressive players, then they just go back to what they were doing before: absolutely nothing! The goals and ambitions of the enemies are portrayed through the game's story, not through their actual behavior in the game.
If we define the win condition for our game to be "at least one unit survives", then that implies one big prerequisite: there must be at least one unit alive when the game begins! Many tower defense games provide a planning phase before the waves of enemies begin their attacks, in which the player can place towers in locations they believe will be most effective. It's not the worst idea, but what happens if they player doesn't do anything during that planning phase? In those games, the player would witness the enemies reach their destination without any resistance, and hopefully understand that it's up to them to provide said resistance. In our game, we would lose automatically because no friendly units were alive.
So having a base might be the better route. If reaching the base is the top priority of the enemy, then as the game's designer, I'm sort of incentivized to make the enemy units avoid the player's defenses, and go straight for the prize. If we do that, then there's no combat, which means there's nothing to heal, which defeats the purpose of the core mechanic. I'm not saying the game couldn't be good without healing mechanics, I just think healing is an underrepresented mechanic that has a lot of potential, so I'm fixating on it.
So what could the enemy possibly want that would require them to attack our units? Perhaps something about the units themselves - their magic, or their souls. It's easy to come up with an arbitrary answer to that question to fit the theme of the game (whatever that might end up being).
We'll go with no base, but we'll have to come up with a way for the player to always have something to heal from the very beginning of each level. I thought I could just add a Tapped
component to one of the tiles in the scene to trigger the unit creation logic, but I was reminded that the GestureRecognitionSystem
actually clears all of the Tapped
components at the beginning of each frame. So I created a SpawnUnit
component, updated the TileSelectionSystem
to spawn units for entities containing that component instead of the Tapped
component, but also added some logic to add the SpawnUnit
component if a tile entity was Tapped
. To spawn a unit at the bottom-center of the grid, I can just add the SpawnUnit
component to the entity at { 2, 0 }
.
linguine/src/scenes/TowerPrototypeScene.h snippet
Enemy Movement
Even though we've declared that the enemy's primary goal is to attack the player's units, there are countless ways to make enemies move around the playing field to achieve that goal. The easiest thing to implement would just be to whip up some enemy spawn points and have the enemies make their way toward the closest unit. That solution feels rather chaotic though, and raises many questions.
- Should enemy movement be completely free-form?
- We already have a grid, should we make enemy movement bound to that grid? - Could we introduce obstacles into the grid?
- Do enemies always prefer to attack the closest target?
- How close do enemies have to get to their target before they can start attacking?
The first level of Plants vs. Zombies makes it nearly impossible for the player to fail, and serves as a tutorial that answers these types of questions for the player. Zombies move toward your house (your base) using a single lane in a grid. Players collect "suns" by tapping on them as they appear on the screen. Players may spend suns on new plants, which they may place anywhere on the grid, and serve as both obstacles and defense mechanisms for the invading zombies.
Our grid is oriented vertically rather than horizontally, but is otherwise very similar to Plants vs. Zombies. Obviously the biggest difference is that the enemies in our game are not concerned with a base of any sort. So what should the first level of our game teach the player? The core healing mechanic, of course! Everything that doesn't directly play into that should be glossed over entirely until a later level. Enemy movement plays into the healing mechanic because the player needs to know when to expect incoming damage.
It's honestly taking me way longer to think through this than I'd like to admit. I've been stuck here for a few days. I'd like to blame it on my busy schedule, but really I just have a lot of trouble focusing on something when I do make the time for it. The entire point of the prototype is to just try things out and see how they feel, so I just need to pick something and roll with it.
The part that I don't like about Plants vs. Zombies (and many other tower defense games) is that the player tries to kill things before they even reach the towers. Since the entire point of my game is to heal the damage that the enemies do to the towers, it makes sense that the enemies would move fairly quickly, in order to close the gap. Let's start by emulating the lane-based game play of other tower defense games.
To begin with, I just created a single enemy unit above the center lane, and added all the necessary systems to the scene to enable the units to fire projectiles at one another. Immediately, the game feels better than the ProgressPrototypeScene
, because you can create new friendly units to help defeat the enemy. It is clearly unfair though, because there's nothing preventing the player from creating a ton of units to quickly defeat the hostile units without even having to heal - as a matter of fact, I haven't even added the systems to enable healing to the scene yet. I'll address these problems later, but right now I need to focus on the movement of the enemy units.
Currently, all units have an infinite attack range. This eliminates the need for units to get close to one another at all, though I'm not sure if that's actually a problem. Since the core mechanic is healing, the long range attacks allow the player to anticipate the incoming damage. It feels like it would limit the number of enemies that the player can reasonably identify though, since they would just be stacked on top of one another at the top of the screen.
I keep pondering the idea of spawning enemies in different lanes, particularly a lane that the player has not defended with a friendly unit, and what that might mean for the game play. In a typical tower defense game, and unguarded lane results in the enemy infiltrating the base (which means the player loses the game). Since our game doesn't have a base, spawning an enemy in an unguarded lane wouldn't mean anything at all. We should definitely address that.
The goal of the enemies is to attack our units. Plain and simple. Therefore, the enemies should not be bound to a single lane. Enemies should be able to traverse the grid in such a way that allows them to eventually attack the player. When combined with different spawn points for the enemies (such as the bottom of the screen instead of the top), this could provide for some interesting challenges for the player to overcome.
I just realized that we don't want the enemies to move too quickly because we need to give the player time to place new towers before the enemy closes the gap. This puts us in a weird conundrum: the player's units won't take damage unless enemy units are sufficiently close, but the player can't place new towers to heal if the enemy is too close. The easiest solution to this problem is to make all units attack at range, but then we're back to enemies clumping up at the spawn point like I mentioned before.
I'm probably just overthinking this. Let's recap some of these ideas into a concise list.
- I like the idea of enemies spawning at various points, which the player has to react to by placing appropriate towers in strategic locations.
- I prefer the idea of enemies having short-range attacks and approaching the player's units. Attacks can still be anticipated by the movement of the enemy, rather than the movement of the attack itself.
- Since there's no base to attack, enemies should be able to traverse the grid in order to reach a suitable target.
- To emphasize the healing aspect, the player's units should also be short-ranged, so that the enemies don't die before they start dealing damage.
Path Finding
The grid traversal isn't trivial to implement, as it requires some sort of path-finding algorithm. Dijkstra's algorithm is a popular choice for finding the shortest path between weighted graph nodes, particularly in computer science interviews. I first learned the algorithm when I was interviewing with Google for the second time (it took me three tries before I got an offer). In video games, a variant of Dijkstra's algorithm called A* is widely used, since it reduces the total number of calculations by utilizing a "heuristic" function to guide the algorithm. The closer the result of the heuristic function is to the actual answer, the better the performance of the algorithm will be. For a start, we can just use the distance between two points as the heuristic. The actual formula to determine the distance between two points is somewhat expensive, since it requires a square root operation, which CPUs are very bad at. To improve upon that, we can just use the squared distance between the points, since we're only ever comparing the result to other results - the comparison operation between the points is just an implementation detail that the user of the search algorithm doesn't actually care about.
For a grid without any obstacles, a distance-based heuristic will result in extremely good performance. As we add obstacles that must be navigated around (whether those are walls or other units), the performance will go down, but still result in the shortest path without ever having worse performance than we would have gotten had we used Dijkstra's algorithm instead.
I've never actually implemented A* from scratch, but the actual logic is pretty simple. There are many resources online that show how to implement the algorithm, but I'll just be using the pseudocode from the Wikipedia article as guidance.
The "open set" in our implementation will be a std::priority_queue
of SearchNode
s. A SearchNode
is a private struct that simply contains the node's position in the grid (represented as a 2-dimensional vector with integer values), as well as its "F-score" - a score calculated by taking the distance from the starting point and adding it to an educated guess of the distance from the goal point (the result of the heuristic function). The priority queue will be sorted by the F-score of the nodes, such that the top of the queue always has the highest F-score.
For now, I'll constrain the movement by the four cardinal directions - no diagonal movement. This means rather than using the Euclidean distance between two points for the heuristic function, I'll use the Manhattan distance.
To determine the available neighbors of each coordinate, I'll check the bounds of the grid, as well as a std::unordered_set<glm::ivec2>
containing obstructions within the grid itself. I'll create addObstruction()
and removeObstruction()
methods to allow the player to dynamically add new units, which the enemies must traverse around to reach their target.
I've included <glm/gtx/hash.hpp>
so that I can use glm::ivec2
as keys for my std::unordered_map
s and std::unordered_set
. This is an experimental feature of GLM, but due to floating-point imprecision, I would highly recommend against its use in most cases. I'm only comfortable using it here because I'm storing the grid coordinates as integers instead.
linguine/src/data/Grid.h
#pragma once
#include <list>
#include <unordered_map>
#include <unordered_set>
#include <glm/gtx/hash.hpp>
#include <glm/vec2.hpp>
namespace linguine {
class Grid {
public:
Grid(int width, int height) : _width(width), _height(height) {}
void addObstruction(glm::ivec2 location);
void removeObstruction(glm::ivec2 location);
[[nodiscard]] std::list<glm::ivec2> search(glm::ivec2 start,
glm::ivec2 goal) const;
private:
struct SearchNode {
glm::ivec2 position;
int fScore;
bool operator>(const SearchNode& other) const {
return fScore > other.fScore;
}
};
int _width;
int _height;
std::unordered_set<glm::ivec2> _obstructions;
[[nodiscard]] std::vector<glm::ivec2> getNeighbors(glm::ivec2 location) const;
[[nodiscard]] inline bool hasObstruction(glm::ivec2 location) const;
static inline int heuristic(glm::ivec2 a, glm::ivec2 b);
[[nodiscard]] static std::list<glm::ivec2> reconstructPath(
const std::unordered_map<glm::ivec2, glm::ivec2>& cameFrom,
glm::ivec2 current);
};
} // namespace linguine
linguine/src/data/Grid.cpp
#include "Grid.h"
#include <queue>
#include <glm/gtx/norm.hpp>
namespace linguine {
void Grid::addObstruction(glm::ivec2 location) {
_obstructions.insert(location);
}
void Grid::removeObstruction(glm::ivec2 location) {
_obstructions.erase(location);
}
std::list<glm::ivec2> Grid::search(glm::ivec2 start, glm::ivec2 goal) const {
auto openSet = std::priority_queue<SearchNode, std::vector<SearchNode>, std::greater<>>();
auto startGScore = 0;
auto startFScore = startGScore + heuristic(start, goal);
openSet.push(SearchNode{ start, startFScore });
auto cameFrom = std::unordered_map<glm::ivec2, glm::ivec2>();
auto gScores = std::unordered_map<glm::ivec2, int> {
{ start, startGScore }
};
auto fScores = std::unordered_map<glm::ivec2, int> {
{ start, startFScore }
};
while (!openSet.empty()) {
auto current = openSet.top();
if (current.position == goal) {
return reconstructPath(cameFrom, current.position);
}
openSet.pop();
for (const auto& neighbor : getNeighbors(current.position)) {
auto tentativeGScore = gScores[current.position] + 1;
auto neighborGScore = gScores.find(neighbor);
if (neighborGScore == gScores.end() || tentativeGScore < neighborGScore->second) {
cameFrom.insert({ neighbor, current.position });
auto gScore = tentativeGScore;
auto fScore = gScore + heuristic(neighbor, goal);
gScores.insert({ neighbor, gScore });
fScores.insert({ neighbor, fScore });
openSet.push(SearchNode{ neighbor, fScore });
}
}
}
return {};
}
std::vector<glm::ivec2> Grid::getNeighbors(glm::ivec2 location) const {
auto results = std::vector<glm::ivec2>();
auto left = location - glm::ivec2(1, 0);
if (location.x > 0 && !hasObstruction(left)) {
results.push_back(left);
}
auto right = location + glm::ivec2(1, 0);
if (location.x < _width - 1 && !hasObstruction(right)) {
results.push_back(right);
}
auto bottom = location - glm::ivec2(0, 1);
if (location.y > 0 && !hasObstruction(bottom)) {
results.push_back(bottom);
}
auto top = location + glm::ivec2(0, 1);
if (location.y < _height - 1 && !hasObstruction(top)) {
results.push_back(top);
}
return results;
}
bool Grid::hasObstruction(glm::ivec2 location) const {
return _obstructions.find(location) != _obstructions.end();
}
int Grid::heuristic(glm::ivec2 a, glm::ivec2 b) {
return std::abs(a.x - b.x) + std::abs(a.y - b.y);
}
std::list<glm::ivec2> Grid::reconstructPath(
const std::unordered_map<glm::ivec2, glm::ivec2>& cameFrom,
glm::ivec2 current) {
auto path = std::list<glm::ivec2> { current };
std::unordered_map<glm::ivec2, glm::ivec2>::const_iterator it;
while ((it = cameFrom.find(current)) != cameFrom.end()) {
current = it->second;
path.insert(path.begin(), current);
}
return path;
}
} // namespace linguine
To test it out, I modified the scene to create a Grid
using the earlier defined boardWidth
and boardHeight
. I then performed an arbitrary search for a path from { 2, 5 }
to { 4, 0 }
. Finally, I updated the nested for loop which creates the grid cells such that any cells within the resulting path appear orange instead of gray.
linguine/src/scenes/TowerPrototypeScene.h snippet
auto grid = Grid(boardWidth, boardHeight);
auto search = grid.search(glm::ivec2(2, 5), glm::ivec2(4, 0));
...
for (auto x = 0; x < boardWidth; ++x) {
for (auto y = 0; y < boardHeight; ++y) {
...
if (std::find(search.begin(), search.end(), glm::ivec2(x, y)) != search.end()) {
drawable->feature->color = glm::vec3(0.96f, 0.56f, 0.06f);
} else {
drawable->feature->color = glm::vec3(0.06f, 0.06f, 0.06f);
}
...
}
}
Let's add some obstructions to force the path more toward the middle, say { 3, 4 }
and { 4, 4 }
.
linguine/src/scenes/TowerPrototypeScene.h snippet
auto grid = Grid(boardWidth, boardHeight);
grid.addObstruction({3, 4});
grid.addObstruction({4, 4});
auto search = grid.search(glm::ivec2(2, 5), glm::ivec2(4, 0));
The obstructions aren't visible because they are virtual and exist only within the Grid
, but I can assure you it is working as expected. I intend to call the addObstruction()
and removeObstruction()
methods with the creation and possible death events of the player's units, which will be visible.
The A* algorithm is designed to find the shortest path to a goal, but I'm unsure what happens if the goal is itself an obstruction. In our game, we want the enemy to get as close to its target as possible, but we don't want it to be on top of the target. Since the friendly units will be treated as obstructions, but also a possible goal for the enemy, we need to determine if the path finding algorithm is suitable for such a case. I'll simply set the goal to { 2, 0 }
(the current location of our friendly entity), but also add an obstruction at the same location.
Unfortunately, none of the cells are orange, which means the algorithm did not find a valid path. There are a couple of ways we could work around this limitation.
- Find all the available neighbors of the target, calculate the path to each of them, and choose the shortest path.
- Temporarily remove the obstruction at the target, calculate the path to the target, and re-add the obstruction. Ignore the last node in the resulting path.
The second option is certainly much cheaper. I hate the idea of the systems mutating the grid temporarily before doing a search, so we'll just do it within the search function itself.
linguine/src/data/Grid.cpp snippet
std::list<glm::ivec2> Grid::search(glm::ivec2 start, glm::ivec2 goal) {
auto goalIsObstruction = _obstructions.find(goal) != _obstructions.end();
if (goalIsObstruction) {
_obstructions.erase(goal);
}
...
if (current.position == goal) {
auto result = reconstructPath(cameFrom, current.position);
if (goalIsObstruction) {
_obstructions.insert(goal);
result.erase(std::prev(result.end()));
}
return result;
}
...
if (goalIsObstruction) {
_obstructions.insert(goal);
}
return {};
}
It's not the cleanest solution in the world, but it totally works.
Path Traversal
Finding a valid path isn't particularly difficult once you've learned the algorithms at play. Traversing that path in traditional software is even easier, basically boiling down to a traditional loop over each node in the path. Making a renderable entity in a video game traverse that path over time, however, is somewhat tedious.
The solution I ended up implementing effectively treats the grid as its own "space". Since the grid has its own dimensions and scale, it becomes necessary to convert between world space and grid space to move the entity appropriately. To complicate matters further, a physically simulated entity's position is also interpolated between ticks of the fixed time step.
The actual logic to achieve the movement isn't particularly complicated, but understanding how the different positioning systems interact with one another is important to getting it right. To support the grid-based movement, I created a GridPosition
component containing a position within the grid (as floating-point coordinates, to support traversal), an optional destination (as integer coordinates, to enforce moving to a specific cell), and the speed of movement. I additionally added utility methods to the Grid
class, named getWorldPosition()
and getGridPosition()
, to support converting between world space and grid space.
linguine/src/data/Grid.cpp snippet
glm::vec2 Grid::getWorldPosition(glm::vec2 gridPosition) const {
return {
(-static_cast<float>(_width) / 2.0f + 0.5f + gridPosition.x) * _scale,
(-static_cast<float>(_height) / 2.0f + 0.5f + gridPosition.y) * _scale
};
}
glm::vec2 Grid::getGridPosition(glm::vec2 worldPosition) const {
return {
worldPosition.x / _scale + static_cast<float>(_width) / 2.0f - 0.5f,
worldPosition.y / _scale + static_cast<float>(_height) / 2.0f - 0.5f,
};
}
The new GridPositionSystem
is responsible for moving physically simulated entities toward their destination over time. There is no actual constraint that requires that the destination of a GridPosition
be an adjacent cell, so technically the GridPositionSystem
can move an entity long distances correctly.
linguine/src/systems/GridPositionSystem.cpp
#include "GridPositionSystem.h"
#include <glm/gtx/norm.hpp>
#include "components/GridPosition.h"
#include "components/PhysicalState.h"
namespace linguine {
void GridPositionSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<GridPosition, PhysicalState>()->each([this, fixedDeltaTime](const Entity& entity) {
auto gridPosition = entity.get<GridPosition>();
if (gridPosition->destination) {
auto difference = glm::vec2(*gridPosition->destination) - gridPosition->position;
auto frameDistance = gridPosition->speed * fixedDeltaTime;
if (glm::length2(difference) <= frameDistance * frameDistance) {
gridPosition->position = *gridPosition->destination;
gridPosition->destination = {};
} else {
auto direction = glm::normalize(difference);
gridPosition->position += direction * frameDistance;
}
}
auto physicalState = entity.get<PhysicalState>();
physicalState->currentPosition = _grid.getWorldPosition(gridPosition->position);
});
}
} // namespace linguine
Somewhat noteworthy is that the scene provides a reference to the Grid
object. I'm also using the squared magnitude of the difference
to determine when to "snap" to the destination
point (and comparing it against the square of the frameDistance
), since doing so avoids the expensive square-root calculation. Finally, the currentPosition
of the PhysicalState
is set to the world position of the grid cell.
The logic to actually select a target and start moving toward it is in a new EnemyTargetingSystem
. To support the new system, I've added a new Targeting
component, containing a Strategy
enum value (which currently only contains a Random
strategy), as well as an optional current target ID.
The system is pretty elementary for now. It clears the current target if it's dead, selects a new target based on the strategy, and moves toward that target by searching for a suitable path.
linguine/src/systems/EnemyTargetingSystem.cpp
#include "EnemyTargetingSystem.h"
#include <random>
#include "components/Alive.h"
#include "components/Dead.h"
#include "components/Friendly.h"
#include "components/GridPosition.h"
#include "components/Hostile.h"
#include "components/Targeting.h"
#include "components/Unit.h"
namespace linguine {
void EnemyTargetingSystem::fixedUpdate(float fixedDeltaTime) {
auto friendlies = findEntities<Friendly, Alive, GridPosition>()->get();
findEntities<Hostile, Unit, Alive, GridPosition, Targeting>()->each([this, &friendlies](const Entity& entity) {
auto targeting = entity.get<Targeting>();
auto gridPosition = entity.get<GridPosition>();
if (!gridPosition->destination) {
if (targeting->current) {
clearTargetIfDead(targeting);
}
if (friendlies.empty()) {
return;
}
if (!targeting->current) {
selectNewTarget(targeting, friendlies);
}
moveTowardTarget(targeting, gridPosition);
}
});
}
void EnemyTargetingSystem::clearTargetIfDead(Component<Targeting>& targeting) {
auto targetId = *targeting->current;
auto target = getEntityById(targetId);
if (target->has<Dead>()) {
targeting->current = {};
}
}
void EnemyTargetingSystem::selectNewTarget(Component<Targeting>& targeting,
const std::vector<std::shared_ptr<Entity>>& availableTargets) {
auto randomEntity = std::uniform_int_distribution<>(0, static_cast<int>(availableTargets.size() - 1));
switch (targeting->strategy) {
case Targeting::Random: {
const auto targetIndex = randomEntity(_random);
const auto& target = availableTargets[targetIndex];
targeting->current = target->getId();
break;
}
}
}
void EnemyTargetingSystem::moveTowardTarget(Component<Targeting>& targeting,
Component<GridPosition>& gridPosition) {
auto targetId = *targeting->current;
auto target = getEntityById(targetId);
auto targetPosition = target->get<GridPosition>()->position;
auto path = _grid.search(glm::round(gridPosition->position), targetPosition);
if (path.size() > 1) {
gridPosition->destination = *std::next(path.begin());
}
}
} // namespace linguine
This implementation is not comprehensive by any means, and I can already confuse it by enclosing its current target before it closes the gap. Since it fixates on a target until it's dead, it just becomes immobile rather than selecting a new target. I also modified the EnemyAttackSystem
to utilize PhysicalState
s instead of Transform
s, and to only shoot projectiles at the current target. Ignoring the edge-cases, it totally moves from target to target, killing one at a time, and it's fun to watch!
10.4 Flexible Attack System
As it stands, our entities fire physically simulated projectiles at their targets, which deal damage upon collision. These projectiles can sometimes miss their intended target. For example, friendly entities firing at a moving enemy often miss. Additionally, enemies that are stranded due to pathing issues continue to fire at their target, but the projectiles collide with the entities obstructing the path to begin with.
I like having the option to shoot projectiles, but I don't think that should be the default behavior. Instead, I'll implement melee attacks - a term used to describe short-range attacks, even though the definition of the word, according to the Oxford Dictionary, is "a confused fight, skirmish, or scuffle".
I'd like to build this attack system in such a way that will enable implementing other attack types relatively easily - cleave attacks, area-of-effect (AoE) attacks, ranged projectiles, or even our previously-implemented circular projectile spawner.
Melee Attacks
I moved the attack speed, power, and elapsed variables out of the Unit
component, into a new MeleeAttack
component. I commented out everything within the EnemyAttackSystem
and FriendlyAttackSystem
classes for now - I'll probably delete them entirely before I'm done here. I created a new AttackSystem
, which currently just queries for entities containing the new MeleeAttack
component, a Targeting
component, and a GridPosition
. Its job is fairly simple: keep track of the time between melee attacks, and attack if the target is close enough (one cell away in the grid).
linguine/src/systems/AttackSystem.cpp
#include "AttackSystem.h"
#include <glm/gtx/norm.hpp>
#include "components/GridPosition.h"
#include "components/Health.h"
#include "components/MeleeAttack.h"
#include "components/Targeting.h"
namespace linguine {
void AttackSystem::update(float deltaTime) {
findEntities<MeleeAttack, Targeting, GridPosition>()->each([this, deltaTime](const Entity& entity) {
auto targeting = entity.get<Targeting>();
auto meleeAttack = entity.get<MeleeAttack>();
meleeAttack->elapsed += deltaTime;
if (targeting->current) {
auto target = getEntityById(*targeting->current);
auto targetPosition = target->get<GridPosition>()->position;
auto position = entity.get<GridPosition>()->position;
if (glm::length2(targetPosition - position) <= 1.0f
&& meleeAttack->elapsed >= meleeAttack->speed) {
meleeAttack->elapsed = 0.0f;
auto health = target->get<Health>();
health->current = glm::clamp<int32_t>(health->current - meleeAttack->power, 0, health->max);
}
}
});
}
} // namespace linguine
After making some tweaks so that enemies use the new component, the enemies no longer attack at all if they cannot reach their target, and just sit around with no purpose. To resolve this, I updated the EnemyTargetingSystem
with a new targeting strategy: Nearest
. Since the targeting system only executes each time an entity moves to a new grid cell (rather than every frame), I can afford to do more expensive logic to determine a suitable target. In this case, I iterate over all available targets, calculate the length of the A* path to that target, and choose the one with the shortest available path.
linguine/src/systems/EnemyTargetingSystem.cpp snippet
case Targeting::Nearest: {
auto nearest = INT_MAX;
if (targeting->current) {
auto target = getEntityById(*targeting->current);
auto targetPosition = target->get<GridPosition>()->position;
auto path = _grid.search(glm::round(gridPosition->position), targetPosition);
if (!path.empty()) {
nearest = static_cast<int>(path.size());
}
}
for (const auto& target : availableTargets) {
auto targetPosition = target->get<GridPosition>()->position;
auto path = _grid.search(glm::round(gridPosition->position), targetPosition);
if (!path.empty()) {
auto distance = static_cast<int>(path.size());
if (distance < nearest) {
nearest = distance;
targeting->current = target->getId();
}
}
}
break;
}
This logic relies pretty heavily on how the path is returned from the search:
- If there is no available path, the resulting list is empty
- If there is an available path, the first entry in the list is always the start cell (which is the cell at which the enemy is already located)
- Since the first entry is always the current cell, a list containing a single entry means the enemy is already where it needs to be
This is why our moveTowardTarget()
method checks if the length of the path is greater than 1, and if so, moves toward the second entry in the list.
linguine/src/systems/EnemyTargetingSystem.cpp snippet
Explosion Attack
Adding a new type of attack should just be a matter of adding a new component and querying for it in the AttackSystem
. Let's take the logic for spawning a circle of projectiles from the ProjectileSpawnSystem
and port it to a new attack type instead. Since the projectiles are physically simulated, we'll spawn them based on the current PhysicalState
position of the unit within fixedUpdate()
, rather than the GridPosition
within update()
.
linguine/src/components/ExplosionAttack.h
#pragma once
namespace linguine {
struct ExplosionAttack {
int count = 36;
int32_t power = 25;
float speed = 1.0f;
float frequency = 10.0f;
float elapsed = 0.0f;
};
} // namespace linguine
linguine/src/systems/AttackSystem.cpp snippet
void AttackSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<ExplosionAttack, Targeting, PhysicalState>()->each([this, fixedDeltaTime](const Entity& entity) {
auto targeting = entity.get<Targeting>();
auto explosionAttack = entity.get<ExplosionAttack>();
explosionAttack->elapsed += fixedDeltaTime;
while (targeting->current && explosionAttack->elapsed >= explosionAttack->frequency) {
explosionAttack->elapsed = 0.0f;
auto physicalState = entity.get<PhysicalState>();
auto direction = glm::angleAxis(physicalState->currentRotation, glm::vec3(0.0f, 0.0f, 1.0f))
* glm::vec3(0.0f, 1.0f, 0.0f);
for (auto i = 0; i < explosionAttack->count; ++i) {
auto angle = glm::two_pi<float>() / static_cast<float>(explosionAttack->count) * static_cast<float>(i);
auto newDirection = glm::angleAxis(angle, glm::vec3(0.0f, 0.0f, 1.0f)) * direction;
_projectileFactory.create(physicalState->currentPosition, glm::vec2(newDirection) * explosionAttack->speed, explosionAttack->power);
}
}
});
}
Now that's pretty cool!
10.5 Spawning Enemies
A while back, we created an EnemySpawnSystem
containing some rudimentary enemy spawning logic. While it served its role at the time, we actually don't need it at all. What I'd like to achieve is to be able to execute any arbitrary action at some point in the future. Our scene could construct these actions along with the rest of the scene entities on startup, and a new system could keep track of orchestrating their execution.
The new EventSystem
is very simple. It queries entities with an Event
component, which has a secondsRemaining
field, along with a std::function<void()>
which should get executed when secondsRemaining
reaches zero. The component also contains a useFixedTimeStep
flag for events that should be executed in fixedUpdate()
rather than update()
.
linguine/src/systems/EventSystem.cpp
#include "EventSystem.h"
#include "components/Event.h"
namespace linguine {
void EventSystem::update(float deltaTime) {
findEntities<Event>()->each([deltaTime](Entity& entity) {
auto event = entity.get<Event>();
if (!event->useFixedTimeStep) {
event->secondsRemaining -= deltaTime;
if (event->secondsRemaining <= 0.0f) {
event->function();
entity.remove<Event>();
}
}
});
}
void EventSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<Event>()->each([fixedDeltaTime](Entity& entity) {
auto event = entity.get<Event>();
if (event->useFixedTimeStep) {
event->secondsRemaining -= fixedDeltaTime;
if (event->secondsRemaining <= 0.0f) {
event->function();
entity.remove<Event>();
}
}
});
}
} // namespace linguine
It really is that simple. To test it out, I moved the logic to create the enemy entity into an event, which triggers 5 seconds after the game starts.
linguine/src/scenes/TowerPrototypeScene.h snippet
auto eventEntity = createEntity();
auto event = eventEntity->add<Event>();
event.setRemovalListener([](Entity entity) {
entity.destroy();
});
event->secondsRemaining = 5.0f;
event->function = [this, &renderer]() {
// Create the enemy entity
}
The event did get triggered, although the game crashed before the enemy entity became visible. Running the game in debug mode shows that the crash was caused by a stack overflow:
- The
EventSystem
removed theEvent
component from the entity - The removal listener was triggered by the component's removal, and attempted to destroy the entity
- Because the removal listener was triggered before the entity changed archetypes, destroying the entity caused the removal listener to be triggered again
- The duplicate removal listener attempted to destroy the entity again
- Which triggered the removal listener again
- Et cetera...
I'm currently relying on the removal listener to get triggered before changing archetypes so that I can clean up Renderable
s when Drawable
, Selectable
, or Progressable
components are removed. If I change the archetype of the entity before executing the removal listener, then there will be no way to retrieve the pointers that need to be destroyed.
To prevent the recursion, I'll remove the removal listener from the map prior to executing it. When the removal listener executes and destroys the entity, the set of removal listeners that gets triggered won't include the first listener. It's not the cleanest solution, but it will work.
What won't work is trying to destroy an entity from inside a removal listener for an entity that is currently being destroyed (which also triggers the removal listener). We'll just copy the same solution from the remove()
method to the destroy()
method.
linguine/src/entity/archetype/ArchetypeEntityManager.cpp snippet
auto removalListener = entity.removalListeners.find(type);
if (removalListener != entity.removalListeners.end()) {
// Copy and erase prior to execution to prevent recursive execution
auto function = removalListener->second;
entity.removalListeners.erase(type);
function(Entity(*this, id));
}
Alright, now the event triggers after 5 seconds, causing the enemy to spawn and go about its business. I'll just create 5 events in a loop, each with a spawn timer based on its loop index, which should result in enemies spawning every 5 seconds.
It totally works, and looks awesome! The damage output is a little overwhelming, but that's just a tuning problem - besides, I don't even have healing enabled in this scene yet.
The only thing that I don't like is that the enemies are not currently considered obstacles on the grid, so they tend to walk on top of one another as they move between targets, and it's actually impossible to tell when more than one enemy occupies the same cell. I'll just update the EnemyTargetingSystem
so that the enemies dynamically add and remove obstructions as they move.
linguine/src/systems/EnemyTargetingSystem.cpp snippet
void EnemyTargetingSystem::moveTowardTarget(Component<Targeting>& targeting,
Component<GridPosition>& gridPosition) {
auto targetId = *targeting->current;
auto target = getEntityById(targetId);
auto targetPosition = target->get<GridPosition>()->position;
auto currentPosition = glm::round(gridPosition->position);
auto path = _grid.search(currentPosition, targetPosition);
if (path.size() > 1) {
auto newPosition = *std::next(path.begin());
gridPosition->destination = newPosition;
_grid.removeObstruction(currentPosition);
_grid.addObstruction(newPosition);
}
}
This enemy behavior is intimidating, and it's really fun to watch them swarm toward the player's units. I noticed the frame rate begin to drop over time, and realized that I never added the PhysicalState
or CircleCollider
components to the camera entity in this scene, which are required for the projectiles to be destroyed when they fly off the screen. I added those, and the frame rate is stable once more.
10.6 Unit Placement, Revisited
Now that the enemy AI behaves in a somewhat believable manner, we need to revisit what the player is allowed to do. Currently, the player is free to place as many units on the grid as they want, as quickly as they want. Additionally, when a player's unit dies, it remains on the grid as an obstacle. This combination effectively results in the enemy units getting trapped behind walls of dead units, unable to find a path to attack any remaining living units.
Unit Creation Cooldowns
There are a ton of variables we can play with to prevent this. The first thing I'd like to do is limit how frequently the player can place new units. We've implemented cooldowns on abilities before, so let's whip up a button that the user must press before placing a new unit.
Creation of the actual button is simple, but we need a component that we can query for that makes it specifically create units. The new UnitSelector
component will be just that - it will contain a type
to specify which type of unit should be created. The UnitType
enum will currently just contain a Default
value.
I've added a new UnitCreationSystem
to the scene, which queries for any entities containing UnitSelector
, Tapped
, and Cooldown
components. The system first checks to make sure the button isn't currently on cooldown, and then it has to perform a series of nested queries to perform the rest of its work.
It queries over all UnitSelector
entities, checking if the ID of the entity is the same as the originally selected. To perform this check, I had to add an isSelected
flag to the UnitSelector
component. If the IDs do not match, it sets the isSelected
flag to false. Otherwise, it "toggles" the isSelected
flag. When we toggle the flag, we query over all of the Tile
entities, and set their Drawable
color to gray or light green, depending on the toggle status, as well as enable/disable the Selectable
renderable.
The rest of the logic happens within the TileSelectionSystem
. When a Tile
is Tapped
, we query all of the UnitSelector
entities, and for the one that is currently selected, we set it to unselected, reset its cooldown, and copy the UnitType
to the SpawnUnit
component we added to this system a while back. To make the UnitType
enum sharable between the UnitSelector
and SpawnUnit
components, I moved it out into its own header in the data/
directory. We then iterate over all the Tile
entities, resetting them to their light green, unselectable state.
Finally, I've updated the SpawnUnit
query with a switch statement that can change behavior based on the UnitType
. Since the only possible value right now is Default
, I just moved the entire entity creation logic into that case block.
I updated the scene to contain the new UnitCreationSystem
, as well as the CooldownProgressSystem
(which wasn't needed before now). I updated all the tiles to have an unselected state (light green with disabled Selectable
s). Lastly, I added the UnitSelector
component to the new button, placed toward the top of the screen, with a cooldown of 5 seconds.
linguine/src/systems/UnitCreationSystem.cpp
#include "UnitCreationSystem.h"
#include "components/Cooldown.h"
#include "components/Drawable.h"
#include "components/Selectable.h"
#include "components/Tapped.h"
#include "components/Tile.h"
#include "components/UnitSelector.h"
namespace linguine {
void UnitCreationSystem::update(float deltaTime) {
findEntities<UnitSelector, Tapped, Cooldown>()->each([this](const Entity& tappedSelectorEntity) {
auto cooldown = tappedSelectorEntity.get<Cooldown>();
if (cooldown->elapsed < cooldown->total) {
return;
}
findEntities<UnitSelector>()->each([this, &tappedSelectorEntity](const Entity& selectorEntity) {
auto unitSelector = selectorEntity.get<UnitSelector>();
if (selectorEntity.getId() == tappedSelectorEntity.getId()) {
unitSelector->isSelected = !unitSelector->isSelected;
findEntities<Tile, Drawable, Selectable>()->each([&unitSelector](const Entity& tileEntity) {
auto drawable = tileEntity.get<Drawable>();
auto selectable = tileEntity.get<Selectable>();
if (unitSelector->isSelected) {
drawable->feature->color = glm::vec3(0.06f, 0.06f, 0.06f);
selectable->renderable->setEnabled(true);
} else {
drawable->feature->color = glm::vec3(0.54f, 0.75f, 0.04f);
selectable->renderable->setEnabled(false);
}
});
} else {
unitSelector->isSelected = false;
}
});
});
}
} // namespace linguine
linguine/src/systems/TileSelectionSystem.cpp
#include "TileSelectionSystem.h"
#include "components/Alive.h"
#include "components/CircleCollider.h"
#include "components/Cooldown.h"
#include "components/Drawable.h"
#include "components/Friendly.h"
#include "components/GridPosition.h"
#include "components/Health.h"
#include "components/PhysicalState.h"
#include "components/Progressable.h"
#include "components/Selectable.h"
#include "components/SpawnUnit.h"
#include "components/Tapped.h"
#include "components/Tile.h"
#include "components/Transform.h"
#include "components/Unit.h"
#include "components/UnitSelector.h"
namespace linguine {
void TileSelectionSystem::update(float deltaTime) {
findEntities<Tile, Tapped>()->each([this](Entity& entity) {
auto spawnUnit = entity.add<SpawnUnit>();
findEntities<UnitSelector, Cooldown>()->each([&spawnUnit](const Entity& selectorEntity) {
auto unitSelector = selectorEntity.get<UnitSelector>();
if (unitSelector->isSelected) {
unitSelector->isSelected = false;
auto cooldown = selectorEntity.get<Cooldown>();
cooldown->elapsed = 0.0f;
spawnUnit->type = unitSelector->type;
}
});
findEntities<Tile, Drawable, Selectable>()->each([](const Entity& tileEntity) {
auto drawable = tileEntity.get<Drawable>();
drawable->feature->color = glm::vec3(0.54f, 0.75f, 0.04f);
auto selectable = tileEntity.get<Selectable>();
selectable->renderable->setEnabled(false);
});
});
findEntities<Transform, SpawnUnit>()->each([this](Entity& entity) {
auto spawnUnit = entity.get<SpawnUnit>();
auto unitType = spawnUnit->type;
auto position = entity.get<Transform>()->position;
entity.destroy();
switch (unitType) {
case Default: {
// Unit creation logic
}
}
});
}
} // namespace linguine
Reintegrating Healing Mechanics
This game is supposed to be all about healing, so let's test all of these new features out with the goal of keeping our units alive. All we need to do is copy our global cooldown and "big heal" entities from the ProgressPrototypeScene
, and add the PlayerControllerSystem
. I'll make a couple of positional tweaks to the entities so that they don't overlap with the grid, and we have a game!
10.7 Rethinking the Grid
We're definitely moving in the right direction, but something feels "off" about the grid-based movement. It has bothered me frequently since implementing it, but I've been dismissive of the negative feelings, convincing myself to give it a fair shot - but I can't ignore it anymore.
The motivation for using such a rigid grid structure (which I only now realize I never explained) comes directly from healing in World of Warcraft. In the game, a "group" consists of up to 5 people. Multiple groups can come together to form a "raid group", which can consist of between 2-8 groups (between 10-40 players). The actual cap of the raid group depends on the specific content - for example, "mythic" difficulty raids require exactly 20 people. WoW's default interface displays the health bars of raid members in a grid pattern, with each row representing a single group. WoW also allows the use of third-party add-ons - there are many add-ons that display the health bars in a similar manner, along with additional customizations. One such add-on is literally named Grid (though its support has been lacking of late). The health bars within the grid aren't only for display purposes - they also allow you to target the players and cast spells on them.
Since my overall goal was to emulate the healing mechanics of WoW, it seemed obvious that the health bars would be organized in a similar way. However, enemies in WoW don't literally attack the health bars on the interface; they attack the actual units in the world, and the health bars simply indicate the health of those units. This is where I struggle to connect the differing types of game play.
I actually like the tower defense idea, but the enemy units traversing the same grid as the player's units feels clunky. I made it so that only one enemy could occupy each cell at a time because it was annoying not being able to determine how many enemies were actually in that cell. However, I don't like how often the enemy units seem to get in the way of other enemy units. Even more annoying is how the player can currently use dead units as a way to block enemies from ever reaching the living ones. In fact, I'll go ahead and fix that.
- I modified the
LivenessSystem
to destroy entities rather than adding theDead
component, and remove the obstruction from the grid. - I updated the
clearTargetIfDead()
method in theEnemyTargetingSystem
to check for the absence of anAlive
component instead of existence ofDead
. - I've fixed a crash in the
AttackSystem
in which was was trying to retrieve theGridPosition
of a target which recently died and was destroyed.
With these changes, the game already feels less clunky. I wouldn't mind so much if the obstructions were intentionally placed, but as a side-effect of other mechanics, it's just annoying.
Most tower defense games have wide open pathways for enemies to travel along, and only allow players to construct towers outside of that path. Then again, the enemies in most tower defense games don't even attack the towers. Maybe this isn't a tower defense game at all - maybe it's more of a real-time strategy (RTS) game. The RTS genre is probably one of the biggest inspirations of the tower defense genre. Where a tower defense game really focuses on defending your base using defensive mechanisms, an RTS game also requires you to build up your base, create units, and send them to attack your enemy's base, all while defending your own base.
RTS games have not done very well in recent years. RTS games were very popular during the '90s and early 2000s, and included games such as the Command & Conquer series (Red Alert 2 was my favorite), Age of Empires, and even the original Warcraft games (which would later evolve into the MMORPG known as World of Warcraft). There are certainly still new RTS games coming out, but those on PC aren't particularly popular, and the genre did not translate well to mobile devices. Command & Conquer: Rivals is a mobile interpretation of the genre. I applaud the effort, but it lacks much of what I loved about the RTS games from my childhood - all sense of player agency and creative expression has been squashed for the sake of streamlining the game into short matches more suitable for mobile platforms.
Scaling Up
While traditional RTS games are often grid-based, different types of units and structures can occupy multiple tiles on the grid at a time. It might be interesting to make the grid much larger, but require the player's units to occupy multiple cells. This would result in the enemies appearing smaller on the screen, but allow for more of them to attack the same target at the same time.
To enable units to occupy multiple grid cells, I suppose I'll have to start by modifying the Grid
class. The addObstruction()
and removeObstruction()
methods now require a dimensions
parameter to describe the number of cells that will be occupied, and the std::unordered_set<glm::ivec2>
containing the obstructions has been converted to a std::unordered_map<glm::ivec2, Obstruction>
. The new Obstruction
struct just contains the position and size of an obstruction. If you find an entry for any given cell in the map of obstructions, then you can also get the position and size of the entity that the cell is occupied by.
linguine/src/data/Grid.cpp snippet
void Grid::addObstruction(glm::ivec2 location, glm::ivec2 dimensions) {
auto obstruction = Obstruction{location, dimensions};
for (auto x = location.x; x < location.x + dimensions.x; ++x) {
for (auto y = location.y; y < location.y + dimensions.y; ++y) {
_obstructions.insert({{x, y}, obstruction});
}
}
}
void Grid::removeObstruction(glm::ivec2 location, glm::ivec2 dimensions) {
for (auto x = location.x; x < location.x + dimensions.x; ++x) {
for (auto y = location.y; y < location.y + dimensions.y; ++y) {
_obstructions.erase({x, y});
}
}
}
The search()
method has been updated to consider the idea of targets that occupy multiple cells. The general idea of the algorithm is that, if the destination is considered an "obstruction", then to find the shortest path to the edge of that obstruction by removing path nodes from the end of the path until no obstruction exists. I didn't change anything about the A* pathfinding algorithm - just the details of how the result is returned to the caller.
linguine/src/data/Grid.cpp snippet
std::list<glm::ivec2> Grid::search(glm::ivec2 start, glm::ivec2 goal) {
auto goalObstruction = std::optional<Obstruction>();
auto goalObstructionIt = _obstructions.find(goal);
if (goalObstructionIt != _obstructions.end()) {
goalObstruction = goalObstructionIt->second;
for (auto x = goalObstruction->position.x; x < goalObstruction->position.x + goalObstruction->size.x; ++x) {
for (auto y = goalObstruction->position.y; y < goalObstruction->position.y + goalObstruction->size.y; ++y) {
_obstructions.erase({x, y});
}
}
}
// A* pathfinding stuff...
if (current.position == goal) {
auto result = reconstructPath(cameFrom, current.position);
if (goalObstruction) {
for (auto x = goalObstruction->position.x; x < goalObstruction->position.x + goalObstruction->size.x; ++x) {
for (auto y = goalObstruction->position.y; y < goalObstruction->position.y + goalObstruction->size.y; ++y) {
_obstructions.insert({{x, y}, *goalObstruction});
}
}
while (result.size() > 1) {
auto it = std::prev(result.end());
if (_obstructions.find(*it) == _obstructions.end()) {
break;
}
result.erase(it);
}
}
return result;
}
// More A* pathfinding stuff...
if (goalObstruction) {
for (auto x = goalObstruction->position.x; x < goalObstruction->position.x + goalObstruction->size.x; ++x) {
for (auto y = goalObstruction->position.y; y < goalObstruction->position.y + goalObstruction->size.y; ++y) {
_obstructions.insert({{x, y}, *goalObstruction});
}
}
}
return {};
}
From there, it should just be a matter of updating all the touch points. The TileSelectionSystem
is responsible for creating new friendly units, so to make them bigger, I'll change the scale of the Transform
to 2.0f
, the radius of its CircleCollider
to 1.0f
, and pass in the dimensions of the obstruction to the Grid
. I'll need to keep track of these per entity, so I'll add a dimensions
field to the GridPosition
component, set that to { 2, 2 }
for the friendly unit, and pass it into the addObstruction()
call.
The LivenessSystem
removes obstructions when a unit has died. I can just grab the dimensions off of its GridPosition
component and pass it into the removeObstruction()
call.
The GridPositionSystem
updates the PhysicalState
's current position with the world position returned by the Grid
. Since I don't need the world position of a single cell for the entity, I'll need to find the "center" of the entity and get the world position for that.
linguine/src/systems/GridPositionSystem.cpp snippet
physicalState->currentPosition = _grid.getWorldPosition(gridPosition->position + glm::vec2(gridPosition->dimensions) / 2.0f - 0.5f);
The EnemyTargetingSystem
adds and removes obstructions in the moveTowardTarget()
method as the enemy unit moves across the grid. I just need to grab its dimensions from the GridPosition
component and pass them into the add/remove calls.
The AttackSystem
calculates the distance between units to determine if they are within range of melee attacks. I've changed the calculation to be the distance between the centers of the units, but that actually doesn't fix anything. I whipped up a nasty O(n^4)
algorithm to detect adjacent cells in the Grid
, and utilized that from the AttackSystem
. It works find for the prototype so I'm not going to spend any time thinking about optimizing it.
linguine/src/data/Grid.cpp snippet
bool Grid::isAdjacent(glm::ivec2 a, glm::ivec2 b) const {
auto obstructionAIt = _obstructions.find(a);
auto obstructionBIt = _obstructions.find(b);
if (obstructionAIt == _obstructions.end()
|| obstructionBIt == _obstructions.end()) {
return false;
}
auto& obstructionA = obstructionAIt->second;
auto& obstructionB = obstructionBIt->second;
for (auto aX = obstructionA.position.x; aX < obstructionA.position.x + obstructionA.size.x; ++aX) {
for (auto aY = obstructionA.position.y; aY < obstructionA.position.y + obstructionA.size.y; ++aY) {
for (auto bX = obstructionB.position.x; bX < obstructionB.position.x + obstructionB.size.x; ++bX) {
for (auto bY = obstructionB.position.y; bY < obstructionB.position.y + obstructionB.size.y; ++bY) {
if (glm::distance(glm::vec2(aX, aY), glm::vec2(bX, bY)) <= 1.0f) {
return true;
}
}
}
}
}
return false;
}
Finally, I can update the scene to utilize a larger grid, and set the camera's height to show more of the scene at a time. I'll increase the size of the grid to 12x18 (up from 5x6), and set the camera's height to 30.0f
(up from 15.0f
).
A Wild Bug Appears!
I didn't move the buttons to account for the new grid size, but in testing this new functionality, I noticed that there were some weird rendering glitches if a unit happened to pass behind the global cooldown indicator while it was supposedly hidden. This is generally a sign of errors writing to the depth buffer, so I modified the MetalRenderer
's coloredRenderPassDescriptor
to more explicitly clear the depth buffer at the beginning of every frame (which I assumed would happen naturally as a result of calling currentRenderPassDescriptor()
on the MTK::View
). After making that change, I could not reproduce the issue.
10.8 I Don't Know, Man
I'm looking back on all of the cool features that were implemented throughout this chapter, and while I'm happy with what I've come up with so far, something just feels "off" about it. I've been feeling a severe lack of motivation lately, but I'm trying to be mindful about that and not let it cloud my judgement.
I've been fairly busy recently, so I haven't gotten to work on the game as much as I would have liked. Coupled with the aforementioned motivational issues, I feel some anxiety over the fact that I haven't made much forward progress over the last several days. I can't ignore my other responsibilities, so I just have to keep reminding myself of my long-term determination to release this project - but it's demoralizing that there's such a long way to go, and I can't even nail down a solid prototype.
I've run out of pants to wear. I don't mean that as a metaphor or anything - all of my pants are so old that they have all begun to rip, and I need to go shopping to buy new ones. I absolutely hate shopping, but it's just an example of the type of thing I have to do instead of working on the game. I'm not sure if that's an indication of my mental well-being, but it's definitely a source of frustration. My wife loves shopping, so I'm sure she'd be happy to accompany me, and that makes me less frustrated. Maybe we can go out to eat while we're out.
I'll wrap this chapter up and take a little bit of time to consider other ideas. While I'm happy with most of the actual code that I wrote, I'm just not very excited about the outcome. If you're curious, the current state of the project can be found at this commit.