Prototyping Part 4
My wife asked me if I was going to work on my book tonight, to which I replied that I was getting annoyed with it. She wasn't having any of my bull crap, so here I am, struggling to come up with words to type.
I've watched a few YouTube videos about game prototyping, but I didn't find any of them particularly helpful. They tend to repeat the things I already know: don't waste your time on artwork, complicated bugfixes, or other types of polish - just play with the mechanics in their purest forms using basic shapes. In theory, you'll quickly rule out game ideas that aren't fun without having wasted much time working on something that nobody wanted.
My problem is coming up with mechanics that work well together. The core healing mechanic is satisfying (albeit anxiety-inducing), but doesn't stand well on its own. Even in MMORPGs, it requires several other mechanics to be at play before it even makes sense - there's nothing to heal if no one takes damage, and no one takes damage without enemies to fight.
So the last couple of chapters have been attempts to experiment with complementary mechanics to the healing mechanic, but they both focused heavily on movement. We determined in chapter 9 that it wasn't a great experience to move the health bars around while the player was trying to heal them. Therefore, chapter 10 focused on enemy movement instead, but it just felt wrong. The core healing mechanic itself is still fun, but the game as a whole feels frustrating, and I can't quite put my finger on why.
I want to blame the fact that I'm treating the health bars as physical units within the world, rather than just the interface to interact with the actual entities in the world. The problem with decoupling the health bars from the actual entity is that the entity could be anything, and that's a bit daunting. Naturally, my first thought is that the health bars could represent characters in an RPG which exist within a vast world to be explored. Building an RPG is kind of a trap for a solo developer - the amount of work required to build a world is extraordinary! I liked the idea of a grid-based tower defense game because it kept the scope down (compared to an RPG), and allowed me to focus on building small "levels".
Even an MMORPG like World of Warcraft focuses its content into bite-sized chunks: dungeons only consist of a handful of bosses, and can be completed in around 30 minutes. Even raid bosses are considered to be individual challenges, with each boss taking roughly 5-10 minutes (though I've seen particularly difficult raid boss encounters take nearly 30 minutes in the past). While the world itself is certainly vast, at any given point in the game's history, there are only a handful of relevant dungeons, and only one or two relevant raids (each consisting of a handful of bosses). The vastness of a modern MMORPG is a veil over the face of an otherwise level-oriented game. I am absolutely not discounting the sheer amount of work that goes into those games (it is substantial), I'm just pointing out that their design isn't all that far off from a casual level-oriented mobile game.
With that in mind, I'm going to try to translate an old idea for an RPG into something more level-oriented. The idea was heavily inspired by Pokémon and Pikmin, in which you could collect creatures to fight on your behalf. I imagined the creatures to be simple glowing lights, similar to wisps in other fantasy games. The wisps would orbit around the player, and attack any enemies that would get close by exerting their energy. It was the player's responsibility to replenish their energy before they faded into nothingness - almost like healing!
I'm kind of breaking the rules of prototyping by assigning a theme to the game, but this idea can be somewhat of a prompt to come up with complementary mechanics that make sense. The player can't possibly keep track of each individual small wisp quickly buzzing around, but if a health bar is depleted, then they can expect one of the wisps to disappear.
I had originally envisioned this game being played with a controller, with the healing mechanics being controlled via complex button combinations, and the character's movement simply being controlled by the joystick. We've already got a decent solution for the healing mechanics, but we'll obviously need to adapt the movement controls to be suitable for a mobile platform.
11.1 Character Movement
Moving a character using a touch screen as the input device can be frustrating. Many games utilize on-screen buttons to control the character, taking inspiration from physical joysticks and directional pads, but I find that solution to be revolting. The player can't "feel" the buttons they are pressing, and their fingers often veer into unintentional positions on the screen, resulting in a disconnect between their intent and their character's actual actions. Correcting their positioning also isn't trivial, because the player must move their fingers out of the way to even see the virtual buttons on the screen.
Long ago, I implemented an on-screen joystick that would appear on the screen whenever the player touched the screen, and dynamically change its positioning based on the original location of the touch's "down" event. This felt much better compared to a static joystick, once you learned how it worked. Unfortunately, everyone that I let play the game didn't understand it, requesting that I explain how it works. Honestly, I expected them to figure it out and was surprised by their confusion, but that is the value of play-testing.
If we stick to a top-down perspective of the world, then there's a much more intuitive way to move the character: just select the location that you want to move to! In our tile-based world, we can just make every tile selectable, and move toward the selected tile. Come to think of it, there are actually a couple of ways to handle this type of movement:
- Use our A* pathfinding algorithm to automatically navigate the character to the desired destination.
- Move the character toward the destination, regardless of any obstacles. Make the player move around obstacles by selecting different directions over time.
The former is more liberating for the player, allowing them to simply tap the screen and perform other actions in the meantime. This also allows the player to move their fingers away from the screen and observe the game world as they move along. We might require that the player must stop moving to perform certain actions, while others can be performed while moving to their next location.
The latter is more of a manual solution, but players might enjoy that level of control. The first complication that comes to mind with this solution is that it might not work intuitively if we don't allow the player to move in any direction. When they select a location on the screen, they would expect their character to move in that exact direction, not bound by "degrees of motion" (for example, our enemies currently only move with 4 degrees of motion: up, down, left, and right).
I'm gonna go with the fire-and-forget method, utilizing A*, for one primary reason: I want the player to spend their time healing, not spamming movement actions. If they want more control, then they are free to select nearby tiles in order to more finely control the movement path.
Starting Over... Again
I've created another scene, which I've named the PointAndClickPrototypeScene
. "Point and click" games are more focused on investigating different items by selecting them on the screen, but the term perfectly describes how the player will move their character around the world. I've given the new scene a light purple background; at this rate I'll run out of colors before I build a prototype I'm happy with.
The type of playing field that I've been imagining isn't really a grid - it's still tile-based, but without a rigid width or height. I imagine some areas being narrow, maybe one or two tiles wide, while others are open for the player to explore. This type of world could be thought of as a graph, where each tile is a node with connections to its neighboring tiles.
I don't really want to build out a level editor, and building out such a graph by hand would be incredibly tedious. Instead, I'll just re-use the Grid
that we have now, and maybe add obstructions to make it appear less like a rectangular area.
There really isn't much work required here, which is great. The existing EnemyTargetingSystem
utilized the Targeting
component to move toward another entity over time by recalculating the A* path whenever the GridPosition
's destination
was empty. Meanwhile, the GridPositionSystem
was responsible for actually moving the entity between grid cells. I'll update the GridPosition
component by renaming the position
field to transientDestination
, to represent the short-term movement between cells, and adding a finalDestination
field, to represent the long-term destination. This change will allow me to update the GridPositionSystem
to handle both the cell-to-cell movement, as well as recalculating the path to a final destination over time.
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->transientDestination) {
auto difference = glm::vec2(*gridPosition->transientDestination) - gridPosition->position;
auto frameDistance = gridPosition->speed * fixedDeltaTime;
if (glm::length2(difference) <= frameDistance * frameDistance) {
gridPosition->position = *gridPosition->transientDestination;
gridPosition->transientDestination = {};
} else {
auto direction = glm::normalize(difference);
gridPosition->position += direction * frameDistance;
}
}
if (!gridPosition->transientDestination && gridPosition->finalDestination) {
auto currentPosition = glm::round(gridPosition->position);
auto path = _grid.search(gridPosition->position, *gridPosition->finalDestination);
if (path.size() > 1) {
auto newPosition = *std::next(path.begin());
gridPosition->transientDestination = newPosition;
_grid.removeObstruction(currentPosition, gridPosition->dimensions);
_grid.addObstruction(newPosition, gridPosition->dimensions);
} else {
gridPosition->finalDestination = {};
}
}
auto physicalState = entity.get<PhysicalState>();
physicalState->currentPosition = _grid.getWorldPosition(gridPosition->position + glm::vec2(gridPosition->dimensions) / 2.0f - 0.5f);
});
}
} // namespace linguine
From here, it's just a matter of updating the finalDestination
every time the player selects a tile. I'll take care of that logic within a new PointAndClickMovementSystem
. I've added an empty Player
component to mark the entity which the player will directly control - I'm not sure how separate it will be from a Unit
, but I can always combine them later if needed.
linguine/src/systems/PointAndClickMovementSystem.cpp
#include "PointAndClickMovementSystem.h"
#include "components/GridPosition.h"
#include "components/Player.h"
#include "components/Tapped.h"
#include "components/Tile.h"
#include "components/Transform.h"
namespace linguine {
void PointAndClickMovementSystem::update(float deltaTime) {
findEntities<Tile, Tapped, Transform>()->each([this](const Entity& tileEntity) {
auto transform = tileEntity.get<Transform>();
findEntities<Player, GridPosition>()->each([this, &transform](const Entity& playerEntity) {
auto gridPosition = playerEntity.get<GridPosition>();
gridPosition->finalDestination = _grid.getGridPosition(transform->position);
});
});
}
} // namespace linguine
11.2 Orbital Mechanics
My original idea had each wisp orbiting around the player with a different axial tilt - like electron orbitals, rather than planetary orbital planes. This would result in the player being encapsulated within a shell of wisps, which would shield the player from incoming attacks. This idea doesn't really translate well to a top-down 2-dimensional perspective, so I'll just implement it as an orbital plane instead.
The actual math to move an object in a circle is very simple: given an angle in radians, set the x position to the sine of the angle, and the y position to the cosine of the angle.
A new Orbiter
component will contain the entity ID of the entity which should be the center of the orbit. It will also contain the current angle, the radius, and the speed of the orbit. The OrbitSystem
simply calculates the position of the entity using the angle and radius, and updates the angle based on the delta time and speed. In order to maintain high precision of the angle (and to avoid overflow over long periods of time), the angle is constantly reduced to stay within a range of 0 to 2π.
linguine/src/systems/OrbitSystem.cpp
#include "OrbitSystem.h"
#include "components/Orbiter.h"
#include "components/Transform.h"
namespace linguine {
void OrbitSystem::update(float deltaTime) {
findEntities<Orbiter, Transform>()->each([this, deltaTime](const Entity& orbiterEntity) {
auto orbiter = orbiterEntity.get<Orbiter>();
auto center = getEntityById(orbiter->centerId);
auto centerPosition = center->get<Transform>()->position;
auto transform = orbiterEntity.get<Transform>();
transform->position = centerPosition
+ glm::vec3(
glm::sin(orbiter->angle) * orbiter->radius,
glm::cos(orbiter->angle) * orbiter->radius,
0.0f
);
orbiter->angle += glm::two_pi<float>() * deltaTime * orbiter->speed;
while (orbiter->angle >= glm::two_pi<float>()) {
orbiter->angle -= glm::two_pi<float>();
}
});
}
} // namespace linguine
Multiple Orbiters
The easiest way to add more orbiters around the player would be to create another entity with an Orbiter
component within the scene constructor, and manually set its initial angle appropriately. However, I want to be able to dynamically add and remove orbiters from the player at runtime. If I want the orbiters to be equally distributed around the player, then that would involve changing the angle of all of the previously existing orbiters. I could opt to just ignore the spacing of the orbiters, but that would lead to lopsided or potentially overlapping orbiters, which I think would look tacky.
One option would be to iterate over all of the orbiters every time an orbiter is created or destroyed, recalculating their current angles based on the new number of orbiters. I could see this solution being the cause of several bugs, where I forgot to add the logic on some code path where an orbiter is created or destroyed.
Another solution is to remove the individual angle from the Orbiter
component, and simply render all of the current orbiters with equidistant angles based in the current timestamp. Let's give that a shot.
First, I'd like to create a new runtimeInSeconds()
method in the TimeManager
. The existing currentTime()
method returns a platform-specific time representation, which isn't necessarily measured in seconds. The Engine
utilizes the durationInSeconds()
method to measure the difference between return values. Our system could keep track of the currentTime()
at the start of the game, and use the durationInSeconds()
method to constantly measure the runtime, but I'll just do that directly inside of the TimeManager
, and expose the method for any system to utilize.
In implementing this, I found a copy/paste error from long ago, in which I accidentally named the class inside of MacTimeManager.h
to be IosTimeManager
. These types of errors are not uncommon, and it's not necessarily a "bug", since the code functions perfectly fine - but it is definitely wrong, so I'll fix it. I've also added default constructors to the MacTimeManager
and IosTimeManager
classes which pass in the appropriate start time to the base class constructor.
linguine/include/TimeManager.h
#pragma once
#include <ctime>
namespace linguine {
class TimeManager {
public:
explicit TimeManager(time_t startTime) : _startTime(startTime) {}
virtual ~TimeManager() = default;
[[nodiscard]] virtual time_t currentTime() const = 0;
[[nodiscard]] virtual float durationInSeconds(time_t from, time_t to) const = 0;
[[nodiscard]] float runtimeInSeconds() const {
return durationInSeconds(_startTime, currentTime());
}
[[nodiscard]] float getFixedTimeStep() const {
return _fixedDeltaTime;
}
private:
constexpr static float _fixedDeltaTime = 0.02f;
time_t _startTime;
};
} // namespace linguine
While making the changes to the OrbitSystem
, I noticed that the orbital speed of the entities appears to be way faster when you have more spinning entities than it does when you only have a few. I've removed the speed
value from the Orbiter
component entirely, and instead I'll calculate a speed value for all of the orbiters based on the current count.
linguine/src/systems/OrbitSystem.cpp
#include "OrbitSystem.h"
#include "components/Orbiter.h"
#include "components/Transform.h"
namespace linguine {
void OrbitSystem::update(float deltaTime) {
auto orbiters = findEntities<Orbiter, Transform>()->get();
if (!orbiters.empty()) {
auto speed = 0.25f + 0.5f / static_cast<float>(orbiters.size());
auto startAngle = _timeManager.runtimeInSeconds() * glm::two_pi<float>() * speed;
auto spacing = glm::two_pi<float>() / static_cast<float>(orbiters.size());
for (auto i = 0; i < orbiters.size(); ++i) {
auto orbiterEntity = orbiters[i];
auto orbiter = orbiterEntity->get<Orbiter>();
auto center = getEntityById(orbiter->centerId);
auto centerPosition = center->get<Transform>()->position;
auto angle = startAngle + spacing * static_cast<float>(i);
auto transform = orbiterEntity->get<Transform>();
transform->position = centerPosition
+ glm::vec3(
glm::sin(angle) * orbiter->radius,
glm::cos(angle) * orbiter->radius,
0.0f
);
}
}
}
} // namespace linguine
11.3 Combat
Now for some actual game play. I'm struggling to get started here, because I'm not quite sure how this should work. My vague idea involved the wisps sacrificing their health to damage nearby enemies, resulting in the player having to heal them. There are a couple of problems with this:
- If an individual wisp has a specific attack pattern, then changes to its health are very predictable.
- If a wisp takes damage by dealing damage, then what role do enemies play?
Admittedly, this idea was sort of half-baked. Let's flesh it out a bit more.
- Wisps attack enemies without sacrificing their own health.
- Similar to a traditional tower defense game, the enemies don't actually care about attacking the wisps that are attacking them (the "towers"), they only care about attacking the player (the "base").
- Unlike a tower defense game, the player's wisps intercept incoming attacks like a shield. When there are no wisps remaining, the player character takes all of the incoming damage.
These rules certainly clarify a lot, but there are still a few outstanding questions.
- Can players heal themselves?
- Does the player character attack enemies?
- Can the player control which enemy to attack?
- How do the enemies behave?
That last one is a doozy. The only way to determine the answers to these questions is to try some things out! That is, after all, what prototyping is for.
Adding Enemies... Again
I'll start by creating yet another red triangle on the grid. I noticed that when I move the player character close to the new enemy, the orbiters were Z-fighting with the new enemy. Since the orbiter's positions are based on the position of the player character, and the player character has the same Z value as the enemy, the orbiters were also being set to the same Z value. To resolve that, I updated the OrbitSystem
to reset the Z value of the orbiter after calculating its new position.
linguine/src/systems/OrbitSystem.cpp snippet
auto transform = orbiterEntity->get<Transform>();
auto z = transform->position.z;
transform->position = centerPosition
+ glm::vec3(
glm::sin(angle) * orbiter->radius,
glm::cos(angle) * orbiter->radius,
0.0f
);
transform->position.z = z;
It's hard to envision this style of game containing a single type of enemy. It's more likely that I'll end up adding tons of different types of enemies with greatly varying behavior. I get excited about the idea of building a dungeon crawler containing all sorts of enemies to overcome - but it's also a very daunting task.
For now I just need to keep it simple, so I'll just reuse the EnemyTargetingSystem
and AttackSystem
from the last prototype. I only actually care about melee attacks for now, so I'm just going to remove support for the explosion attack entirely. I'll add the MeleeAttack
component to our enemy entity, along with the Hostile
, Unit
, Health
, Alive
, and Targeting
components. I'll also change its Drawable
to a Progressable
so that we can keep track of its health.
Similarly, I'll add the Friendly
, Health
, and Alive
components to the player character, so that the enemy can properly target and attack it, though I'll only give the player character 100 HP, to make it super punishing if all of the wisps die.
After bringing in the LivenessSystem
and HealthProgressSystem
, the enemy moves toward the player character and, upon reaching it, crashes the game. Running the game in debug mode shows that the OrbitSystem
is trying to get the Transform
component of the player character (the orbiter's center), but the player character's entity was destroyed because the LivenessSystem
destroyed it after the enemy killed it in one hit. I don't actually intend this scenario to be possible, but I will check that the entity contains the Alive
component before grabbing its Transform
. Just as a sanity check, I'll increase the player character's health to 150 so that it doesn't die in a single hit.
Redirecting Attacks
While I do want the enemy to pursue the player character (which the EnemyTargetingSystem
is doing perfectly), I don't actually want it to deal damage to the player character unless all of the wisps are dead, which I can do by adjusting the AttackSystem
. When the enemy is in range of the player character, rather than dealing damage directly to the player character, we'll first check if there are any available orbiters to deal the damage to and, if so, deal damage to the first one that we find.
linguine/src/systems/AttackSystem.cpp snippet
auto orbiters = findEntities<Orbiter, Health>()->get();
if (orbiters.empty()) {
auto health = target->get<Health>();
health->current = glm::clamp<int32_t>(health->current - meleeAttack->power, 0, health->max);
} else {
auto health = orbiters[0]->get<Health>();
health->current = glm::clamp<int32_t>(health->current - meleeAttack->power, 0, health->max);
}
This means that we'll need to add the Health
and Alive
components to our orbiter entities. The LivenessSystem
also requires the GridPosition
component, so that it can remove obstacles when entities die. Instead of requiring it as part of the query, I'll just check for its existence, and remove the obstacle if needed.
linguine/src/systems/LivenessSystem.cpp
#include "LivenessSystem.h"
#include "components/Alive.h"
#include "components/GridPosition.h"
#include "components/Health.h"
namespace linguine {
void LivenessSystem::update(float deltaTime) {
findEntities<Alive, Health>()->each([this](Entity& entity) {
auto health = entity.get<Health>();
if (health->current <= 0) {
if (entity.has<GridPosition>()) {
auto gridPosition = entity.get<GridPosition>();
_grid.removeObstruction(glm::round(gridPosition->position), gridPosition->dimensions);
}
entity.destroy();
}
});
}
} // namespace linguine
At this point, the enemy approaches the player character, and destroys the orbiters one by one until it can finally attack the player character directly.
Decoupled Health Bars
The orbiters are way too small and move much too quickly for the player to heal effectively. Instead, we'll create static health bars at the bottom of the screen to enable the player to monitor and recover the health of the entities, similar to how many MMORPGs do it.
I'll create a new HealthBar
component, which just contains the entity ID of the entity whose health should be monitored by the health bar entity. I'll also update the HealthProgressSystem
to update the Progressable
component for any entities containing a HealthBar
(in addition to its current functionality). Since the orbiters will be destroyed over time, I need to take care not to grab components from entities that no longer exist.
linguine/src/systems/HealthProgressSystem.cpp snippet
findEntities<HealthBar, Progressable>()->each([this](Entity& healthBarEntity) {
auto healthBar = healthBarEntity.get<HealthBar>();
auto feature = healthBarEntity.get<Progressable>()->feature;
auto entity = getEntityById(healthBar->entityId);
if (entity->has<Health>()) {
auto health = entity->get<Health>();
feature->progress = glm::clamp(
static_cast<float>(health->current) / static_cast<float>(health->max),
0.0f,
1.0f
);
} else {
feature->progress = 0.0f;
}
});
I generally don't bother putting the scene code in this book anymore, because it's largely just compositional boilerplate, but here is how I'm centering the health bars at the bottom of the screen:
linguine/src/scenes/PointAndClickPrototypeScene.h snippet
auto healthTransform = healthEntity->add<Transform>();
healthTransform->position = glm::vec3((-static_cast<float>(count) / 2.0f + static_cast<float>(i) + 0.5f) * 1.1f, -6.5f, 0.0f);
Naturally, making the enemy always attack the first orbiter in the list makes for a pretty boring experience - let's randomize it instead.
linguine/src/systems/AttackSystem.cpp snippet
auto orbiters = findEntities<Orbiter, Health>()->get();
if (orbiters.empty()) {
auto health = target->get<Health>();
health->current = glm::clamp<int32_t>(health->current - meleeAttack->power, 0, health->max);
} else {
auto randomEntity = std::uniform_int_distribution<>(0, static_cast<int>(orbiters.size() - 1));
const auto targetIndex = randomEntity(_random);
auto health = orbiters[targetIndex]->get<Health>();
health->current = glm::clamp<int32_t>(health->current - meleeAttack->power, 0, health->max);
}
Now we can add in our CooldownProgressSystem
and PlayerControllerSystem
, though the latter needs to be updated to work with the decoupled health bars.
linguine/src/systems/PlayerControllerSystem.cpp snippet
findEntities<HealthBar, Tapped>()->each([this, &globalCooldown](const Entity& healthBarEntity) {
auto healthBar = healthBarEntity.get<HealthBar>();
auto entity = getEntityById(healthBar->entityId);
if (entity->has<Health>()) {
auto health = entity->get<Health>();
health->current = glm::clamp(health->current + 500, 0, health->max);
globalCooldown->elapsed = 0.0f;
}
});
I'm still hard-coding a handful of values that will later need to be tuned, including the healing values used above. I'm not really worried about it for now.
Fighting Back
I don't really have a clear vision for how the orbiters should attack enemies. I like the idea of being able to select different entities based on their unique capabilities: some excel at single-target damage, others at multi-target damage; some deal mediocre amounts of damage, but enhance the abilities of the other entities; there are a ton of possibilities!
Those ideas don't actually answer the core questions though. How do the orbiters attack enemies? At what point are they allowed to attack an enemy? Does their physical position have anything to do with their ability to attack?
I think a lot of these questions could be simplified by reframing the idea: the orbiters don't attack the enemies directly, they simply enhance the capabilities of the player character. As an example, the player character will always try to attack a nearby target, but if they have a "cleave" orbiter in the party, then the player character's attacks will hit an additional target. If they have two "cleave" orbiters in the party, then their attacks will hit two additional targets. That's a pretty cool idea.
With the player's attacks (and possibly defenses) being so dynamic, I'm wondering if it's possible to use our single AttackSystem
for both the player character and the enemies. I mean, I'm sure it's possible, I'm just curious if it would be better than creating separate systems. In the spirit of rapid prototyping, I'll just make the systems separate, even if that means duplicating code - I can always consolidate them later. I'll rename the current AttackSystem
to EnemyAttackSystem
, and create a new PlayerAttackSystem
.
The EnemyAttackSystem
relies on the EnemyTargetingSystem
to select a target. I'm considering how the player character should behave in regards to targeting nearby enemies. I'd like offensive attacks to be mostly passive for the player, so that they can focus on the healing aspect of the game. However, I can totally imagine a situation where there is a particular enemy that they would prefer to defeat before the rest. I think I'd like to automatically target the first enemy that attacks the player, so that starting combat is passive, but still allow the player to switch targets manually, if they so desire.
The only issues I can think of with this type of system come from movement. For example, what happens if the player character automatically targets an enemy that attacks them, but the player chooses to move away from that enemy? Should the player character select a new target automatically? Let's just go with "yes" and see what happens.
I've copied the code from the EnemyAttackSystem
into the PlayerAttackSystem
, except I added Hostile
to the query in the EnemyAttackSystem
(so that it only applies to enemy entities), and Player
to the query in the PlayerAttackSystem
(gee, I wonder why?). I ripped out the portion of the code that selects an Orbiter
from the target before attacking the target itself. At this point, the PlayerAttackSystem
looks pretty much the same as the original AttackSystem
did at the beginning of this chapter.
In order for this system to work, I'll need to add some targeting logic for the player character. I'll do so in a new PlayerTargetingSystem
, which I've copied from the EnemyTargetingSystem
. The new system is the same, except I've removed the moveTowardTarget()
method, since it's up to the player to control the movement of the character.
Now it's just a matter of adding the correct components to the entities, and everything should work.
11.4 Iterating on Movement
It's actually kind of amazing how good it feels, and I haven't even added in all the mechanics yet. I don't really like how closely the enemy follows you around, and I'm still not completely sold on the 4 degrees of movement on the grid. The first problem is somewhat tunable by giving the player a faster movement speed than the enemy. For now, I'll just give the player's GridPosition
a speed of 2.0f
, and the enemy a speed of 1.5f
.
The other problem is a little more interesting. We can expand our movement to 8 directions simply by modifying the getNeighbors()
method in the Grid
. Additionally, I'll change the isAdjacent()
method to ignore large obstructions, since we're not using that feature anymore. There are probably better ways to do this, but I don't actually care right now - I'm just trying to test mechanics quickly.
I've added a couple of static obstructions on the grid to see how the navigation appears around corners. Honestly, it sucks. With the agents capable of moving diagonally, they cut the corners of the obstacles, and it immediately breaks the immersion for the player. I'll update the getNeighbors()
method so that it doesn't allow diagonal movement around obstacles. While I'm at it, I'll modify the GridPositionSystem
so that our triangles always "face" in the direction they are moving.
That definitely looks and feels a lot better, but I'm concerned with the combination of movement and combat mechanics. The enemy unit deals damage when it gets close, so it feels natural to run away from the enemy unit to avoid taking damage in the first place - even though your own unit can't deal any damage to the enemy! What is particularly odd is that I'm much less likely to "kite" the enemy if I feel like it will die relatively quickly.
In traditional MMORPGs, it is the role of a "tank" to engage in combat with the enemies first and prevent them from attacking other party members. Tanks generally have stronger armor or larger health pools to increase their survivability. In the event that the tank dies and the enemies charge after the other players, it's rather common for them to resort to kiting in order to prevent the most incoming damage, while still chipping away at the enemy's health. This reaction is more of a gut instinct, enabled by the movement mechanics of the game. In fact, in the event that the tank is still alive but a player accidentally becomes the focus of the enemies' ire, they still tend to resort to kiting, even though the better option would be to move toward the tank.
This game is not much different. Kiting increases our survival time by simply never taking damage. Unfortunately, this leads to a situation where we can never defeat the enemy, since we are never in range for our own attacks. However, I have a couple of theories:
- A player is less likely to kite when dealing with multiple enemies in a sufficiently small space.
- Kiting is a defensive reaction to overly aggressive enemies.
The first one is somewhat easy to test: just add more enemies! Of course, I'll need to tune them to be a little less powerful so that I'm not just immediately overwhelmed. This type of thing isn't really quantifiable; it all comes down to how it feels. I modified the scene to create 3 enemies rather than 1, and I will admit that I try to kite them, but give up on the strategy rather quickly. I don't think my this theory is completely accurate, but it has some merit to it. It's interesting that tweaking a single parameter can manipulate the player's natural behavior.
The second theory is a little more difficult to implement. I'd like to allow the player to initiate combat with the enemies, rather than the enemies immediately charging at the player, forcing a defensive reaction.
linguine/src/systems/EnemyTargetingSystem.cpp snippet
case Targeting::Adjacent: {
if (!targeting->current) {
for (const auto& target : availableTargets) {
auto targetPosition = target->get<GridPosition>()->position;
if (_grid.isAdjacent(glm::round(targetPosition), glm::round(gridPosition->position))) {
targeting->current = target->getId();
}
}
}
break;
}
This actually changes the feeling and pacing of the game a lot - way more than you might expect. I've played a ton of games that had enemies standing around, seemingly oblivious to the player's rather obvious existence across the room. I never understood why that would be the case - it's obviously very unrealistic - but I seem to have stumbled upon the answer: it's important for the game play.
The movement is a little buggy, but as a prototype, I think it illustrates the idea of the game just fine.
11.5 Evaluating Progress
I've been making very slow progress over the last few weeks. I've had a lot on my plate recently, but truth be told, I think my problem has more to do with a lack of self-discipline or motivation - probably a combination of the two.
It's been three months since I added support for opening a macOS window; two months since I finished implementing the ECS architecture; and one month ago, I was experimenting with Bézier curves and bullet spawning patterns. It is obvious to me that my progress began slowing down substantially when I stopped implementing technical features, and began focusing on "game design".
I spent a lot of time trying to shoehorn the healing mechanic into genres that didn't make a whole lot of sense. The prototype that I've ended up with so far isn't a far stretch from the healing game play of an MMORPG: health bars are decoupled from the entities in the world, and the player controls the movement of their own character within the world.
The game is definitely beginning to resemble the skeleton of a dungeon crawler. If we were to procedurally generate maps and add in some random enemies and collectables, then we might have a roguelike on our hands - or perhaps a "roguelite", depending on how strictly we follow the set of requirements to be classified as a roguelike. I don't really care too much about labeling the game, I'm just trying to accurately convey my thought process. I just want to implement fun mechanics that make sense.
Though progress has been slow, this is the first time in the prototyping phase hat I've felt like I'm moving in the right direction. It's interesting that I'm reaching for mechanics from my previous game ideas. Were they always good ideas, or am I just sabotaging this entire project?
The current state of the project can be found at this commit. I really have't made all that many changes since the last chapter - this chapter was a short one.