2024-05-20 - Thinking about enemies
After the three weeks break I devoted to the static site generator, I really need a minute of planning and design before jumping into the code.
Today I only have a couple hours, so I'm going to focus on that.
First, let's write an updated TODO list.
main game loop
improve the pause/unpause visualization and interactions (now we access it with the "system" pinching gesture, or with the test "left hand thumbs down" gesture)
implement (more or less explicit) access to the save/load system in VR (now we just use keypresses for testing)
implement the time rewind mechanic, which is also related to the "embodiment" or the detachment of the avatar (like pause/unpause and) which also is part of the pause/unpause and save/load features
better avatar management
visual distinction for embodied/disembodied states
interaction between avatar and portals (when you stick an arm into a portal, it must not disappear but be different from the part which is outside the portal)
better body mocap
possibility to show a reference skeleton coming from some selected keyframes, useful to capture looping and "connected" clips
possibility to tune the capture frequency and to resave a clip lowering it
performance
add a pooling system to recycle dynamic objects
Ok, these are bullet points extracted from previous TODOs and I have also written a few new items.
I roughly sorted the items by putting the most important things first.
I definitely want to reach the point where I have a robust, complete main loop.
But I was thinking that, to have something interesting to distinguish the different states (both in terms of save/load that of time rewind), it's time to put back some enemies in the game.
In the initial "16 day" PoC prototype, for the enemies, I went for the minimalistic approach which was appropriate for that stage.
Enemies were simple spheres, and their behaviour was simply "float towards the player and collide with them to drain some energy". They had
a collider the player could hit (directly with the hands, or shooting fireballs)
two simple procedural animations for spawning and destruction
VFX and SFX to make them a little more interesting
Everything was pretty much hardcoded. Waves with increasing number of enemies were progressively spawned according to a simple rule.
Now, it's time to set-up things properly, in a more flexible way so that I can spawn and handle different kinds of enemies, with distinctive behaviours and characteristics.
Let's try and put down a few ideas: I might change things at a later stage of development, but I need to start somewhere.
I think there are two core approaches that could suit the game in interesting ways.
One, more focused on exploration, could involve going around the game world progressively unlocking new areas, maybe with some puzzles and power-ups. In that case, enemies should respawn when entering a zone you have previously cleared, or going around the world would get boring pretty fast. There could be some "special" boss enemies that don't come back once you get rid of them, of course. This would be in some sense a "metroidvania"-like approach. Each zone would define a number of specific enemies to place at specific points, maybe adding a bit of randomness.
The second approach would be somewhat simpler, more linear: I would define for each zone some waves of enemies, and when the players clear them, they can access the following zone, never going back.
Waves could be defined by hand for each zone (even in that case adding some procedural/random variation, maybe). An excellent example of this structure, with lots of twists that made it more interesting, is Hades.
I like both approaches, but I'm a bit scared by the design complexity of the first one, and I feel like the second can work better with a more casual audience too.
But can I cheat and implement an enemies management system which could potentially serve both, and postpone high level design decisions?
Let's say that a set of enemies spawned at a single time is called `EnemyWave
`.
A manager for these waves should take care of the specified enemies entities, both in terms of spawning and destruction.
I could make it more "active" or "passive", depending on how I want to wire it to the rest of the gameplay code.
Let's say I want to keep it "passive", it could have this API:
`
void spawnEnemies(EnemyWave rEnemyWave)
``
void clearEnemies()
``
bool isSomeEnemyAlive()
`
Could this simple set of functionalities allow me to implement all the scenarios I'm considering?
Let's try and imagine how.
Metroidvania-like approach: a `
Zone
` defines a single `EnemyWave
`, describing the enemies that respawn (via a call to `spawnEnemies(rZoneWave)
`) every time the player enters the area. Clearing the wave is not mandatory to proceed to a nearby `Zone
`, so the portals "locking" is not related to the fact that the wave has been cleared or not. When a player exits, any enemies still alive would be automatically cleared calling `clearEnemies()
`.Hades-like approach: a `
Zone
` defines one or more `EnemyWave
`. When the player enters the `Zone
`, the portals lock, and a counter of cleared waves (let's say `iClearedZonesCnt
`) is set to `0`. Then, while that counter is less than the number of waves defined for the zone, if there are no alive enemies (checked calling `isSomeEnemyAlive()
`), a new `spawnEnemies(rZoneWaves[iClearedZonesCnt])
` is called, and the counter gets incremented. When there are no enemies alive, and no more waves to spawn, the exit portals unlockadditionally, a survival mode: there's a single `
Zone
`, with an endless stream of new waves, which get generated as needed (when the manager detects there are no alive enemies)
This all sounds pretty reasonable, so I think that tomorrow I'm going to start implementing all this. I still need to think about how to define an `EnemyWave
`, but I can definitely start with a basic definition and then improve it later, without even changing the method signatures.
For starters, it could be a list of pairs, where each pair is made of an enemy type and the zone location where to spawn them.
What about the enemy types? As first step, I'm going to put back in the spheres of the PoC, maybe doing a couple of variants. Then, I'd like to define a more sophisticated enemy type that makes use of the mocap features I implemented recently. In terms of behaviour, I'm going to define a set of basic features that can be composed to implement different "AI" for the enemies.
I'm going to define a perception system, or fake it (what does an enemy know about the world?)
Then I'm going to have a path finding system, so that an enemy can calculate how to reach a specific platform. And different kind of enemies could move in different ways (floating, jumping to an adjacent platform, teleporting....). What about attacks? Some enemies could only damage the player with a direct collision, while others might have ranged attacks, shooting projectiles or energy waves. Another interesting point might be having some sort of "group" behaviour when multiple enemies coordinate together.
There's plenty of possibilities, and in the spirit of prototyping, I'm going to try and implement many options and test them. Then, I'll decide which one work well together and get rid of the others
Tomorrow I'll start drafting the code.
2024-05-21 - Enemy waves definition
Yesterday I wrote a lot, and didn't touch the code. Today I'm going to do quite the opposite, starting to implement what I described yesterday!
I'm going to start with the data definition of the enemy waves, taking care of their persistence too, so that I can load them similarly to how I load a zone description containing a bunch of platforms arranged in a specific way.
I decided to keep the enemy waves definitions separated from the zone definitions, at least for now.
I added a simple enumeration in the global definitions of the game, to have a first, basic, enemy type identifier:
// from PR_Defs.cs
public enum EEnemyType
{
purpleSphere,
greenSphere
}
Then I defined an `EnemySpawnDesc
`, with information about the type of enemy and their spawn coordinates.
// EnemySpawnDesc.cs
using BinaryCharm.ParticularReality.HexGrids;
using Newtonsoft.Json;
namespace BinaryCharm.ParticularReality.LevelManagement {
public struct EnemySpawnDesc {
[JsonProperty("eEnemyType")]
public readonly EEnemyType m_eEnemyType;
[JsonProperty("hexCoords")]
public readonly Hex m_hexCoords;
[JsonConstructor]
public EnemySpawnDesc(
EEnemyType eEnemyType,
Hex hexCoords) {
m_eEnemyType = eEnemyType;
m_hexCoords = hexCoords;
}
}
}
An `EnemyWaveDesc
` is a simple aggregate of `EnemySpawnDesc
`. I also added a string id, which might be useful while debugging (to distinguish specific waves).
// EnemyWaveDesc.cs
using Newtonsoft.Json;
namespace BinaryCharm.ParticularReality.LevelManagement {
public struct EnemyWaveDesc {
[JsonProperty("sId")]
public readonly string m_sId;
[JsonProperty("rEnemySpawns")]
public readonly EnemySpawnDesc[] m_rEnemySpawns;
[JsonConstructor]
public EnemyWaveDesc(
string sId,
EnemySpawnDesc[] rEnemySpawns) {
m_sId = sId;
m_rEnemySpawns = rEnemySpawns;
}
}
}
Finally, I prepared a quick generator method to automatically prepare waves of enemies that could be suitable for a given `Zone
`. This will help me with testing, considering that I don't have a level editor yet.
I pass in a dictionary asking to randomly put in the zone a certain number of enemies of specific types, and the method, provided there's enough platforms available (I also excluded the "bridge" platforms), assigns a random platform to each desired enemy instance.
I used a kind of Fisher–Yates shuffle algorithm: I put the identifiers of the suitable platforms into an array, and pick random identifiers from such array, progressively excluding them from the selection to avoid repetitions, so that I can only have one enemy per platform. I really like this algorithm as it has linear complexity, doesn't use extra data structures, and at the same time it's pretty intuitive. Intuitive enough that I haven't checked an "official" implementation but just wrote the following code, and it might very well contain bugs - you've been warned.
public class RandomEnemyWaveGenerator : IEnemyWaveGenerator
{
public EnemyWaveDesc getWave(
string sId,
ZoneDesc zoneDesc,
Dictionary<EEnemyType, int> rEnemyTypeToCountMap) {
int iTotEnemies = 0;
foreach(var kv in rEnemyTypeToCountMap) {
iTotEnemies += kv.Value;
}
PlatformDesc[] rZonePlatforms = zoneDesc.getPlaformsDescs();
List<int> rNonPortalPlatforms = new List<int>();
for (int i = 0; i < rZonePlatforms.Length; ++i) {
PlatformDesc platform = rZonePlatforms[i];
if (!platform.isBridge()) {
rNonPortalPlatforms.Add(i);
}
}
if (rNonPortalPlatforms.Count < iTotEnemies) {
throw new System.Exception("Too many enemies for this zone!");
}
EnemySpawnDesc[] rSelected = new EnemySpawnDesc[iTotEnemies];
int[] rSuitableIndices = rNonPortalPlatforms.ToArray();
int iUsedPlatforms = 0;
foreach (var kv in rEnemyTypeToCountMap) {
for (int i = 0; i < kv.Value; ++i) {
int r = UnityEngine.Random.Range(
iUsedPlatforms, rSuitableIndices.Length
);
int iRandomIdx = rSuitableIndices[r];
PlatformDesc randomUnusedPlatform = rZonePlatforms[iRandomIdx];
rSelected[iUsedPlatforms] = new EnemySpawnDesc(
kv.Key,
randomUnusedPlatform.m_hexCoords
);
int tmp = rSuitableIndices[iUsedPlatforms];
rSuitableIndices[iUsedPlatforms] = rSuitableIndices[r];
rSuitableIndices[r] = tmp;
++iUsedPlatforms;
}
}
return new EnemyWaveDesc(sId, rSelected);
}
}
2024-05-22 - Assigning wave definitions to zones
I could spawn wave definitions on the fly, but I prefer to take another preliminary step.
I want to generate some wave definitions for some of the zones in my current "game world", save them once, and then just load them. Exactly like I did with the zone definitions.
Having the enemies spawn consistently on the same platforms will be helpful while testing, and it will be definitely needed at some point by the "real" game: the only difference is that the waves are going to be defined through a level editor.
I defined another simple data type, collecting the wave definitions for a specific zone:
using BinaryCharm.Interfaces;
using Newtonsoft.Json;
namespace BinaryCharm.ParticularReality.LevelManagement {
public struct ZoneEnemyWavesDesc : IHasStringId {
[JsonProperty("sId")]
private readonly string m_sId;
[JsonProperty("rWaves")]
private readonly EnemyWaveDesc[] m_rWaves;
[JsonConstructor]
public ZoneEnemyWavesDesc(
string sId,
EnemyWaveDesc[] rWaves) {
m_sId = sId;
m_rWaves = rWaves;
}
public string getId() {
return m_sId;
}
public EnemyWaveDesc[] getWaves() {
return m_rWaves;
}
}
}
I added to `PersistenceUtils
` some utility methods to automatically save/load the `ZoneEnemyWavesDesc
` objects to a specific directory, using their id as filename. Very similar to what I already did for the zones.
Let's define a few increasingly difficult "fights" for three zones of our test world:
`
corridorZone
`:`
corridorWave0
`: 2 purple spheres
`
leftTriZone
`:`
leftTriWave0
`: 3 purple spheres`
leftTriWave1
`: 6 purple spheres
`rightTriZone`
`
rightTriWave0
`: 1 green sphere, 2 purple spheres`
rightTriWave1
`: 3 green spheres, 2 purple spheres`
rightTriWave2
`: 6 green spheres, 6 purple spheres
The code to generate the desired waves is a (verbose) transposition of these definitions.
// called on startup, after loading ZoneDescs
static void WriteTestWaves(Dictionary<string, ZoneDesc> rZoneDescs) {
RandomEnemyWaveGenerator rRandomWaveGen =
new RandomEnemyWaveGenerator();
EnemyWaveDesc rCorridorWave0 = rRandomWaveGen.getWave(
"corridorWave0",
rZoneDescs["corridorZone"],
new Dictionary<EEnemyType, int>() {
{ EEnemyType.purpleSphere, 2 }
}
);
EnemyWaveDesc rLeftTriWave0 = rRandomWaveGen.getWave(
"leftTriWave0",
rZoneDescs["leftTriZone"],
new Dictionary<EEnemyType, int>() {
{ EEnemyType.purpleSphere, 3 }
}
);
EnemyWaveDesc rLeftTriWave1 = rRandomWaveGen.getWave(
"leftTriWave1",
rZoneDescs["leftTriZone"],
new Dictionary<EEnemyType, int>() {
{ EEnemyType.purpleSphere, 6 }
}
);
EnemyWaveDesc rRightTriWave0 = rRandomWaveGen.getWave(
"rightTriWave0",
rZoneDescs["rightTriZone"],
new Dictionary<EEnemyType, int>() {
{ EEnemyType.greenSphere, 1 },
{ EEnemyType.purpleSphere, 2 }
}
);
EnemyWaveDesc rRightTriWave1 = rRandomWaveGen.getWave(
"rightTriWave1",
rZoneDescs["rightTriZone"],
new Dictionary<EEnemyType, int>() {
{ EEnemyType.greenSphere, 3 },
{ EEnemyType.purpleSphere, 6 }
}
);
EnemyWaveDesc rRightTriWave2 = rRandomWaveGen.getWave(
"rightTriWave0",
rZoneDescs["rightTriZone"],
new Dictionary<EEnemyType, int>() {
{ EEnemyType.greenSphere, 6 },
{ EEnemyType.purpleSphere, 6 }
}
);
ZoneEnemyWavesDesc corridorWaves = new ZoneEnemyWavesDesc(
"corridorZone", new EnemyWaveDesc[] {
rCorridorWave0
}
);
ZoneEnemyWavesDesc leftTreeWaves = new ZoneEnemyWavesDesc(
"leftTriZone", new EnemyWaveDesc[] {
rLeftTriWave0,
rLeftTriWave1
}
);
ZoneEnemyWavesDesc rightTreeWaves = new ZoneEnemyWavesDesc(
"rightTriZone", new EnemyWaveDesc[] {
rRightTriWave0,
rRightTriWave1,
rRightTriWave2,
}
);
PersistenceUtils.storeEnemyWaveDesc(corridorWaves);
PersistenceUtils.storeEnemyWaveDesc(leftTreeWaves);
PersistenceUtils.storeEnemyWaveDesc(rightTreeWaves);
}
Running this code gets me three JSON files in such directory, containing data that looks reasonable at a quick glance.
Tomorrow, I'll load these definitions and proceed.
2024-05-23 - Loading waves and initializing their state
So, on startup, where I actually load the zone definitions, I will also load the waves definitions.
Yesterday, by giving them the same string identifier, I established an implicit mapping from the `ZoneEnemyWaveDesc
` to the `ZoneDesc
` they refer to .
This is very basic and inflexible, but still a tiny bit better that saving the `ZoneEnemyWaveDesc
` directly into the `ZoneDesc
`: I can easily try different `ZoneEnemyWaveDesc
` instances for a single `Zone
` without having to touch the zone description.
You might remember from past articles that I'm only loading the "world" data definitions at startup, using to build a better representation to use at runtime (switching from `string
` based identifiers to `int
` based identifiers in the process).
Now, I augmented the `RTZone
` (`RT
` for "runtime") data type by adding an array of `EnemyWaveDesc
`, which will be empty if there's no wave defined for that zone.
// rZoneEnemyWavesDescs is a Dictionary<string, ZoneEnemyWavesDesc>
// containing all the loaded ZoneEnemyWavesDesc
EnemyWaveDesc[] rEnemyWavesZone;
if (rZoneEnemyWavesDescs.TryGetValue(
sZoneLabel,
out ZoneEnemyWavesDesc rZoneEnemyWavesDesc)) {
rEnemyWavesZone = rZoneEnemyWavesDesc.getWaves();
} else {
rEnemyWavesZone = new EnemyWaveDesc[0];
}
rZones[zId] = new RTZone(
sZoneLabel,
zId,
zd.m_sSkyboxMatId,
rPlatforms,
rEnemyWavesZone // <--- new field
);
Crude, but ok for now.
We are done with the "static data" layer, now we need to go up in the architecture we have defined in the past.
First, we need to build the state management for the enemies, and finally we will have to implement the presentation of the state, managing the scene game objects which visualize the state.
The state management involves the dynamic data about enemies: the information which goes into a save game (and in the state history to be used by the rewind system).
I followed the path I already traced when implementing the zones/platforms.
I defined in the namespace `BinaryCharm.ParticularReality.StateManagement
`:
`
EnemyWave
` and `EnemyWaveStateManager
``
Enemy
` and `EnemyStateManager
`
This hierarchical organization is pretty flexible, because I can define some update logic for a whole wave, and for each enemy.
I plugged an array of `EnemyWaveStateManager
` into `ZoneStateManager
`, together with the `PlatformStateManager
` instances.
It looks like the more reasonable place, assuming enemies can't change zone.
Notice that since the `ZoneStateManager
` update is executed for all the active zones (so, current and adjacent, if any), when I'm done you should be able to see enemies through a portal to an adjacent zone, as one would expect. Unless I want to spawn them after you enter the new zone for gameplay reasons.
There's quite a while of boilerplate to write to have the execution logic work in the layered architecture I established. In fact, I'm wondering if I should give some thought to simplifying things a bit.
Anyway, I can't complete this step today - fingers crossed for tomorrow!
2024-05-24 - State update, prefabs setup
I need to complete the state management, and then handle the instantiation and destruction of enemy game objects in the scene.
I'm going to keep things simple and just define two states for an enemy:
hidden: the instance is ready but not displayed
idle: the instance is displayed, and stands still on its spawning platform
Actually, I'm going to define the same two states for an enemy wave too.
I added the enemy waves state and a counter of the cleared waves to the `WorldZone.State
` structure, that until now only contained the state data for the platforms in that zone.
In the state update, if the counter is less than the number of waves for the zone, I know I have an active wave, and that I must update the simulation of its enemies.
When all the enemies of the wave will get into the "dead" state, I will consider the wave itself cleared and increment the counter, switching to the next wave (if any).
But I currently don't have a dead state nor a way to destroy enemies, so my first goal is just being able to spawn the first wave of enemies for a zone, if any, at their correct spawn position.
I should have completed a basic implementation of the state management, and it's finally time to move to the representation layer, and put something on the screen.
For starters, I need to fetch back the enemy prefab from the PoC prototype.
I'm going to strip from it the behaviours that had to do with its gameplay logic (movement, interaction with the player) and just keep the functionality which is strictly correlated to visualization.
This separation of concerns adds a lot of complexity to the architecture of the game, but it will hopefully prove worthwhile, allowing me to do all kinds of cool stuff in a robust way. It's the kind of thing that separates a throwaway prototype, or a jam game, from the real thing.
I copied the old prefab and cleaned it up. Then, I duplicated it and made an alternate version changing the color to green.
I then added references to these two prefabs to my stupid simple resource manager, so that I can easily instantiate an enemy prefab of the desired type (specified by the `EEnemyType
` enumeration).
I hoped to actually complete the wave instantiation, but there's still a lot of work to do. I need to "connect" the presentation layer to the state management.
As soon as I have the enemies correctly spawning according to the wave definitions, I will then proceed to add back in the possibility to destroy them. Otherwise, I can't "clear" a wave and get the next one to spawn. That should basically be my TODO list for the next week of development.