Today was spent cramming to get the Morale system out before the end of the weekend. A lot of the features we needed to work on next interact with the Morale system. I was kind of holding up the whole train.
The biggest thing preventing me from getting the system out was the need to refactor several of the larger systems to better support the increased complexity. Namely, how the Warmth states were being handled and how the Icon Trays worked.
If you looked at the code from the post the other day, you might not see the problem right away. When the Crew warmth state was that simple, there wasn't really anything wrong with it. For easy reference, here is the code from the other day.
private void HandleWarmthStateChange(WarmthStatus state)
{
switch (state)
{
case WarmthStatus.Freezing:
_freezingTimer.Start();
_moraleMechanic.AddModifier(MoraleModifierType.Freezing);
break;
default:
_freezingTimer.Stop();
_freezingTimer.Reset();
_moraleMechanic.RemoveModifier(MoraleModifierType.Freezing);
break;
}
}
Seems simple enough on the surface. Either we are Freezing or we are not. If we're Freezing, start the timer and add the Morale modifier. If we are not Freezing, reset everything. This worked fine and dandy when we only had the Freezing and "Normal" states but it broke down really quickly once we started to add in the other states.
Andrew was working on adding in the other Warmth states (Warm and Hypothermia) and just extended the pattern that was already there. That looked like this:
private void HandleWarmthStateChange(WarmthStatus state)
{
ClearWarmthIcons();
switch (state)
{
case WarmthStatus.Warm:
_iconTray.AddIcon(IconKeyUI.Warm);
HandleTimer();
break;
case WarmthStatus.Hypothermia:
_iconTray.AddIcon(IconKeyUI.Hypothermia);
HandleTimer();
break;
case WarmthStatus.Freezing:
_freezingTimer.Start();
_iconTray.AddIcon(IconKeyUI.Freezing);
break;
default:
HandleTimer();
break;
}
}
As you can see, it is still pretty clean but if you look closely, you'll notice two things: the call to ClearWarmthIcons at the top of the method and the HandleTimer call that is called from every state except Freezing.
The ClearWarmthIcons is a sleeper problem that I'll go over more in the "Reworking the Icon Tray" section below. The HandleTimer is more straight forward. The contents of that method are essentially the contents of the default case in the original code. It is responsible for resetting the Freezing timer and removing the Freezing morale modifier. With the current code, we can't really tell what state we were in last so we can't intelligently clean up the Freezing state stuff (or the icons).
I had thought about adding a field to track the last state, the checking if the last state was Freezing and do the reset then. However, this seemed like just kicking the can somewhat down the road. If we needed to handle specific states, we'd have to add checks in for each previous state and handle it. It didn't seem very expandable.
private WarmthStatus _lastWarmthState;
private void HandleWarmthStateChange(WarmthStatus state)
{
switch (_lastWarmthState)
{
case WarmthStatus.Freezing:
// Clear Timers
break;
case WarmthStatus.Normal:
// Do stuff
break;
}
// Rest of the method
}
Another option that I tossed about was something I'd seen early on in my career (where switch-based "state machines" were abused) was to add more states to the WarmthStatus to include things like:
WarmthStatus.EnterFreezing
WarmthStatus.Freezing
WarmthStatus.ExitFreezing
Then you add more cases into the switch statement to do your setup and teardown. This is really unruly and only really works if you have a very linear flow to the states. What I was really heading towards though was a simple state machine.
To my thinking, a simple state machine could be the ticket for managing all of the states and ensuring that we no longer have any issues with setup and teardown.
I started with creating a StateMachine class and an IState interface. I'll start with the IState, so the state machine makes more sense. The interface is incredibly simple. Each state has a Type (i.e. the key/state it represents) and Enter/Exit methods.
public interface IState<TState>
{
TState Type { get; }
void Enter();
void Exit();
}
The state machine would have methods for registering/adding states to it and moving to a given state. This was built with the intention of utilizing an enum as the defining factor for the keys/state names, hence the generic param. I have excluded most of the safety checks for brevity.
public class StateMachine<TState>
{
private readonly Dictionary<TState, IState<TState>> _states = new();
private IState<TState> _currentState;
public void AddState(IState<TState> state)
{
_states.Add(state.Type, state);
}
public void MoveToState(TState type)
{
if (!_states.TryGetValue(type, out var state)) return;
_currentState?.Exit();
_currentState = state;
_currentState.Enter();
}
}
It is a very simple implementation. You can add any number of states, then when you want to MoveTo the state, you just pass in the state to use. If there is a current state it will call the Exit method on it, then call Enter on the new state. This allows us to put setup logic in the Enter method and teardown in the Exit method.
While I built it with the intention of making specific IState implementations for each state, I ultimately didn't go that route. The main reason was because I didn't want to deal with passing in all the information/objects that the states might need. That would typically be handled by a blackboard or similar. Given the fact that this was for a game jam and my two other team mates aren't as familiar with game programming (not that I really am either...it is the slightly less blind leading the blind). Instead, I decided to make a SimpleState implementation that just fires events when the Enter/Exit methods are called on it.
public class SimpleState<TState> : IState<TState>
{
public event Action OnEnter;
public event Action OnExit;
public TState Type { get; }
public SimpleState(TState type)
{
Type = type;
}
public void Enter()
{
OnEnter?.Invoke();
}
public void Exit()
{
OnExit?.Invoke();
}
}
The main benefit of this is that it keeps all of the logic in the class that owns the state machine. The main downside is that it makes the owning class a little...verbose... Here is how the Warmth state machine is being initialized. You can see that we create a new state then assign event handlers for the Enter and Exit events on each one.
private readonly StateMachine<WarmthStatus> _warmthStateMachine = new();
var warmState = new SimpleState<WarmthStatus>(WarmthStatus.Warm);
warmState.OnEnter += OnEnterWarmthStateWarm;
warmState.OnExit += OnExitWarmthStateWarm;
_warmthStateMachine.AddState(warmState);
var normalState = new SimpleState<WarmthStatus>(WarmthStatus.Normal);
_warmthStateMachine.AddState(normalState);
var hypothermiaState = new SimpleState<WarmthStatus>(WarmthStatus.Hypothermia);
hypothermiaState.OnEnter += OnEnterWarmthStateHypothermia;
hypothermiaState.OnExit += OnExitWarmthStateHypothermia;
_warmthStateMachine.AddState(hypothermiaState);
var freezingState = new SimpleState<WarmthStatus>(WarmthStatus.Freezing);
freezingState.OnEnter += OnEnterWarmthStateFreezing;
freezingState.OnExit += OnExitWarmthStateFreezing;
_warmthStateMachine.AddState(freezingState);
Here is what the Freezing event handlers look like:
private void OnEnterWarmthStateFreezing()
{
_freezingTimer.Start();
_moraleMechanic.AddModifier(MoraleModifierType.Freezing);
_iconTray.AddWarmthIcon(WarmthStatus.Freezing);
}
private void OnExitWarmthStateFreezing()
{
_freezingTimer.Stop();
_freezingTimer.Reset();
_moraleMechanic.RemoveModifier(MoraleModifierType.Freezing);
_iconTray.RemoveWarmthIcon(WarmthStatus.Freezing);
}
As you can see, now we can handle starting the timer and cleaning it up in a nice, isolated manner. It doesn't matter what state we are coming from, nor where we are going. It takes care of everything it cares about.
This also made it really easy to add in another state machine to handle the Morale states, which have similar needs. Now, it was on to reworking the Icon tray and taking care of that sneaky ClearWarmthIcons call.
I needed to add a couple more icons to be displayed in the icon tray. Since I didn't build the icon tray system, I started digging into it to understand how it worked. While the overall structure was pretty good, there were a couple things that I noticed right off the bat.
- The
ClearWarmthIcons method clears out all of the icons and then the appropriate ones are reapplied every frame.
- Depending on how it was being handled, this could be fine, if excessive. However, I found that the icon GameObjects were being destroyed and instantiated every time. If you are not as familiar with Unity, this can cause a ton of overhead and potentially big chuggies when the garbage collector comes around. Honest mistake but still problematic
- There was a separate enum that holds all of the icon names but it doesn't quite map easily to the individual enums we already had (e.g. WarmthStatus and MoraleStatus)
- There was nothing wrong with this approach, it was just something that stood out to me
- The icon tray was being dynamically spawned and attached to the Crew
- Again, there isn't anything particularly wrong with this but I felt that it would be better to just make it a child of the actual Crew prefab so we don't have to worry about it not being there and could move almost all of the configuration on the component instead of needing it in the Crew script
- For example, the "Icon Library" was being defined in the Crew script and having to be passed in. After the rework, all of that config just lived on the Icon Tray itself
My main goal of the rework was to do the following:
- Change the individual "icon tray item" UI pieces to use existing instances and hide/show them instead of destroying and instantiating them every time
- Consolidate the icon items to be more by "type"
- Even though we have a handful of Warmth-related icons, we only ever want one to be shown at a time. Same with Morale.
- Rework where the icon tray lived and how it got its configuration.
Note: I know this sounds like a lot of negatives for this system, but that is only because that was what I was focusing on refactoring. There were quite a few good features in the icon tray that I did not have to touch (or they got touched incidentally because something else did). The overall structure for almost all of it was left intact but the coolest piece of it all was the dynamic resizing of the icons based on how close you were to the Crew. That was really cool and was left completely as-is. Good job, Andrew!
This refactor would hopefully take care of the first two goals. The idea was to replace the icon tray item spawning with discrete instances, one for each main "type" of icon that the tray supports. This would mean I only had to manage one instance per type, hiding it when no icons of that type were being displayed and showing it when appropriate. No need to instantiate or destroy on the fly and I get the "segregated" icons. Win-win!
Here is what it looked like after the initial refactor. We have one icon tray item for each "slot" we have on the tray. They all get initialized the same way, to ensure they all look consistent and are set up to utilize that nifty distance-scaling feature.
private void Awake()
{
_camera = Camera.main;
_baseScale = transform.localScale;
_warmthTrayItem = InitializeTrayItem();
_moraleTrayItem = InitializeTrayItem();
_stalkedTrayItem = InitializeTrayItem();
}
private IconTrayItemUI InitializeTrayItem()
{
var trayItem = Instantiate(_itemPrefab, _rowRoot, false);
trayItem.SetSize(_iconSizePx);
RemoveIcon(trayItem);
_allTrayItems.Add(trayItem);
return trayItem;
}
Next was expanding that concept to add "slot" specific methods for adding and removing icons. Originally, it was a more generic "AddIcon" method that took that enum I was talking about. I wanted to move away from that to prevent someone from trying to add multiple icons of the same type accidentally. Here is an example of the Warmth icons:
public void AddWarmthIcon(WarmthStatus status)
{
var icon = _iconLibrary.GetWarmthSprite(status);
AddIcon(_warmthTrayItem, icon);
}
public void RemoveWarmthIcon(WarmthStatus status)
{
RemoveIcon(_warmthTrayItem);
}
As you can see, the icon type separation goes all the way down to the Icon Library (more on that in a second). Then we just pass that to the AddIcon method, along with the slot that it should show up in. The AddIcon method is responsible for setting the sprite and ensuring it is visible (if we have a valid sprite) and the RemoveIcon does the opposite.
private void AddIcon(IconTrayItemUI trayItem, Sprite icon)
{
if (icon == null)
{
RemoveIcon(_moraleTrayItem);
}
else
{
trayItem.SetSprite(icon);
SetItemVisibility(trayItem, true);
}
}
private void RemoveIcon(IconTrayItemUI trayItem)
{
trayItem.RemoveSprite();
SetItemVisibility(trayItem, false);
}
This makes sure all the slots work the same but provides a more explicit interface to get the icons that you want. Speaking of getting the right icons, our Icon Library is a ScriptableObject, so we can have different Icon Libraries if we wanted to (like for different types of characters or something).
[CreateAssetMenu(fileName = "IconLibrary", menuName = "THGJ22/Icon Library")]
public class IconLibrary : ScriptableObject
{
[Header("Config")]
[SerializeField]
private List<IconKeyValue<WarmthStatus>> _warmthIcons = new();
[SerializeField]
private List<IconKeyValue<MoraleStatus>> _moraleIcons = new();
[SerializeField]
private Sprite _stalkedSprite;
public Sprite GetWarmthSprite(WarmthStatus status)
{
var icon = _warmthIcons.FirstOrDefault(w => w.Type == status);
return icon?.Icon;
}
public Sprite GetMoraleSprite(MoraleStatus status)
{
var icon = _moraleIcons.FirstOrDefault(w => w.Type == status);
return icon?.Icon;
}
public Sprite GetStalkedSprite()
{
return _stalkedSprite;
}
[Serializable]
private class IconKeyValue<T>
{
[field: SerializeField]
public T Type { get; private set; }
[field: SerializeField]
public Sprite Icon { get; private set; }
}
}
I ended up following the same pattern I talked about in the "Housekeeping and Morale" post the other day to make the "dictionary" visible in the Inspector, hence the private class at the bottom. Pretty simple, other than that. Here is how it looks in Unity.

The last part of the rework was to rearrange who owned "spawning" the icon tray and where its configuration was stored. The prefab and the actual icon library used to live on the Crew script but we've been trying to break out (potentially) independent systems into their own components, so I thought it made more sense to expand the existing IconTrayUI script to own more of that. Now you can set the icon library and the icon size per tray, unlike before.
I also moved the actual tray to always be on the Crew prefab instead of being dynamically spawned in. This is what that looks like in Unity.

The Row child GameObject of the IconTray has a Horizontal Layout Group that takes care of dynamically positioning the icon "slots" when they are enabled and disabled. I didn't add this, Andrew already had it. I just wanted to call it out because it was really helpful to have that already set up.
Once all the of the refactoring was completed, it made using the system much easier and it all looked good. Well, besides our goofy placeholder icons! Yes, the "Stalked" icon is a man with hearts above him. When all three icons are shown, the left one is the Warmth icon, the middle is the Morale icon, and the Stalked on the right.
You can see the Warmth and Morale icons being added in when the Crews' temperature drops low enough, then changing when their temp gets even lower. If you check the debug window in the top-left corner, you can see when the states change. You can also see the scaling in action as the last sled moves away. There is still a lot of fine tuning to get the icon sizing, position, and scaling just right but that will be cleaned up when get a bit further along. They are also all changing at the same time but that should change as we tweak buff/debuff/threshold values and add in the Glimmerstalker harassing mechanic.

The last thing I needed to get added in was the chance for the Crew to break if their Morale was too low. This mainly just involved adding a new Broke state and a method to check if the Crew should break.
Adding the new state was as simple as adding a new state to the Morale state machine and the event handlers for it.
private void InitializeMoraleStates()
{
// Add Other States
var brokeState = new SimpleState<MoraleStatus>(MoraleStatus.Broke);
brokeState.OnEnter += OnEnterMoraleStateBroke;
brokeState.OnExit += OnExitMoraleStateBroke;
_moraleStateMachine.AddState(brokeState);
}
private void OnEnterMoraleStateBroke()
{
_iconTray.AddMoraleIcon(MoraleStatus.Broke);
Debug.Log($"{name} broke due to {_moraleMechanic.CauseOfBreak.Type}");
}
private void OnExitMoraleStateBroke()
{
_iconTray.RemoveMoraleIcon(MoraleStatus.Broke);
}
The check was a little more involved because I had to add another "dictionary" so we can assign different break chance rates to the different Morale states. Once I had that in, the check became a pretty straight-forward case of "roll random number and check against a target DC". The one thing I want to call out though is that we only want to check if they break if the modifier being applied is negative. It would feel bad to Soothe the Crew and then they break because you tried helping.
private void CheckIfBreak(MoraleModifierBase modifierTemplate)
{
// Never break when adding positive modifiers
if (modifierTemplate.Amount >= 0) return;
var randomValue = UnityEngine.Random.Range(0, 100);
var breakChanceMap = _breakChances.FirstOrDefault(b => b.Status == _currentState);
var breakChance = breakChanceMap?.BreakChance ?? 0;
if (randomValue < breakChance)
{
CauseOfBreak = modifierTemplate;
SetState(MoraleStatus.Broke);
}
}
Here is what it looks like in the Inspector. You can see the break chances. In the Normal state there is no chance that they break, then a very small chance when they are Scared and a larger chance when they are Terrified. Honestly, I'm not sure if there should be a chance when they are only Scared but that will come out in playtesting.

Here is the break icon in action, another banger that is spot on.
