2024-07-29 - Revisiting state management
As it surfaced in the latest entry with the "delayed hit" bug, I have some problem in my state management code.
Today I want to revisit the current execution flow, because there are too many details I don't remember well enough to debug it.
For starters, I'm going to check the data structures involved in the state management, and how they're related. I'm going to write a few notes, both for you and for future me.
Warning: this is not a suggestion about how to organize things... I'm just analysing the current state of the project, so that I can improve upon it: there are definitely improvements to be done on many fronts (naming, performances etc)
Let's start with a diagram:
Each one of these classes uses generic parameters so that it can be instantiated with custom data and logic. I'm skipping them to keep things short and simple. I just left in the diagram `D
`, which is the custom data, and `S
`, which is an enumeration of the state identifiers.
Obviously, the diagram is incomplete (only some data members are shown, and no methods are included): it would take too much time to be exhaustive, and it would not help to focus on the core aspects.
An `FSM` class contains the definition of the logic to be executed for each state, and a label (mostly useful for debugging).
A single instance of a class inheriting from `
FSM
` can be used, of course, by multiple state managers on multiple execution acting on different data items.
`FSMExecData
` contain some data common to every FSM execution, and a custom data element that changes depending on the FSM definition.
`FSMExec
` contains an `FSMExecData
` and some additional data, including an input queue.
Why the split between `
FSMExec
` and `FSMExecData
`? It's a clear separation between transient/runtime data and data that can be saved and loaded.
A class inheriting from `AStateManager
` wraps a definition (`FSM
`) and an execution (`FSMExec
`) which operates on some data bound to the FSM (`FSMExecData
`).
Every `update()
` of a `AStateManager
` object runs its logic and calls the `update()
` methods of its child state managers.
I like to keep a precise, deterministic and stable execution ordering. Never been a fan of the Unity approach using the script execution order to define what gets simulated first.
My time is almost over for today, so I'm just going to do a bit of clean-up and take note of possible problems. I usually add some short comments in the code, like `// !
`, to mark something that needs further inspection, and then go and revisit the marked zones (using "find in files" or simply the git diff highlighting the changed lines).
2024-07-30 - More fixes, more problems
After yesterday's inspection, I feel ok with trying some changes.
While doing some more testing, I noticed that even keeping the history window to two elements only, sometimes a collision did not cause a proper hit. There's no delay, in this case the hit doesn't show up at all: I get the audio/wave feedback, but the enemy doesn't bounce.
I changed the update logic in the `EnemyStateManager` so that the movement of the enemy gets adjusted in a single point, after every force that can alter it gets accumulated. I also changed the global FSM execution moving it from `Update` to `FixedUpdate`, which should make more sense. Using `Update` should be limited to the _presentation_ layer behaviours, and should do some extrapolation of the data coming from the state data. But that's something to revisit at a later stage of development.
After these changes, it looks like the hits always register properly.
Then, I went and tried to understand the "history window" problem.
I found out that the `GameStateManager
` did contain a separated `SimStateManager
`, each wrapping a `SimFSMExec
` initialized on one `SimFSMExecData
` of the state history array.
That doesn't feel very sensible: a single `SimStateManager
` should suffice, and should be able to set the (single instanced) `SimFSMExec
` execution it wraps to the desired `SimFSMExecData
` in the history buffer.
I guess I didn't want to change the `FSMExec
` API while I was doing the save/load (which is when I drafted this, if I remember correctly), and as it didn't allow to change the wrapped `FSMExecData
`, I worked around the problem doing this silly thing.
Having multiple `FSMExec
` instances meant having different input queues, which probably introduced the "delayed" input problem discussed last week.
I changed the `FSMExec
` API and eliminated the "multiple `SimStateManager
`" anomaly.
Unfortunately, the fix doesn't work well. Other issues with other elements of the game appear. There must be some subtle issue I'm overlooking.
I think it's time for a strategic decision: I will temporarily disable the history buffer, and only use a single simulation state. I was working on the enemies, I want to keep working on the enemies.
Taking a significant detour to fix something that will be needed by other features, not yet developed, doesn't feel right.
With a single simulation state, it looks like everything works properly. Actually, remember the annoying "one frame glitch" that showed up when stepping into a portal? It's gone, so it's probably related to that too (and/or to other execution order fixes I did today).
To celebrate the unexpected good news, a short capture using a Quest build (instead of Meta Link).
You can notice a couple of glitches: the avatar VFX briefly disappears, and at the end, when the zone changes, the enemies visualization is somewhat off. We'll get there.
But tomorrow, I will start popping enemies.
2024-07-31 - Popping enemies
My goal for today should be pretty simple: handling the destruction of enemies.
I already did the heavy lifting in week #21, preparing and testing the full enemy life cycle.
So, it should only be a matter of sending to the enemy FSM the "pop" signal when appropriate, and everything should work.
Usually, enemies have an "health" value. Hitting them decreases such value.
The current health value can change the enemy behaviour, depending on the context and the kind of game. When the health value reaches zero, the enemy gets eliminated.
This kind of approach introduces a problem, which is making players aware of the current enemy health level. I don't want visible health bars (one of my design principles is "fully diegetic"), so I will need to find something, on the visualization side, which gives at least a rough indication.
In the debug visualization, instead, I can definitely use a health bar similar to the one I'm using on the player wrist.
Anyway, let's start introducing the "damage" logic.
To apply a damage, one should calculate the impact strength.
The simplest approximation that comes to my mind is considering the magnitude of the relative velocity of the "hitting" hand and the enemy.
Something like this:
public void applyHit(Vector3 vHitVelocity) {
applyForce(vHitVelocity);
Vector3 vRelativeVelocity = vHitVelocity - m_vVelocity;
float fImpactForce = vRelativeVelocity.magnitude;
float fHealthAdjustment = fImpactForce * fHIT_DAMAGE_FACTOR;
m_fHealth01 = Mathf.Max(0f, m_fHealth01 - fHealthAdjustment);
}
In the enemy FSM logic, I have a state transition, from `core` to `destroying`, when the health value reaches zero.
After a bit of tuning of the `fHIT_DAMAGE_FACTOR
` value, it looks like it's working properly.
I hoped to at least do the health bar visualization too, but something came up - I'm done for today!
2024-08-01 - Enemy health bars
First day of August! The Particular Reality birthday (August 5th) is approaching...
Today I'm going to add a debug visualization for the enemies health, and use it to make sure that the player hits affect it in a sensible way.
I can reuse the `p_energy_bar` prefab I already implemented for the player health/mana I'm visualizing near the wrists.
I just need to make sure that they are only visualized when in debug mode, and set the displayed value to the health value read from the enemy instance data.
Additionally, I need to rotate the energy bar so that it faces the player.
It's easy enough, so I'm going to take the chance to show a code snippet:
// skipped the "using" directives
namespace BinaryCharm.ParticularReality.DebugManagement {
[RequireComponent(typeof(IStateManagerProvider<EnemyStateManager>))]
public class DebugEnemyBhv : MonoBehaviour {
[SerializeField] private Transform m_rDebugVisTr;
[SerializeField] private EnergyBarBhv m_rEnergyBar;
private void Update() {
bool bIsDebugging = DebugManagerBhv.i().isDebugging();
if (bIsDebugging != m_rDebugVisTr.gameObject.activeSelf) {
m_rDebugVisTr.gameObject.SetActive(bIsDebugging);
}
if (!bIsDebugging) return;
IStateManagerProvider<EnemyStateManager>[] rSMPs =
GetComponents<IStateManagerProvider<EnemyStateManager>>();
EnemyStateManager rESM =
rSMPs[rSMPs.Length - 1].getStateManager();
StateManagement.Enemy.Data rData = rESM.getFSMData();
m_rEnergyBar.setLevel(rData.m_fHealth01);
m_rEnergyBar.gameObject.setRendered(rData.m_fHealth01 > 0f);
BodyInputManagerBhv rBI = BodyInputManagerBhv.i();
Vector3 vEnemyToPlayerDir =
(rBI.getBasePos() - transform.position.withY(0f)).normalized;
m_rEnergyBar.transform.rotation =
Quaternion.LookRotation(vEnemyToPlayerDir, Vector3.up);
}
}
}
The only thing worth discussing in this snippet is the part where I fetch the `EnemyStateManager
`.
I'm getting all the components implementing the `IStateManagerProvider<T>
` interface, and using the last of the returned array as data source.
Why? Well, such interface is implemented both by `EnemyBhv
` and `EnemyTestBhv
`. Every enemy has a `EnemyBhv
` attached to it, but if I want a "test enemy" which I can control via inspector, as shown in week #21, I just need to also attach at runtime an `EnemyTestBhv
`and it starts driving the enemy, using a local `EnemyStateManager
` detached from the whole game simulation.
So, the debug visualization uses the data coming from `EnemyTestBhv
` if present, and from `EnemyBhv
` otherwise.
Why didn't I directly use `GetComponent<EnemyTestBhv>()`
and do `GetComponent<EnemyBhv>()
` if the first call gave `null` as result? Well, `EnemyTestBhv
` is not currently in the gameplay logic package, but in the main project `Assets
`, because it depends on `com.cyborgAssets.inspectorButtonPro
` (which I don't want to put as dependency of the gameplay logic package). The `IStateManagerProvider
` allows to have a loose coupling. In a future refactoring pass, I'm going to separate the debugging scripts into a separate package, and at that point I will have no problem adding the external dependency.
The final goal, in terms of project/packages organization, is having a setup which will allow me to easily strip unnecessary elements (testing/debugging) from a build to be released. Not that I'm sure any of this prototype version will remain in the version that is going to be released someday.
After a bit of fiddling with scale and positioning, here's the result. I think it works pretty well: if I push gently there's a small health falloff, while if I hit more violently or two-handedly the falloff is significant, or even able to destroy the enemy in a single hit.
2024-08-02 - Changing wave, pausing enemies
Now that I can destroy enemies, it's time to take care of the "wave changes": in week #20, we assigned to each zone definition a number of enemy wave definitions. In week #21, we designed the FSM that should take care of the waves logic, which could be summarized by "materialize the enemies of each wave as long as the previous wave is cleared". We also took care of instantiating and destroying the enemies together with the zone that hosts them.
It's time to activate the logic "incrementing" the current wave counter when the current wave state is `destroyed
` and see if everything works as expected.
Here's an excerpt from `ZoneStateManager
`:
var rExecData = m_rFsmExec.getExecData().getData();
int iNumWaves = rExecData.m_rEnemyWavesFSMEx.Length;
if (rExecData.m_iClearedEnemyWaves < iNumWaves) {
EnemyWaveStateManager rCurrWaveStateManager =
m_rEnemyWavesStateManagers[rExecData.m_iClearedEnemyWaves];
EnemyWave.EState waveState = rCurrWaveStateManager.getState();
if (waveState == EnemyWave.EState.hidden) {
rCurrWaveStateManager.materializeEnemies();
} else if (waveState == EnemyWave.EState.destroyed) {
++rExecData.m_iClearedEnemyWaves;
}
}
Cool, it works!
I still have some time, but I don't want to start big tasks at the end of the week. Let's see the TODO list:
go back to the VFX graph stuff and see how to control the VFX simulation time, then bind it to my gameplay system
So, I don't want to touch the global VFX deltaTime setting, because I want to be able to control the simulation speed of different VFX elements in a different way.
After checking the documentation, I started remembering that I had already faced this issue while working on the portal VFX. when implementing the pause feature for the first time.
Unfortunately, it looks like the VFX graph doesn't allow to manually call an "update" of the particles simulation passing a custom delta time. That's very annoying.
The only workaround that I can find some traces about is disabling the automatic particle update options and replicate the logic myself, passing the delta time as a `float
` parameter.
Here’s the panel with the Particle Update Options:
That shouldn't be too hard, but still annoying and I particularly hate the idea of having to do it for each VFX. Maybe there's a way to make a function and reuse it? I need to dig deeper. And I hate using node based editors.
I'm almost out of time, so for this week I'll be happy enough to apply the same hack I did in the case of the portal VFX: calling pause on the particles simulation. Yep, at least, the `VisualEffect
` API allows to do that.
To end the week, a short video capture where I show the "pausing" of the enemies and the clearing a wave. At the end of the video, the next wave appears.
If you look carefully, around 00:23, there's an ugly glitching of the enemies particles. I think that's also due to automatic update in the VFX graph simulation.
When I change zone, as discussed long time ago, I move things around so that the current zone is always at the world origin. But the automatic particle update calculates the velocity on the basis of the world position change of the enemy, not its "logic" velocity (which is not affected by the offset I'm applying to move the zone).
I could work around that too passing the velocity to the VFX myself. Or, as it usually happens, I could spawn the first wave only after the player enters the zone, and not when it gets instantiated (which is when the player is on the bridge portal platform that acts as an entrance to the zone). But I kind like that you can see what awaits you on the other side of the portal!
Deep down, I know very well that the moment of the enemies appearance should be a design decision not influenced by technical limitations, so I'll end up fixing the problem anyway, because I know it's there and it would bother me even if not visible to the player.
Prepare for something special next week!