Prototyping
This is the part I suck at. It seems silly to have made it this far without any actual game play to show for it. The time required to develop a game engine is substantial, but every time I do it, I get through it a little quicker, and am even happier with the result. Even so, this engine is anything but feature-complete. Even the prototyping phase will consist of quite a bit of "engine" development, mostly in the form of render features (I assume). An interesting implication of developing your own game engine is that you are constrained by default, and constraints breed creativity!
I'm not a game designer, though I apparently aspire to become one; otherwise I wouldn't be spending so much time and energy on this project. Contrary to what my family thinks I do for a living, I take part in absolutely no game design discussions or decisions at work. My role as a server engineer is so far removed from game design that my team is actually in the process of being split into a separate company entirely.
It's hard to just "get started" with prototyping a game without a clear vision. Luckily, we have some idea for a core mechanic with which to experiment - progress bars!
8.1 Implementing Progress Bars
It's been over a year since I declared that I would be building a game inspired by MMORPG healing mechanics. I stopped working on the project very shortly after I wrote that, and only picked it up again within the last few months. I'm still very much interested in pursuing the idea though.
The first thing I need to do is create a new render feature capable of filling an object according to a defined percentage. The new feature object is just a copy of the ColoredFeature
, with the addition of a progress
field, ranging from 0.0
to 1.0
.
linguine/include/render/features/ProgressFeature.h
#pragma once
#include "RenderFeature.h"
#include <glm/mat4x4.hpp>
#include "renderer/mesh/MeshType.h"
namespace linguine {
struct ProgressFeature : public RenderFeature {
glm::mat4 modelMatrix = glm::mat4(1.0f);
glm::vec3 color = glm::vec3(1.0f);
float progress = 1.0f;
MeshType meshType;
};
} // namespace linguine
Implementing the feature renderer is incredibly similar to the others. The biggest difference in the C++ code is that I'm splitting up the uniform buffers for the vertex and fragment functions.
renderers/metal/src/ProgressFeatureRenderer.h snippet
struct MetalProgressVertexFeature {
simd::float4x4 modelMatrix{};
};
struct MetalProgressFragmentFeature {
simd::float3 color{};
float progress{};
};
renderers/metal/src/ProgressFeatureRenderer.cpp snippet
auto vertexFeatureBuffer = _vertexFeatureBuffers[valueBufferIndex];
auto metalVertexProgressFeature = static_cast<MetalProgressVertexFeature*>(vertexFeatureBuffer->contents());
memcpy(&metalVertexProgressFeature->modelMatrix, &feature.modelMatrix, sizeof(simd::float4x4));
auto fragmentFeatureBuffer = _fragmentFeatureBuffers[valueBufferIndex];
auto metalFragmentProgressFeature = static_cast<MetalProgressFragmentFeature*>(fragmentFeatureBuffer->contents());
memcpy(&metalFragmentProgressFeature->color, &feature.color, sizeof(simd::float3));
metalFragmentProgressFeature->progress = feature.progress;
commandEncoder->setVertexBuffer(vertexFeatureBuffer, 0, 2);
commandEncoder->setFragmentBuffer(fragmentFeatureBuffer, 0, 0);
The pipeline shaders are written so that the vertex shader passes the current x
coordinate to the fragment shader. The fragment shader then determines the output color based on whether the x
coordinate is greater than the current progress
.
renderers/metal/src/ProgressFeatureRenderer.cpp shaders
VertexOutput vertex vertexProgress(uint index [[vertex_id]],
const device float2* positions [[buffer(0)]],
const constant MetalCamera& camera [[buffer(1)]],
const constant MetalProgressVertexFeature& feature [[buffer(2)]]) {
VertexOutput o;
o.position = camera.viewProjectionMatrix * feature.modelMatrix * float4(positions[index], 0.0, 1.0);
o.x = positions[index].x + 0.5;
return o;
}
float4 fragment fragmentProgress(VertexOutput in [[stage_in]],
const constant MetalProgressFragmentFeature& feature [[buffer(0)]]) {
return in.x < feature.progress ? float4(feature.color, 1.0) : float4(0.0, 0.0, 0.0, 1.0);
}
A couple of things to note here. First of all, this code very much assumes the minimum and maximum x
coordinates (in model space) will be -0.5
and 0.5
, respectively. This is the case with both our quad and triangle meshes, but it's absolutely not necessary for meshes to comply to such a standard. Writing a more complex shader that could support any arbitrary size of model would just take more time, and a goal of prototyping is expediency.
The other thing to note is that branching logic is really bad for shader performance. There are ways to do this type of logic purely mathematically in order to enhance the performance of the shader, but I'm not interested in optimizing performance right now.
Before I forget, I'll need to update the TransformationSystem
to update the model matrix of the new Progressable
entities. I don't like how the render features are panning out in conjunction with the ECS architecture, but I'm not going to focus on that right now.
Rather than replacing all of the entity creation logic within my TestSystem
class, I've created a new ProgressPrototypeScene
specific for this prototype, and modified the Engine
to load this new scene by default instead. The new scene strips out all of the systems that we previously used to test the ECS architecture, and simply creates a camera object and a single Progressable
and Selectable
entity.
linguine/src/scenes/ProgressPrototypeScene.h snippet
auto cameraEntity = createEntity();
cameraEntity->add<CameraFixture>();
cameraEntity->add<Transform>();
auto entity = createEntity();
entity->add<Transform>();
auto progressable = entity->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 = 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();
});
Starting up the application, I don't actually see anything. Turns out that I forgot to add the new feature renderer to the MetalRendererImpl
's vector of features. After fixing that, the quad shows up as expected. To change the progress of the quad, I can simply add progressable->feature->progress = 0.5f;
to the scene.
To make this more dynamic, I'll create a ProgressTestSystem
to constantly "drain" the progress of the entity, as well as allow the player to replenish its progress by tapping it.
linguine/src/systems/ProgressTestSystem.cpp
#include "ProgressTestSystem.h"
#include "components/Progressable.h"
#include "components/Tapped.h"
namespace linguine {
void ProgressTestSystem::update(float deltaTime) {
findEntities<Progressable, Tapped>()->each([](const Entity& entity) {
auto feature = entity.get<Progressable>()->feature;
feature->progress = glm::clamp(feature->progress + 0.25f, 0.0f, 1.0f);
});
findEntities<Progressable>()->each([deltaTime](const Entity& entity) {
auto feature = entity.get<Progressable>()->feature;
feature->progress = glm::clamp(feature->progress - deltaTime * 0.25f, 0.0f, 1.0f);
});
}
} // namespace linguine
It's a bit difficult to tell where the bounds of the selectable entity is when it's empty, since the empty portion of the object is black on top of a black background. I'll just change the renderer's clear color to a light shade of blue for now.
renderers/metal/src/MetalRenderer.cpp snippet
I should really make the clear color dynamic based on the camera settings, but again, I'm going to ignore that for now.
8.2 Finding the Fun
Why is it so easy for me to whip up the code for a technical feature, but so difficult to turn something into a "game"? I struggle a lot with defining what "fun" is. It's a running joke in my group of friends that all RPGs can be boiled down to one main idea: you just run around and do stuff. This sentiment is incredible accurate, and yet it doesn't actually take away from how fun the games are. Even games with incredibly vast worlds which require lots of running, such as The Legend of Zelda: Breath of the Wild or The Elder Scrolls V: Skyrim, are wildly popular and considered fantastic games. Surely I can take a mechanic that doesn't require running around endlessly and turn it into something "fun".
I know the core mechanic is fun, because I love to play games where it is the focus. Let's take a look at some of the games from which I take inspiration. What makes it fun to keep progress bars from reaching 0%?
Healing allows a group to overcome obstacles that would otherwise be too difficult.
There's a lot to unpack here. We haven't even established what obstacles the player has to overcome yet, so it seems that we need some external actor that makes our progress bar move in a bad way, so that the player can move it in a good way.
Right now, the external actor is "time" - the bar is drained at a constant rate, forever (25% per second, according to the code). In order to "overcome" the obstacle, we would have to stop time. That could be an interesting mechanic, but it seems more logical to visually indicate an antagonist of some sort that can be defeated.
I also used the word "group" for some reason. It is true that many types of characters in RPGs are capable of just healing themselves while doing solo content, but healing is more of an afterthought in those cases. I want the progress bars to be the primary focus in this prototype.
Dealing with progress bars more quickly allows you to progress more quickly.
This is a vague statement, but let me explain. In an MMORPG, when players who are incapable of healing themselves takes too much damage without a healer available to assist them, they tend to run away from the source of the damage. If they are running away, then they are not fighting, which prolongs the encounter. Even if they survive, the amount of time taken to deal with the enemy is clearly greater than it would have been if they did not have to be so defensive. Even in a single-player RPG, dealing more damage to the enemy ("dealing with the progress bar") means that enemy will die faster, allowing the player to progress more quickly.
As a non-RPG example, Overcooked! uses progress bars to indicate the progress of the sub-tasks within the game - namely chopping or cooking the ingredients. The goal of each level is to earn enough points to be rated highly. Ratings come in the form of "stars", and groups can earn between 0 and 3 stars. Stars are then used to unlock more levels. Even though the goal of the game is not defeating an enemy, groups can progress through the game more quickly if they handle the sub-tasks more quickly and efficiently. Overcooked! does not provide any mechanism to "un-chop" ingredients - though it can be very detrimental to your rate of progress if you spend your time chopping the wrong ingredients. Similarly, you cannot "un-cook" food, but you can overcook the food, which can lead to kitchen fires that can be unrecoverable! It's a great combination of mechanics, framed in a very relatable way.
Progress bars are a visual indicator of the player's stress level.
Overcoming stressful situations is challenging, and it feels good to overcome a challenge. In MMORPGs, it's easy to tell how much stress a healer is dealing with based on the group's overall health. Overcooked! doesn't have the same type of stress indicator "at a glance", but you can visualize different elements of the game as progress bars - how much time is left relative to how much you started with, or how many points you currently have relative to how many you need to unlock the next level.
I think there's something special about overcoming stress in a safe way. In MMORPGs, even when everybody dies, the party members can regroup, recoup their resources, and try again. In co-op games like Overcooked!, each level can be restarted at any point - you can even revisit old levels after you've gotten better at the game in order to earn more stars. When a group has failed too many times, the stress can become unbearable, and the group falls apart. In a single-player version of this mechanic, there is no one to blame but yourself!
Still, there must be sufficient incentive for a player to submit themselves to such stressful situations. In MMORPGs, obtaining better gear for your character is almost always the incentive. With better gear, you can more easily handle the same content, or you can choose to take on more difficult content. In Overcooked!, however, your character never gains any power or abilities, you simply unlock more difficult levels - the players get better with experience, not gear.
8.3 Experimenting with Mechanics
Let's make a list of ideas that I want to play around with, in no particular order.
- "Enemies", which deal damage to the player's units.
- The ability for the player's units to deal damage back to the enemies.
- Perhaps outgoing damage is relative to the "healthiness" of the player's unit.
- Levels which require the player to defeat all enemies.
- Levels which are scored based on effectiveness within a time limit.
- Movement of the player's units.
- The player controlling one specific unit.
- The "move set" of the player.
As I mentioned way back in the first chapter, I can use any "coat of paint" to portray these mechanics to the player. I don't want to be too opinionated on "what" these enemies are, or "where" the levels take place. This prototype is all about making sure the core game play is fun without the use of any aesthetic or theme.
Implementing Enemies
Enemies will choose a random "friendly" unit, and attack them based on a defined attack speed and attack power. Visually, I'll just display enemies as red triangles, which is pretty easy to whip up.
Rather than dealing with the raw percentages of the progress bars, I'll create a new Health
component, containing current
and max
values. I'll give the new enemy unit a health of 10,000, but our friendly unit a health of 1,000. This will ideally emphasize the need for player intervention, but tuning is somewhat arbitrary at this stage. In any case, I'm going to replace the ProgressTestSystem
with a HealthProgressSystem
, which just converts between the health values to percentages for the ProgressFeatureRenderer
to deal with.
linguine/src/systems/HealthProgressSystem.cpp
#include "HealthProgressSystem.h"
#include "components/Health.h"
#include "components/Progressable.h"
namespace linguine {
void HealthProgressSystem::update(float deltaTime) {
findEntities<Health, Progressable>()->each([](const Entity& entity) {
auto health = entity.get<Health>();
auto feature = entity.get<Progressable>()->feature;
feature->progress = glm::clamp(
static_cast<float>(health->current) / static_cast<float>(health->max),
0.0f,
1.0f
);
});
}
} // namespace linguine
For the new EnemyAttackSystem
, I'll need to differentiate between friendly and hostile entities. To do so, I'll create an empty Friendly
component, and a Hostile
component containing attackSpeed
, attackPower
, and attackTimer
values.
linguine/src/systems/EnemyAttackSystem.cpp
#include "EnemyAttackSystem.h"
#include <random>
#include <glm/common.hpp>
#include "components/Friendly.h"
#include "components/Health.h"
#include "components/Hostile.h"
namespace linguine {
void EnemyAttackSystem::update(float deltaTime) {
auto friendlies = findEntities<Friendly, Health>()->get();
auto random = std::random_device();
auto randomEntity = std::uniform_int_distribution<>(0, static_cast<int>(friendlies.size() - 1));
findEntities<Hostile>()->each([this, deltaTime, &friendlies, &random, &randomEntity](const Entity& entity) {
auto hostile = entity.get<Hostile>();
if (hostile->attackTimer >= hostile->attackSpeed) {
hostile->attackTimer -= hostile->attackSpeed;
auto index = randomEntity(random);
auto target = friendlies[index];
auto health = target->get<Health>();
health->current = glm::clamp<int32_t>(health->current - hostile->attackPower, 0, health->max);
_logger.log(std::to_string(health->current));
}
hostile->attackTimer += deltaTime;
});
}
} // namespace linguine
Hopefully the code speaks for itself. We get to use the nifty feature to collect all of the entities from the <Friendly, Health>
query into a vector so that we can choose one at random.
To test things out, I'll set the Hostile
's attack speed to 1.0f
, and its attack power to 100
. As expected, the progress of our friendly unit ticks down 10% per second.
It's not particularly obvious that it's the triangle which is attacking the quad, but I'm going to ignore that detail for now. To test things further, I'll create a couple more friendly entities, just to make sure the triangle is only attacking one at a time. It works as expected, but having a row of friendly units across the screen makes it feel more like a turn-based RPG, even though it's definitely not.
Fighting Back
Obviously there's not much to look forward to if you can't possibly win the fight. I'll copy the EnemyAttackSystem
into a new FriendlyAttackSystem
and invert the logic between Friendly
and Hostile
entities. Ideally these would just be one AttackSystem
but copying it is so much quicker, and none of this code needs to survive to the final product.
The copied system totally works as intended, except I would to distinguish between Alive
and Dead
entities, and only allow Alive
entities to attack. To accomplish this, I've created a new LivenessSystem
, which detects when an entity's health reaches 0
, removes the Alive
component, and adds the Dead
component. The attack systems now only deal with Alive
entities. After implementing all of that, a bug appeared: if an attack system cannot find any possible targets, I was still trying to access the first element in the empty vector of targets. I've added the easy fix to both attack systems, and everything works as expected.
I've run the application a handful of times now, and it seems it is just barely impossible for the "friendly" team to win without some sort of intervention - but I have not yet implemented any way for the player to intervene.
Healing Friendly Units
I'll create a new PlayerControllerSystem
whose function will be similar to the former ProgressTestSystem
.
src/linguine/systems/PlayerControllerSystem.cpp
#include "PlayerControllerSystem.h"
#include <glm/common.hpp>
#include "components/Alive.h"
#include "components/Friendly.h"
#include "components/Health.h"
#include "components/Tapped.h"
namespace linguine {
void PlayerControllerSystem::update(float deltaTime) {
findEntities<Friendly, Alive, Health, Tapped>()->each([](const Entity& entity) {
auto health = entity.get<Health>();
health->current = glm::clamp(health->current + 50, 0, health->max);
});
}
} // namespace linguine
I love how easy it is to add these features with an ECS architecture!
It's now totally possible to "win" the battle, so I'm going to make one more major adjustment: when the enemy has been defeated, I'll spawn a new wave of enemies, with each wave consisting of one more enemy than the previous wave. To do so, I'll move the enemy creation logic out of the scene and into a new EnemySpawnSystem
, which will query for any living enemies. When the number of living enemies reaches zero, it will construct the new wave of enemies using a createEnemy()
method, which just requires the position of the new enemy.
linguine/src/systems/EnemySpawnSystem.cpp update()
void EnemySpawnSystem::update(float deltaTime) {
auto alive = findEntities<Hostile, Alive>()->get();
if (alive.empty()) {
findEntities<Hostile>()->each([](Entity& entity) {
entity.destroy();
});
int enemyCount = ++_wave;
for (int i = 0; i < enemyCount; ++i) {
createEnemy({-static_cast<float>(enemyCount - 1) / 2.0f * 1.5f + static_cast<float>(i) * 1.5f, 3.0f, 0.0f});
}
}
}
I'm going to zoom the camera out a little, so that more entities can be displayed horizontally. In the CameraSystem
, I just set the _height
constant to 15.0f
, up from 10.0f
. I also added a static position to Alfredo's window so that it's easier for me to deal with video capture moving forward.
When spam clicking on an entity, occasionally the drag handler in the GestureRecognitionSystem
is unintentionally triggered, moving the camera in some random direction. Whatever causes this also appears to make the drag handler "stuck", so you can't move the camera back. I don't think this is an important feature for this prototype, so I'm just going to remove all drag-related code altogether.
Now that is the stress-inducing game play I've been talking about.
Play Testing
I pushed the game to my phone so that my wife could try it out. Here are my observations:
- It is actually kind of fun! She made it to wave #5 and said she would be done after that wave, but couldn't put the game down once wave #6 popped up.
- She is smarter than me, and figured out how to use the engine's multi-touch support to her advantage.
- She was cramping up due to how fast she was tapping her fingers on the screen to keep up with the incoming enemy damage. While I do intend for the game play to be stressful, I certainly don't intend for people to hurt themselves.
- The mouse cursor isn't a big obstacle when playing the game in Alfredo, but when playing it from Scampi on a real device, fingers hide the entities (and therefore their health), which can be frustrating when you're already tapping the screen at full speed.
- Because enemies always choose an entity to attack at the exact same time, there is a chance most of them (if not all of them) will choose the same entity, causing a big chunk of damage.
- If one of your entities dies, it becomes even more likely for the enemies to focus on one target (since they have fewer options to choose from).
Here is some of her feedback:
- There should be more friendly entities.
- She probably wouldn't play it again like this.
- Her elbow and wrist were cramping up!
- The stupid triangles take too long to die, there should be a way to damage them directly.
All of this is very valuable information! This is about the point where I gave up on this idea last time I attempted it, but this time I will persevere.
Saving the Players from Themselves
The biggest thing I need to address is that the spammable inputs clearly cause discomfort. Technically, if you were physically capable, you could tap the screen 120 times per second, and the game would respect every single one of those inputs. Let's take a look at how other games handle this.
Overcooked! has a remarkably simple set of controls. Outside of its movement controls, it really only has a single "interact" button, which the player must hold down in order to perform the sub-tasks necessary to perform their role. The sub-tasks, such as chopping vegetables or washing dishes, are measured using a progress bar. The progress of that bar only increases as the user holds the button down. This is clearly better than spamming that button as fast as possible.
World of Warcraft, on the other hand, has a remarkably complex control scheme, which is fully customizable by the player. A player's character might have dozens of possible spells which they can cast, and each one can be bound to a button of the player's choice. Each spell can either be instant or have a cast time. Instant-cast spells trigger immediately when the player presses the button bound for that spell. Spells with a cast time, on the other hand, begin casting when the player presses the appropriate button. The button does not need to be held down to finish the cast, but any action taken before the end of the cast will interrupt the cast entirely, and the spell will fail. Each spell might further have a "cooldown" - a period of time the player must wait after casting that spell before they are able to cast that spell again.
In order to prevent players from spamming instant-cast spells without cooldowns, most spells are subject to a "global cooldown" - a period of time the player must wait after casting any spell before they are able to cast any other spell. There are some spells in the game which are not subject to the global cooldown, but they are intentionally designed to work in ways that complement the global cooldown instead. The default global cooldown is 1.5 seconds, but some classes have a default of 1 second. It is possible to customize a character's stats in order to reduce the global cooldown, down to a minimum of 0.75 seconds.
If we were to use World of Warcraft's minimum global cooldown of 0.75 seconds, then we could only cast 4 of our instant heals per 3 seconds, rather than the current theoretical maximum of 360 heals per 3 seconds.
The fighting game genre is very different from the type of game play I'm trying to achieve, but I still think it is interesting to consider. When a player inputs the correct sequence of buttons, an ability is triggered. Each ability takes a particular amount of frames to execute. Obviously this can cause problems with variable frame rates, but fighting games tend to use fixed frame rates (such as our fixedUpdate()
method). Even if we decided that our healing ability took 2 frames to execute, that would reduce our theoretical maximum to only 180 heals per 3 seconds.
I think it's clearly a bad idea to require a "press and hold" system on a touchscreen. It's hard enough to see things behind your fingers as it is! I also don't think relying so heavily on the fixed frame rate is a good idea, since we're not actually worried about any physical interactions (at least not yet). Imposing a global cooldown sounds good in theory, but there would have to be some way to indicate such a limitation to the player.
WoW has yet another type of spell: "channeled" spells begin as soon as the button is pressed, but they deal damage (or healing) incrementally as long as the player maintains the channel. This might be the most appropriate type of system for a touchscreen, since the player can select the entity that they want to start healing, and simply leave it selected until they want to switch targets. A handful of instant-cast spells which are not channeled still provide their effects over time, with a maximum duration. This could also be a useful type of mechanic, but it's important to remember that the user has very limited actions that they can perform using a touchscreen.
I appreciate the simplicity of Overcooked!'s interaction button. The answer to the question "How do I use this thing?" is always the same, even though the actual action that the player performs can be different based on the current context. I think the context-sensitivity of a single action would translate very well to a mobile game.
I feel like I'm stuck overanalyzing the possibilities, and I need to just pick something and see how it feels. I struggle with the idea of spending too much time building features that might just be thrown away, but that's sort of the entire point of prototyping: the goal is to spend the least amount of time implementing these features by avoiding spending any time on artwork or other polish.
I'm going to come at it with a very rigid approach. I'll attempt to replicate the healing mechanics of World of Warcraft as a base, and then iterate from there. I can already see how some of the mechanics would be somewhat difficult to execute effectively on a touchscreen (for example, choosing between multiple spells), but once we get a more substantial base, it should be easier to make tweaks.
The first thing I'll implement is the "global cooldown", which should immediately make the game less physically intensive. I've added a new Cooldown
component, with elapsed
and total
values. I constructed a new entity in the scene to display this cooldown using a ProgressFeature
and set its color to a shade of orange. I disabled the Renderable
to start with, since I only want the cooldown to show up when the player is not currently capable of casting a spell. I set both the elapsed
and total
values of the new Cooldown
component to 1.5f
, to mimic WoW's global cooldown.
I added a new CooldownProgressSystem
, which is incredibly similar to the HealthProgressSystem
. The new system just updates the progress feature for any entities containing a Cooldown
based on the elapsed
and total
values. The biggest difference is that it enables the Renderable
if the current progress is less than 1.0f
.
Lastly, I updated the PlayerControllerSystem
to prevent the ability to heal a tapped entity if the cooldown's elapsed
value is less than its total
, and reset its elapsed
value whenever a heal was successful, in order to "trigger" the cooldown.
It definitely works, but it's much harder to keep up with the incoming damage now that we're so limited by how often we can heal our units. This can be mitigated with a bit of tuning. Rather than only healing for 50 HP, our heals will now recover a whopping 500 HP. This feels much better until wave 4, when the incoming damage is still way too much to handle. More thorough tuning and balancing will have to wait until much later, once we've figured out a good set of fun mechanics.
Adding Some Variety
Currently, the player is only capable of executing a single action: an instant-cast heal on whichever entity they choose. While there is beauty in the simplicity, we can add a lot of depth to the game play by introducing multiple spells that the player may choose from. Different spells can have different characteristics - some might require a longer cooldown, others might require a cast time before the heal actually occurs.
The difficulty in implementing multiple spells lies in the input system. What is the best way to allow the player to select from different spells? Let's brainstorm some options.
- Prior to selecting an entity, the player must first select a spell from a row of possibilities at the bottom of the screen.
- In addition to the row of spells at the bottom, allow the player to select no spell, which causes a "default" spell to be cast (basically the same behavior we have now).
- Rather than a separate row of spells, allow the player to "bind" spells to different types of gestures (tap, long press, two-finger press, etc).
- "Drag" a spell onto an entity to cast it.
- "Chain" a spell across multiple entities by dragging across them.
I'm sure the answer lies somewhere in the midst of all of these options. Rather than deal with any new input system, I'm going to add a button at the bottom of the screen, which casts a relatively powerful heal. The heal will have its own rather long cooldown, but can be used to get out of particularly tough situations.
I've renamed the existing Cooldown
component to GlobalCooldown
, so that I can use the name Cooldown
to represent the cooldown of each separate spell. I updated the CooldownProgressSystem
to keep track of both Cooldown
and GlobalCooldown
entities.
I've also created a new BigHeal
component, with a power
value of 500
. Within the scene, I've created a new entity containing Transform
, Progressable
, Selectable
, BigHeal
, and Cooldown
components. I set its position toward the bottom of the screen, and its color to green.
Finally, I updated the PlayerControllerSystem
to handle the new BigHeal
.
linguine/src/systems/PlayerControllerSystem.cpp
#include "PlayerControllerSystem.h"
#include <glm/common.hpp>
#include "components/Alive.h"
#include "components/BigHeal.h"
#include "components/Cooldown.h"
#include "components/Friendly.h"
#include "components/GlobalCooldown.h"
#include "components/Health.h"
#include "components/Tapped.h"
namespace linguine {
void PlayerControllerSystem::update(float deltaTime) {
findEntities<GlobalCooldown>()->each([this](const Entity& entity) {
auto globalCooldown = entity.get<GlobalCooldown>();
if (globalCooldown->elapsed >= globalCooldown->total) {
findEntities<Friendly, Alive, Health, Tapped>()->each([globalCooldown](const Entity& entity) {
auto health = entity.get<Health>();
health->current = glm::clamp(health->current + 500, 0, health->max);
globalCooldown->elapsed = 0.0f;
});
findEntities<BigHeal, Tapped, Cooldown>()->each([this, globalCooldown](const Entity& entity) {
auto cooldown = entity.get<Cooldown>();
if (cooldown->elapsed >= cooldown->total) {
auto bigHeal = entity.get<BigHeal>();
findEntities<Friendly, Alive, Health>()->each([bigHeal](const Entity& entity) {
auto health = entity.get<Health>();
health->current = glm::clamp(health->current + bigHeal->power, 0, health->max);
});
cooldown->elapsed = 0.0f;
globalCooldown->elapsed = 0.0f;
}
});
}
});
}
} // namespace linguine
This code is getting a little out of hand, but essentially it checks to make sure the player is allowed to cast a spell within the bounds of the global cooldown, and then it checks if the player tapped an entity (and heals it, if so) or if the player tapped the big heal within the bounds of its own cooldown (and heals all friendly entities, if so). In both cases, the global cooldown is reset. In the case of the big heal being cast, its own cooldown is also reset.
The first couple of waves are pretty easy to get through, but wave 4 is very challenging. To test out the new big heal, I'll set the first wave to spawn on startup to be the 4th wave by setting the _wave
variable in the EnemySpawnSystem
to 3
.
I enjoy having more than one option available to me. I don't actually make it very much farther toward defeating the wave of enemies, but that is largely due to how unfair the tuning is, and that's totally fine. The point is that the game is more fun having a separate button with a more powerful ability than it was without it.
8.4 Projectiles
The randomness of the damage feels a bit unfair. In actual MMORPGs, the incoming damaging abilities are very telegraphed - a term used to describe that the game is informing the player of something before it happens. With the current system, the player has no time to prepare for incoming damage. That is to say, the game is purely reactive with no proactive elements.
To make the game play more proactive, I would like to illustrate the incoming damage before it happens. In WoW, you can see the player that an enemy is currently targeting and what spell that enemy is currently casting. Handling different spells appropriately certainly requires a bit of learning on the player's part, but at least it isn't completely unexpected.
Our game doesn't have formal targeting or cast time indicators (at least not yet). Instead of implementing those, I'd like to experiment with a different route entirely: I'd like to change the direct damage to visual projectiles that are emitted from the entities. Being able to see the projectiles before they make contact will allow the player to prepare for that incoming damage, as well as the magnitude of the damage each entity is about to endure.
There are a few notably missing features that I will have to implement in order to achieve this. Most notably, I will have to build a physics system capable of detecting collisions between certain entities so that damage can be distributed appropriately. Our renderer is currently unable to adjust the visual size of its entities, so I'll also have to implement the ability to scale those entities if I want to portray small projectiles using the same render features we currently have.
Scaling Entities
Luckily, adding a scale to our renderable entities is relatively easy. All we have to do is encode the change in scale into the model matrix, which can be achieved with a simple glm::scale()
. I'll add a glm::vec3
named scale
to the Transform
component, and default it to { 1.0f, 1.0f, 1.0f }
. All I have to do from here is update the TransformationSystem
to take the scale into account for the features which contain a model matrix (ColoredFeature
, ProgressableFeature
, and SelectableFeature
).
linguine/src/systems/TransformationSystem.cpp snippet
drawable->feature->modelMatrix = glm::scale(
glm::translate(glm::mat4(1.0f), transform->position) * glm::mat4_cast(transform->rotation),
transform->scale
);
As a quick test, I've updated the scale of the "big heal" button to glm::vec3(0.5f)
and verified that it is displayed with half the size it was before. I then changed it to 1.25f
and verified that it's 25% bigger than the other quads.
Simple Physics
Physics engines can be extraordinarily complicated, but they can also be remarkably simple. It all comes down to what types of possible geometries can be simulated within the engine. While our renderer is technically capable of drawing arbitrary shapes in the form of vertex meshes, our physics engine will explicitly not support collisions between arbitrary meshes. Instead, we will stick to very simple shapes - namely, circles, defined by a radius. I'll start by declaring a CircleCollider
component, consisting of a radius
value, defaulting to 0.5f
.
Why circles instead of quads, like our renderable entities? The math for axis-aligned boxes isn't particularly complicated, but there is a bit of additional complexity when you want to support arbitrary rotations of those boxes. With circles, the math is extremely simple, and doesn't change when rotating about the Z-axis (which is the only axis about which we would rotate for a 2-dimensional game).
The calculation to determine if two circles are colliding is pretty straight-forward:
The problem with this very simple formula is that you have to check every collider against every other collider, which, when done naively, is a simple O(n^2)
algorithm (a nested for
loop). As with most things, there are ways to optimize by skipping comparisons for colliders that are known to be far away from one another. For now, I'll just implement the naive algorithm to get the ball rolling. I've added a new CollisionSystem
for this purpose.
linguine/src/systems/CollisionSystem.cpp
#include "CollisionSystem.h"
#include "components/CircleCollider.h"
#include "components/Transform.h"
namespace linguine {
void CollisionSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<Transform, CircleCollider>()->each([this](const Entity& entity) {
auto idA = entity.getId();
auto transformA = entity.get<Transform>();
auto colliderA = entity.get<CircleCollider>();
findEntities<Transform, CircleCollider>()->each([this, idA, &transformA, &colliderA](const Entity& entity) {
auto idB = entity.getId();
if (idA == idB) {
return;
}
auto transformB = entity.get<Transform>();
auto colliderB = entity.get<CircleCollider>();
auto distance = glm::distance(transformA->position, transformB->position);
auto isColliding = distance <= colliderA->radius + colliderB->radius;
if (isColliding) {
_logger.log("Entity " + std::to_string(idA) + " is colliding with entity " + std::to_string(idB));
}
});
});
}
} // namespace linguine
This is our first use of the fixedDeltaTime()
function. The consistent processing rate of this function will ensure that our physics calculations are highly deterministic.
My Wife's Hobbies
For the last 5 or 10 minutes, my wife has been reading over the code and paragraphs that I've been writing, and replacing all of the L's and R's with W sounds. Now she's stuck figuring out how to pronounce the letter W with this rule.
Rendering Issues
I wanted to test the new collision logic, so I added the CircleCollider
component to all of the friendly entities. I then created a new entity, and added a CircleCollider
, along with a Transform
and Drawable
. I created a Renderable
with a ColoredFeature
(since I don't need to display "progress" for this entity), and set its color to yellow. I set its position to a location where I expected the collision system to find a collision, and ran the application. To my surprise, the new entity was not visible, but the collision logic was working correctly.
I moved the location of the new entity to a location where I did not expect a collision to occur, re-ran the application, and the collision logs stopped as expected, but I still could not see the new entity.
In the MetalRendererImpl
class, I rearranged the order of the feature renderers so that the ColoredFeatureRenderer
came last. After running the application again, I was greeted with a yellow quad, but no other entities were visible at all!
The problem is that each feature renderer was starting its own render pass using a render pass descriptor which was created by the MTKView
. The view is configured to have a clear color (as well as a clear depth value), and so the resulting render pass descriptor is configured to clear the resulting image to the clear color! This didn't matter before because the ColoredFeatureRenderer
came first, but didn't actually draw any entities. When we added a new entity for the ColoredFeatureRenderer
to draw, it successfully drew it, but then the ProgressFeatureRenderer
cleared the framebuffer image prior to drawing its entities, erasing the result of the ColoredFeatureRenderer
.
Luckily, the render pass descriptor returned by the MTKView
can be altered according to our use case after the fact. Since the descriptor is initially configured to "clear" the color and depth attachments on load, we'll just reconfigure the descriptor to "load" the color and depth attachments instead, after the first feature has been drawn.
I'm also going to take this opportunity to also modify our Camera
to contain a configurable clearColor
, like I mentioned earlier, rather than hard-coding it in the renderer.
renderers/metal/src/MetalRenderer.cpp snippet
void MetalRendererImpl::doDraw() {
auto pool = NS::AutoreleasePool::alloc()->init();
auto clearColor = getCamera().clearColor;
_view.setClearColor(MTL::ClearColor::Make(clearColor.r, clearColor.g, clearColor.b, 1.0f));
_view.setClearDepth(1.0f);
_context.commandBuffer = _context.commandQueue->commandBuffer();
_context.coloredRenderPassDescriptor = _view.currentRenderPassDescriptor();
for (const auto& feature : getFeatures()) {
feature->draw();
_context.coloredRenderPassDescriptor->colorAttachments()->object(0)->setLoadAction(MTL::LoadActionLoad);
_context.coloredRenderPassDescriptor->colorAttachments()->object(0)->setStoreAction(MTL::StoreActionStore);
_context.coloredRenderPassDescriptor->depthAttachment()->setLoadAction(MTL::LoadActionLoad);
_context.coloredRenderPassDescriptor->depthAttachment()->setStoreAction(MTL::StoreActionStore);
}
_context.commandBuffer->presentDrawable(_view.currentDrawable());
_context.commandBuffer->commit();
pool->release();
}
Totally my bad.
Back to Physics
Now I can visually verify whether my collision detection is working as intended... well, mostly. My entities are visually still quads, even though the collision detection is for circles. More accurately, I can more easily verify the position of the CircleCollider
, whose circle should have a diameter matching the size of the quad that is displayed on the screen.
If I place my new test object at { -1.0, 0.0, 0.0 }
(exactly between two of my friendly entities at { -2.0, 0.0, 0.0 }
and { 0.0, 0.0, 0.0 }
), then I see logs indicating that the new object is colliding with both entities, since the circles of the three objects would be touching one another exactly.
Entity 2 is colliding with entity 6
Entity 1 is colliding with entity 6
Entity 6 is colliding with entity 2
Entity 6 is colliding with entity 1
If I adjust the radius of the collider to 0.25f
(down from the default of 0.5f
), then the collisions are no longer reported. In order to properly visualize the reduction in the collider's radius, I'd also have to set the scale of the visual object to 0.5f
. Many game engines take an object's visual scale into account for the purposes of physics calculations, but others keep the concepts entirely decoupled. I'll choose to keep them decoupled for now, but that decision might bite me later.
Before I discovered the issues with the renderer, I mentioned that different optimizations might be made to reduce the total number of physics calculations required for each frame. One such optimization is to narrow down the possibilities based on the use case. In our case, we know that we want projectiles which collide with targets. Rather than checking collisions for all colliders against every other possible collider, we can simply check collisions for all projectile colliders against every possible target collider. Further optimizations can be made later.
I've consolidated the attackSpeed
, attackPower
, and attackTimer
values into a new Unit
component so that I can re-use the Friendly
and Hostile
components for projectiles. Doing so required a handful of changes to the EnemySpawnSystem
, EnemyAttackSystem
, and FriendlyAttackSystem
, as well as adding the Unit
component to the friendlies in the scene setup.
At this point, we can update the CollisionSystem
to check for collisions between Hostile
Projectile
s against Friendly
Unit
s, as well as Friendly
Projectile
s against Hostile
Unit
s.
linguine/src/systems/CollisionSystem.cpp
#include "CollisionSystem.h"
#include "components/CircleCollider.h"
#include "components/Friendly.h"
#include "components/Hostile.h"
#include "components/Projectile.h"
#include "components/Transform.h"
#include "components/Unit.h"
namespace linguine {
void CollisionSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<Hostile, Projectile, Transform, CircleCollider>()->each([this](const Entity& a) {
findEntities<Friendly, Unit, Transform, CircleCollider>()->each([this, &a](const Entity& b) {
if (checkCollision(a, b)) {
_logger.log("Hostile Projectile " + std::to_string(a.getId()) + " is colliding with Friendly Unit " + std::to_string(b.getId()));
}
});
});
findEntities<Friendly, Projectile, Transform, CircleCollider>()->each([this](const Entity& a) {
findEntities<Hostile, Unit, Transform, CircleCollider>()->each([this, &a](const Entity& b) {
if (checkCollision(a, b)) {
_logger.log("Friendly Projectile " + std::to_string(a.getId()) + " is colliding with Hostile Unit " + std::to_string(b.getId()));
}
});
});
}
bool CollisionSystem::checkCollision(const Entity& a, const Entity& b) {
if (a.getId() == b.getId()) {
return false;
}
auto transformA = a.get<Transform>();
auto transformB = b.get<Transform>();
auto colliderA = a.get<CircleCollider>();
auto colliderB = b.get<CircleCollider>();
auto distance = glm::distance(transformA->position, transformB->position);
return distance <= colliderA->radius + colliderB->radius;
}
} // namespace linguine
To test the logic, I'll adjust our new entity to be a Hostile
Projectile
and place it on the edge of our center-most Friendly
Unit
.
If I change the projectile to be Friendly
instead of Hostile
, then the logs go away! Perfect.
Firing Projectiles
While the collision detection works, our projectile doesn't currently move. Moving an object across the screen isn't anything new, but doing so using the fixed time step isn't quite as trivial as you might expect. Since fixedUpdate()
is called at a different rate from the renderer, entities that are moved using fixedUpdate()
appear to be "choppy". You can imagine that sometimes an entity is drawn in the same location as the previous frame (because fixedUpdate()
wasn't called at all between the draw calls).
The solution to this is to actually render the entities based on where they were in the past, and linearly interpolate their current renderable position between two historical positions. This strategy is described in more detail in Glenn Fiedler's blog post, Fix Your Timestep!, which I mentioned back in chapter 2 when I was first adding support for the fixed time step.
We'll start by adding a velocity
value to the Projectile
component. We'll also create a new Hit
component, which will contain the IDs of all of the entities which were hit. We'll update the CollisionSystem
to remove all of the Hit
components from the previous frame, and then re-calculate the hits for the current frame.
linguine/src/systems/CollisionSystem.cpp snippet
void CollisionSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<Hit>()->each([](Entity& entity) {
entity.remove<Hit>();
});
findEntities<Hostile, Projectile, Transform, CircleCollider>()->each([this](Entity& a) {
findEntities<Friendly, Unit, Transform, CircleCollider>()->each([&a](const Entity& b) {
detectHit(a, b);
});
});
findEntities<Friendly, Projectile, Transform, CircleCollider>()->each([this](Entity& a) {
findEntities<Hostile, Unit, Transform, CircleCollider>()->each([&a](const Entity& b) {
detectHit(a, b);
});
});
}
void CollisionSystem::detectHit(Entity& a, const Entity& b) {
if (checkCollision(a, b)) {
if (a.has<Hit>()) {
a.get<Hit>()->entityIds.push_back(b.getId());
} else {
a.add<Hit>()->entityIds = { b.getId() };
}
}
}
From here, I'll whip up a new ProjectileSystem
, who will be responsible for moving the projectiles, as well as handling any detected collisions.
linguine/src/systems/ProjectileSystem.cpp
#include "ProjectileSystem.h"
#include "components/Hit.h"
#include "components/Projectile.h"
#include "components/Transform.h"
namespace linguine {
void ProjectileSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<Projectile, Hit>()->each([this](const Entity& entity) {
auto hit = entity.get<Hit>();
for (const auto entityId : hit->entityIds) {
_logger.log("Projectile " + std::to_string(entity.getId()) + " hit Unit " + std::to_string(entityId));
}
});
findEntities<Projectile, Transform>()->each([fixedDeltaTime](const Entity& entity) {
auto projectile = entity.get<Projectile>();
auto transform = entity.get<Transform>();
transform->position += projectile->velocity * fixedDeltaTime;
});
}
} // namespace linguine
To emphasize the choppiness of the projectile, I'll increase the _fixedDeltaTime
constant in the Engine
to 0.2f
(only 5 frames per second).
Next I'll create a PhysicalState
component, containing a previousPosition
and currentPosition
. The positions in this component will only be 2-dimensional vectors, since we only need to deal with 2D physics. Using 3-dimensional vectors can actually be detrimental to our physics calculations, because we might use the Z component to indicate rendering depth, but don't actually want it to apply to our collision detection. Systems dealing with physically simulated entities in their fixedUpdate()
method will use the currentPosition
of the PhysicalState
component, rather than the position
of the Transform
component.
Finally, I'll create a PhysicsInterpolationSystem
, which will update the previousPosition
at the beginning of every fixed time step, and interpolate between the previousPosition
and currentPosition
as part of its render-based time step. That means this will be the first system that implements both the update()
and fixedUpdate()
methods.
In order to properly determine the interpolation factor, we'll have to keep track of the amount of time that has passed since the last fixedUpdate()
, and divide that by the expected fixed time step. Keeping track of the time since the last fixedUpdate()
is trivial, but the update()
method doesn't currently have access to the fixed delta time. Rather than doing some weird roundabout logic to keep track of the last known fixedDeltaTime
, I'll add a function to our TimeManager
class to access it, and update the Engine
to use it as well.
linguine/include/TimeManager.h
#pragma once
#include <ctime>
namespace linguine {
class TimeManager {
public:
[[nodiscard]] virtual time_t currentTime() const = 0;
[[nodiscard]] virtual float durationInSeconds(time_t from, time_t to) const = 0;
[[nodiscard]] float getFixedTimeStep() const {
return _fixedDeltaTime;
}
private:
constexpr static float _fixedDeltaTime = 0.2f;
};
} // namespace linguine
linguine/src/systems/PhysicsInterpolationSystem.cpp
#include "PhysicsInterpolationSystem.h"
#include <glm/gtx/compatibility.hpp>
#include "components/PhysicalState.h"
#include "components/Transform.h"
namespace linguine {
void PhysicsInterpolationSystem::update(float deltaTime) {
_timeSinceLastFixedUpdate += deltaTime;
const auto lerpFactor = _timeSinceLastFixedUpdate / _timeManager.getFixedTimeStep();
findEntities<PhysicalState, Transform>()->each([lerpFactor](const Entity& entity) {
auto physicalState = entity.get<PhysicalState>();
auto transform = entity.get<Transform>();
const auto lerpPosition = glm::lerp(
physicalState->previousPosition,
physicalState->currentPosition,
lerpFactor
);
transform->position.x = lerpPosition.x;
transform->position.y = lerpPosition.y;
});
}
void PhysicsInterpolationSystem::fixedUpdate(float fixedDeltaTime) {
findEntities<PhysicalState>()->each([](const Entity& entity) {
auto physicalState = entity.get<PhysicalState>();
physicalState->previousPosition = physicalState->currentPosition;
});
_timeSinceLastFixedUpdate = 0.0f;
}
} // namespace linguine
Now our projectile is smoothly making its way across the screen, even though the fixed time step is only 5 FPS! The downside of using such an infrequent fixed time step is that your physics engine is much more likely to "miss" collisions for faster moving entities. The previous fixed time step of 50 FPS seemed perfectly fine so far, so I'll update the TimeManager
's _fixedDeltaTime
to 0.02f
.
As a quick test, I'll update the ProjectileSystem
to destroy the projectile once a collision has been detected.
linguine/src/systems/ProjectileSystem.cpp snippet
findEntities<Projectile, Hit>()->each([this](Entity& entity) {
auto hit = entity.get<Hit>();
for (const auto entityId : hit->entityIds) {
_logger.log("Projectile " + std::to_string(entity.getId()) + " hit Unit " + std::to_string(entityId));
}
entity.destroy();
});
Whelp, because the Hostile
projectile is created on top of a Friendly
unit, it is destroyed instantly. I'll change it to be Friendly
so that it moves up the screen, and disappears once it meets an enemy.
With that seemingly working correctly, I can update the EnemyAttackSystem
and FriendlyAttackSystem
to create projectiles instead of dealing damage to their targets directly. I've also added a power
value to the Projectile
component so it can deal its damage when the collision is detected.
linguine/src/systems/ProjectileSystem.cpp snippet
findEntities<Projectile, Hit>()->each([this](Entity& entity) {
auto projectile = entity.get<Projectile>();
auto power = projectile->power;
auto hit = entity.get<Hit>();
for (const auto entityId : hit->entityIds) {
auto target = getEntityById(entityId);
auto health = target->get<Health>();
health->current = glm::clamp<int32_t>(health->current - power, 0, health->max);
}
entity.destroy();
});
linguine/src/systems/EnemyAttackSystem.cpp
#include "EnemyAttackSystem.h"
#include <random>
#include <glm/common.hpp>
#include "components/Alive.h"
#include "components/CircleCollider.h"
#include "components/Drawable.h"
#include "components/Friendly.h"
#include "components/Hostile.h"
#include "components/PhysicalState.h"
#include "components/Projectile.h"
#include "components/Transform.h"
#include "components/Unit.h"
namespace linguine {
void EnemyAttackSystem::update(float deltaTime) {
auto friendlies = findEntities<Friendly, Alive>()->get();
if (friendlies.empty()) {
return;
}
auto random = std::random_device();
auto randomEntity = std::uniform_int_distribution<>(0, static_cast<int>(friendlies.size() - 1));
findEntities<Hostile, Unit, Alive, Transform>()->each([this, deltaTime, &friendlies, &random, &randomEntity](const Entity& entity) {
auto unit = entity.get<Unit>();
auto transform = entity.get<Transform>();
if (unit->attackTimer >= unit->attackSpeed) {
unit->attackTimer -= unit->attackSpeed;
auto index = randomEntity(random);
auto target = friendlies[index];
auto targetTransform = target->get<Transform>();
auto direction = glm::vec2(targetTransform->position) - glm::vec2(transform->position);
auto velocity = direction;
createProjectile(transform->position, velocity, unit->attackPower);
}
unit->attackTimer += deltaTime;
});
}
void EnemyAttackSystem::createProjectile(glm::vec2 location, glm::vec2 velocity, int32_t power) {
auto entity = createEntity();
entity->add<Hostile>();
auto projectile = entity->add<Projectile>();
projectile->velocity = velocity;
projectile->power = power;
auto transform = entity->add<Transform>();
transform->position = glm::vec3(location, 2.0f);
transform->scale = glm::vec3(0.25f);
auto physicalState = entity->add<PhysicalState>();
physicalState->previousPosition = glm::vec2(transform->position);
physicalState->currentPosition = physicalState->previousPosition;
auto collider = entity->add<CircleCollider>();
collider->radius = 0.125f;
auto drawable = entity->add<Drawable>();
drawable->feature = new ColoredFeature();
drawable->feature->meshType = Quad;
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();
});
}
} // namespace linguine
The FriendlyAttackSystem
is very similar, so I won't bother showing it, though I should probably consolidate the systems at some point. I also realized that the CollisionSystem
was never updated to use the PhysicalState
component instead of the Transform
component, so I made that update as well.
In testing this, I noticed that occasionally a single projectile would deal double the amount of damage that it was supposed to. After a lot of debugging (and even more logs), I finally traced the problem down to the entity query system itself. The CollisionSystem
has a couple of complex nested queries, but the actual problem was the fact that adding the Hit
component to an entity changes its archetype, and the underlying ArchetypeResult
iterates over all archetypes that match the query. Therefore, it's totally possible for a single entity to appear multiple times within a single query, if it happened to move to an archetype which is part of the ArchetypeResult
, but hasn't been iterated over yet. I've updated the ArchetypeResult
's each()
method to create an internal vector of entities, prior to invoking the provided function on any of them.
linguine/src/entity/archetype/ArchetypeResult.cpp snippet
void ArchetypeResult::each(const std::function<void(Entity&)>& function) const {
auto results = std::vector<Entity>();
for (const auto* archetype : _archetypes) {
archetype->each([this, &results](uint64_t id) {
results.emplace_back(_entityManager, id);
});
}
for (auto& entity : results) {
function(entity);
}
}
Doing it this way doesn't seem to meaningfully impact performance, so I'll roll with it. Now we have projectiles spewing between the separate factions!
Taking a Breath
I find myself opening the game just to play it. That's a really good sign. I want to show off what I have so far, but from past experience, if I show too many people, I'll be satisfied enough with the response that I won't continue working on the project.
I find myself actually referring to it as a "game" now instead of just an "app". It contains actual mechanics, a losing condition, and a foundation that we can continue to expand upon. Developing a physics system opens up an entire world of possibilities that I'm excited to explore.
One big caveat with our physics system: currently it cannot resolve collisions - resolving collisions involves preventing objects from passing through one another. We might come up with a mechanic that will involve such a feature, but for now, triggering events based on collisions is all we need.
I'm going to wrap up this chapter, because I think the introduction of the physics system rounds out the story arc - but we are definitely not done prototyping yet! As always, the code is available at this commit. I've been sacrificing the cleanliness of the code for the sake of rapid prototyping, but I think that's okay. We still managed to discover a handful of critical bugs throughout this chapter. My confidence in the engine grows with each new feature we add.