2023-12-18 - Pause, pause, pause
The last DevLog entry, at the end, discussed some problems of the portal implementation (related to the pause mode).
The first was the fact that the portal particles VFX kept running when the gameplay simulation was paused.
After checking the `VisualEffect
` documentation, the properties that jump to my eyes about controlling the playback of the effects are `pause
` and `playRate
`:
// from https://docs.unity3d.com/ScriptReference/VFX.VisualEffect-pause.html
public bool pause;
Use this property to set the pause state of the visual effect.
// from https://docs.unity3d.com/ScriptReference/VFX.VisualEffect-playRate.html
public float playRate;
A multiplier that Unity applies to the delta time when it updates the VisualEffect. The default value is 1.0f.
To play the visual effect faster than normal, set this property to a value greater than 1.0f. To play the visual effect slower than normal, set this property to a value between 0.0f and 1.0f.
So, I can set `pause
` according to my `AppState
` and I should be fine.
If I want to smooth the pause/play in some way, I could quickly change `playRate
` with some easing function.
If I want to implement a time rewind mechanic, instead, I'm going to need some other technique, because `playRate
` can't be negative.
Anyway, for now I'll keep it simple and stick to `pause
`.
It worked as expected: I added a parameter to the portal visualization indicating if the execution was paused or not, and forwarded the `bool
` to the VFX which is part of the portal visualization.
Another thing to fix was the audio playback.
Last week I drafted the implementation of sound effects playback so that it can work properly (at least, I hope so: didn't really test it that much) with an arbitrary game state as starting point.
This means that you restore the execution of a game state that was saved at 0.5 seconds after the start of a sound playback, it should start playing the sound from that 0.5 seconds offset.
But when pausing the simulation, the same audio offset was repeatedly sent to the playback system, which caused the annoying stuttering in the video of last week. To avoid that, I should adjust the playback code so that it detects the playback is not going forward. That should pause/unpause properly the `AudioClip
` playback.
This also worked pretty quickly, even if I don't like very much the way I implemented the fix. The same way I don't really like the Unity `AudioSource
` API, and I think that's part of the problem. I will probably revisit it later.
To complete the day, considering that I'm dealing with pause/unpause, it should be a good moment to implement Focus Awareness.
As explained in the Meta documentation here, this basically means properly handling the fact that the user could pop-up the system UI at any time, with the controllers or with the system defined gesture.
We are going to pause the application when it doesn't have focus (because the system UI is up).
This was also trivial, basically I had to add to the initialization of `AppStateManagerBhv
` (which handles the "Application State" FSM, shown in the 2023-12-04 DevLog entry) these lines of code:
OVRManager.InputFocusAcquired += () => {
m_rFSM.unpauseGame();
};
OVRManager.InputFocusLost += () => {
m_rFSM.pauseGame();
};
Let's complete the day with a small video showing today's work: you can see that when pausing, the audio and the portal particles pause too. Additionally, the paused state can also be enabled/disabled by opening/closing the system menu with the system gesture.
2023-12-19 - Evening clean-up
This afternoon I couldn't work on Particular Reality (deadlines, deadlines, deadlines), but I feel like putting in a couple of hours of work this evening. Better than nothing!
I did those little things that are useful to keep the project in good shape, but don't require significant focus or mental energy: renamed some data types and some variables, improved code layout by adding and removing whitespace, cleaned up some commented code that's not needed anymore.
Then, I also updated the Oculus SDK packages to the recently released v60, which should come handy for the upper body tracking I'm going to use soon.
2023-12-22 - GameState persistence
Yeah I know, we jumped from 19th to 22nd of December. End of year deadlines happened. And even today, I only have very limited time for Particular Reality.
I could proceed on many fronts. After this week I'm going to be off the project for the Christmas break, so I don't want to start huge tasks, or I might be forced to interrupt them.
Now that I somewhat have the pause mode, even if only supported by the two elements properly handled by the game state (player and portal), I could try drafting the saving and loading of a game state.
For today I'm just going to make the little edits needed to be able to save and load a `GameState
` using the same technique I already used for the "zone" descriptions (lame JSON serialization, inefficient and verbose, but quick to implement and suitable for testing).
All done, nice and easy.
Hopefully in this weekend I should be able to make it up for the two days I skipped mid-week.
2023-12-23 - Save Game Saturday
I'm going to proceed on the save/load feature, trying to get the basics working.
As first step, I'm going to make it work on PC, testing the feature with keypresses.
At a later time, we're going to decide how to let the player access the features in-game (we already discussed some ideas in an earlier post.)
I decided to use the "not on the numpad" number keys as my test keys.
Specifically `1-9
` will be associated to 9 save slots. I'm going to reserve `0
` as a new game key.
I'm going to save with `Shift+N
`, and load by only pressing `N
`. It's not perfect, but it's not supposed to be perfect.
So if I hit `Shift+3
` the current game state is going to be saved to slot #3 (mapped to the file `_savegames/gamestate_3.json
`).
If I hit `3
`, the saved game state is going to be loaded and will replace the currently running game state.
The game visualization layer is going to make the needed adjustment so that the Unity scene changes according to the new state.
Saving worked fine, or so I thought.
While doing the `load()
` code, I realized I overlooked something important.
I did a generic FSM that you can bind a data item to, in this case `Portal.Data
`, and in the savegame I put the data item. But I forgot that I must also save the generic FSM data that is needed to restore the FSM execution exactly from there (like, the current and previous states identifiers).
For example, considering `Portal.State
`:
//[...]
namespace BinaryCharm.ParticularReality.StateManagement {
public class Portal {
public enum EState {
closed,
opening,
opened,
closing
}
//[...]
public class Data {
[JsonProperty("eOpenedDir")]
public Hex.EDir? m_eOpenedDir;
[JsonProperty("fOpenedLevel01")]
public float m_fOpenedLevel01;
[JsonConstructor]
public Data(
Hex.EDir? eOpenedDir = null,
float fOpenedLevel01 = 0f) {
m_eOpenedDir = eOpenedDir;
m_fOpenedLevel01 = fOpenedLevel01;
}
}
}
}
If this gets saved as `{"eOpenedDir": "N", "fOpenedLevel01": 0.7 }
`, that's not enough information to restore perfectly the saved state: the portal is facing north and is at 70% aperture, so I know it's not closed and not opened, but was it closing or opening?
I know that it wouldn't really matter in this specific case, as the player input would flip closing/opening depending on if the gesture gets detected... but I'm trying to establish some basic architecture that would work in any case
The `PortalStateFSM
` holds the other data needed to restore the execution properly (the `Portal.EState
` id indicating the currently executing state of the FSM).
I tried the quick and dirty fix of saving and restoring the whole FSM, but things quickly got messy.
So, I decided to do a significant change into my generic FSM implementation (see? that's why I didn't want to show it yet) and separated between FSM definition and execution.
For each kind of FSM, there will be a single definition (containing the states logic), but there might be multiple executions, in different states and with different data item instances bound.
This should allow me to cleanly save/load only the FSM executions, without messing with the FSM definitions.
I managed to save and load a portal state using the revised implementation.
There's still some cleanup to do and I must extend the changes to the player state, but at that point I should have some solid foundations to build upon.
I'll try to complete the changes tomorrow!
2023-12-24 - Load Game Sunday
I refactored the code handling the player and the player avatar so that is structured similarly to the portal, which I got working yesterday.
While checking a saved state, I noticed that the `Pose
` members of the `PlayerAvatar
` that I'm saving contain data that shouldn't be serialized (the three `forward
`, `right
` and `up
` vectors).
If you're wondering why am I saving the avatar data: as I mentioned in the past, I'd like to implement a "time rewind" feature, and anyway it's going to be needed to visualize the save game the way I'm imagining it.
That happens because the system serializes all public properties by default.
I could maybe add some custom serialization behaviours to instruct `Newtonsoft.Json
` to only serialize the members I want, but I'm not crazy about the idea.
Instead, I'm going to add a simple custom data type which is just a position/rotation pair, and use that in my state data types. It's quite isolated from the rest (differently from the other things I'm doing), so it's a good opportunity to show a bit of code.
using System;
using UnityEngine;
using Newtonsoft.Json;
namespace BinaryCharm.ADT {
public struct PosRot : IEquatable<PosRot> {
[JsonProperty("vPos")]
public Vector3 m_vPos;
[JsonProperty("qRot")]
public Quaternion m_qRot;
[JsonConstructor]
public PosRot(Vector3 vPos, Quaternion qRot) {
m_vPos = vPos;
m_qRot = qRot;
}
public static implicit operator PosRot(Pose pose) {
return new PosRot(pose.position, pose.rotation);
}
public static readonly PosRot DEFAULT =
new PosRot(Vector3.zero, Quaternion.identity);
public override bool Equals(object? obj) =>
obj is PosRot other && this.Equals(other);
public bool Equals(PosRot p) =>
m_vPos == p.m_vPos && m_qRot == p.m_qRot;
public override int GetHashCode() =>
(m_vPos, m_qRot).GetHashCode();
public static bool operator ==(PosRot lhs, PosRot rhs) =>
lhs.Equals(rhs);
public static bool operator !=(PosRot lhs, PosRot rhs) =>
!(lhs == rhs);
public override string ToString() {
return "("
+ m_vPos.ToString() + ", "
+ m_qRot.ToString()
+ ")";
}
}
}
I also ported the `PoseUtils
` provided by the Oculus SDK, which I find very useful, to a `PosRotUtils
` that works on my data type. This has another advantage: it allows me to remove the namespace import of `Oculus.Interaction
` from classes that don't use anything related to `Oculus
`, but that called `PoseUtils
` methods (`GetPose
` and `SetPose
`, mainly).
I did some more refactoring and clean-up, and finally got to the point where I could test saving and loading the state, which works, with the only quirk that it doesn't handle well different zones.
I kinda expected this, as I haven't refactored the zone management subsystem to use a strict separation where the visualization layer only reads the logic layer and adjusts the scene accordingly. I started the process of making it better (see 2023-12-11), but it needs some more work.
Let's see a simple example capturing on the PC (I have no VR interface to the save/load features yet).
The save/load is instant and with no UI or visual feedback except for the instantaneous state change, so you need to know what's going on to appreciate the video, of course.
Moreover, the utility that I'm using to show the key presses on screen is not perfect for my use case:
shown `
!
` -> pressed shift+1shown `
@
` -> pressed shift+2shown `
#
` -> pressed shift+3shown `
%
` -> pressed shift+5
Basically, I cross the first portal to enter the menu zone, and save to slot 1. Then I move to the center platform, and save to slot 2. Then I load slot 1, which brings me back to the zone entrance platform, and then load slot 2 to go back to the center platform. Same for slot 3 and the new game platform, on the left. At the end, I go back to the entrance and save to slot 5 while keeping the portal opened. Notice that when I load 5, the portal starts opened (it closes right away, correctly, as I'm not keeping the "open portal" key down (`R
`).
I'm also going to show you how a savegame file looks like with the serialization I have implemented:
{
"rPlayerFSMEx": {
"rData": {
"location": {
"coords": {
"vCubeCoords": {
"x": 0,
"y": 0,
"z": 0
}
},
"sZoneId": "menuZone"
},
"fHealth01": 1.0,
"fMana01": 0.25
},
"eCurrStateId": "normal",
"ePrevStateId": "normal",
"fCurrStateElapsedTime": 57.69863
},
"rPlayerAvatarFSMEx": {
"rData": {
"head": {
"vPos": {
"x": 0.325789243,
"y": 1.17261779,
"z": -0.9749955
},
"qRot": {
"x": -0.0132797835,
"y": 0.08217783,
"z": -0.00109510531,
"w": -0.9965286
}
},
"leftHand": null,
"rightHand": null
},
"eCurrStateId": "normal",
"ePrevStateId": "normal",
"fCurrStateElapsedTime": 57.69863
},
"rPortalFSMEx": {
"rData": {
"eOpenedDir": "N",
"fOpenedLevel01": 1.0
},
"eCurrStateId": "opened",
"ePrevStateId": "opening",
"fCurrStateElapsedTime": 1.65156925
}
}
I hope you can appreciate the readability: everything is properly nested and named, and only the data that is actually needed is there.
The three "FSM executions" objects of player (`rPlayerFSMEx
`), player avatar (`rPlayerAvatarFSMEx
`) and portal (`rPortalFSMEx
`) each include a `rData
` member for the state data bound to the FSM, and some other members that every FSM execution has (identifiers for current and previous state, and elapsed time in the current state).
Thanks to the use of generics and of `Newtonsoft.Json
`, saving and loading of this file involves very little code. Let's give it a look.
This is a fragment of the generic `FSMExecution
` class. Notice the `JsonProperty
` and `JSonConstructor
` annotations.
//[...]
public class FSMExecution<S, I, D> : IFSMView<S, I, D>
where S : struct, System.IConvertible
where I : struct, System.IConvertible
where D : class {
[JsonProperty("rData")]
private D m_rData;
[JsonProperty("eCurrStateId")]
private S m_eCurrStateId;
[JsonProperty("ePrevStateId")]
private S? m_ePrevStateId;
[JsonProperty("fCurrStateElapsedTime")]
private float m_fCurrStateElapsedTime;
//[...]
[JsonConstructor]
public FSMExecution(
D rData,
S eCurrStateId,
S? ePrevStateId = null,
float fCurrStateElapsedTime = 0f) {
m_rData = rData;
m_eCurrStateId = eCurrStateId;
m_ePrevStateId = ePrevStateId;
m_fCurrStateElapsedTime = fCurrStateElapsedTime;
//[...]
}
//[...]
Also notice the three `S, I, D
` generic type parameters: they stand for State, Input and Data.
When I implement a FSM, I define three specific types:
a `
EState
` enumeration defining the identifiers for the FSM statesa `
EInput
` enumeration defining the identifiers for the input messages handled by the FSMa `
Data
` class containing the data bound to the FSM, which should only be manipulated from inside the FSM
Let's see the full `Portal
` class (I already showed part of it):
//[...]
namespace BinaryCharm.ParticularReality.StateManagement {
public class Portal {
public enum EState {
closed,
opening,
opened,
closing
}
public enum EInput {
open,
close,
cross
}
private const float fSHOW_DURATION_SECS = 0.1f;
private const float fHIDE_DURATION_SECS = 0.1f;
public const float fOPENING_SPEED = 1f / fSHOW_DURATION_SECS;
public const float fCLOSING_SPEED = 1f / fHIDE_DURATION_SECS;
[Serializable]
public class Data {
[JsonProperty("eOpenedDir")]
public Hex.EDir? m_eOpenedDir;
[JsonProperty("fOpenedLevel01")]
public float m_fOpenedLevel01;
[JsonConstructor]
public Data(
Hex.EDir? eOpenedDir = null,
float fOpenedLevel01 = 0f) {
m_eOpenedDir = eOpenedDir;
m_fOpenedLevel01 = fOpenedLevel01;
}
}
}
}
As you can see, the `Portal.Data
` class also has some decorations to take care of the serialization/deserialization, and we define `Portal.EState
` and `Portal.EInput
`.
The part related to the actual FSM definition is too long to discuss for today, and not relevant to the serialization, but as you can imagine it define the logic for the states execution and uses the three `S, I, D
` types.
My favourite thing of all this is how the serialization of `EState` works through the generics and the `FSMExecution
` class. In the JSON file, you can see the readable string identifiers associated to the different `EState
` enumerations defined in `Portal
`, `Player
` and `PlayerAvatar
`. For `rPortalFSMEx
`, as current/previous state identifiers, you see `"opened"
` and `"opening"
`, while for `rPlayerFSMEx
` and `rPlayerAvatarFSMExec
`, you see `"normal"
`) . That's indeed the identifier of the placeholder state currently defined by the `Player
`/`PlayerAvatar
` state machines.
// Both in Player class and in PlayerAvatar class
[...]
public enum EState {
normal
}
[...]
Ok, I feel like it's a good moment to stop!
The code is not a disaster, the architecture is almost sensible, and the foundations for save/load/pause have been implemented.
As last thing before starting my Particular Reality XMAS break, which also marks the end of the first ten weeks of development (after the 16 days spent for the proof-of-concept), I'm going to write an updated TODO list to use as starting point mid-January, when I expect to get back at work. If I manage, I'll throw in an "extra" post before that. But no promises!
TODO:
refactor the zone management so that it fits the new architecture (getting rid of string based logic in the process, mapping to `
int
` identifiers once, at load time)work on a fully fledged player avatar (now we just have three spheres for hands and head)
implement (more or less explicit) access to the save/load system in VR (now we just use keypresses for testing)
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)
improve the visualization of the bridge portals (now we just have a small blue sphere on the platform edge)
add a pooling system to recycle platforms (and other stuff in the future)
After doing all this (which is a significant amount of work, and will definitely take some weeks), I'm going to pick another "major" task. Combat? Visualization? Level editor? I'm not sure yet. One of those. Probably.
Until next time!