In my last post, we had wrapped a successful month, post-jam, polishing FOOTSLOG. Now it was time, as the programmer, to get started overhauling things. The groundwork for the project was coded during the four-day game jam, and August faced a lot of post-jam triages being placed upon end-of-jam-rush triages. If we wanted something that could be expanded and iterated upon, there was a lot of cleaning house to be done.
Logger
The first thing I knew I wanted was some helpful logging that would be easier to search for in the console and to turn parts on and off. Inspired by a logger Stephen Hodgson and Alon Farchy made while working on Burner Sphere, I decided to make my own.
It starts off by taking in a prefix name and a color that then turns into a prefix wrapped in square brackets and given a color via a rich text tag.
public enum LogLevel
{
Verbose,
Info,
Warning,
Error,
}
[Serializable]
public struct Logger
{
[SerializeField]
private string _prefix;
[SerializeField]
public LogLevel Level;
public UnityEngine.Object Obj;
public Logger(string prefix, Color color) : this(prefix, $"#{ColorUtility.ToHtmlStringRGBA(color)}")
{ }
public Logger(string prefix, string color)
{
_prefix = $"<color={color}>[{prefix}]</color>";
Level = LogLevel.Error;
Obj = null;
}
// ...
}
For example, _log = new(nameof(HudUI), "#df432eff")
when calling _log.Info("health 3")
would output:
The Level
is used to determine what kind of debug logs show up in our console. A logger with the level set to LogLevel.Warning
won’t print calls to the Info
nor Verbose
methods, but will print Warning
and Error
.
public void Verbose(string message)
{
if (Level == LogLevel.Verbose)
{
Debug.Log(Format(message));
}
}
public void Info(string message)
{
if (Level <= LogLevel.Info)
{
Debug.Log(Format(message));
}
}
public void Warning(string message)
{
if (Level <= LogLevel.Warning)
{
Debug.LogWarning(Format(message));
}
}
public void Error(string message) => Debug.LogError(Format(message));
These methods also allow the pass-in of not just a string
, but also have overloads for Func<string>
. Sometimes a lower-level log has a lot of information to gather and build into a string. Now that building can be avoided if the logger’s level wouldn’t have printed it, anyways.
public void Warning(Func<string> toString)
{
if (Level <= LogLevel.Warning)
{
Warning(toString());
}
}
Sometimes there may be multiple game objects with a component containing a logger. In those cases, that is what the public UnityEngine.Object Obj;
is for. In the Format
method, if the Obj
is not set to null, then its name will be included in the prepend to the message in the console logs.
private string Format(string message)
{
var sb = new StringBuilder(_prefix);
if (Obj.IsNotNull())
{
sb.Append(' ').Append(Obj.name).Append(':');
}
sb.Append(' ').Append(message);
return sb.ToString();
}
Just by calling _log.Obj = gameObject;
in the Enemy
’s Awake
method:
Of course, we want changing the logger’s value accessible to the designers, too. For that, I made a custom Property Drawer that allows the designer to change the prefix, color, and level.
It was great having this for cleaning things up, as I could be smarter with my console searches and declutter once I was done debugging.
Base Architecture
When working out the game’s base architecture, I pulled from three points of inspiration.
1) Service Locators. We made good use of them at Virtual Maker. Alon mentions it in his comprehensive Git & Unity guide.
2) A great video on Single Entry Point from PracticAPI.
3) Looking over the discontinued Unity Open Project’s Scene Architecture Diagram to think about using an EditorInitializer
to boot up the game from any scene.
I decided on the following: the Base Scene holds the ServiceLocator
with the different services as its children in the heirarchy. On Awake
, the locator will get the IService
components in its children. It’ll call ServiceEnable
for each service and invoke an event to signal the service has been enabled. The GameStateService
is a state machine with states that make calls to other services or affect game-state values other objects might be looking to for guidance.
When the application starts up with the Base Scene as the active scene, the default InitState
for the Game State Service will kick off the state machine. It make the calls necessary for the Main Menu scene to load in and to listen for the main menu’s start button to be pressed.
When the editor enters playmode from a different scene with an EditorInitializer
, the following plays out:
1) The Editor Initializer loads the missing Base Scene in.
2) The Game State Service sees on ServiceEnable
that the base class is a different scene. If that scene’s EditorInitializer
is storing a State, the Game State Service will start the state machine with this state instead.
// GameStateService.cs
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using VirtualMaker.Bindings;
namespace OEG.FootSlog.GameState
{
public class GameStateService : MonoBehaviour, IService
{
[SerializeField]
private Logger _log = new(nameof(GameStateService), "#AAFF99") { Level = LogLevel.Info };
[SerializeField]
private GameStateConfig _config;
[SerializeField]
internal Property<CursorLockMode> _cursorLock = new(CursorLockMode.Confined);
// More Properties ...
private Bindings _bindings = new();
private Bindings _stateBindings = new();
private IGameState _state;
public void ServiceEnable()
{
_bindings.AddUnsubscriber(() => _stateBindings.Reset());
_bindings.Bind(_cursorLock, mode =>
{
_log.Verbose($"{nameof(Cursor)}.{nameof(Cursor.lockState)} = {mode}");
Cursor.lockState = mode;
});
#if UNITY_EDITOR
IGameState state = new InitState();
var activeSc = SceneManager.GetActiveScene();
if (activeSc.buildIndex != _config.BaseScene.BuildIndex)
{
var roots = activeSc.GetRootGameObjects();
foreach (var root in roots)
{
if (root.TryGetComponent<EditorInitializer>(out var editorInit))
{
state = editorInit.State;
break;
}
}
}
SetState(state);
#else //UNITY_EDITOR
SetState(new InitState());
#endif //UNITY_EDITOR
}
public void ServiceDisable()
{
_bindings.Reset();
}
private void Update()
{
_state?.Update(Time.deltaTime);
}
private void SetState(IGameState state)
{
_stateBindings.Reset();
_state = state;
_log.Info($"{nameof(SetState)}: {state.GetType().Name}");
state.OnChange += SetState;
_stateBindings.AddUnsubscriber(() => state.OnChange -= SetState);
_stateBindings.AddUnsubscriber(() => state.Disable());
_state.Enable();
}
}
}
Overall, pretty solid, but there are a couple spots where the order of the services being enabled or the multi-scene loading could lead to errors inside a gamestate.
For the order of the services, I wrote an extension method for VirtualMaker’s Bindings called OnService
. It passes in an action using a specified service. If the service doesn’t exist to call the action immediately, the bindings will wait for the ServiceLocator
to signal the service being enabled to call it.
using VirtualMaker.Bindings;
using System;
namespace OEG.FootSlog
{
public static class BindingsServiceExtension
{
public static void OnService<T>(this Bindings bindings, Action<T> action) where T : IService
{
if (ServiceLocator.TryGet<T>(out var service))
{
action(service);
}
else
{
ServiceLocator.OnServiceEnabled += HandleServiceEnabled;
bindings.AddUnsubscriber(() => ServiceLocator.OnServiceEnabled -= HandleServiceEnabled);
}
void HandleServiceEnabled(Type type, IService iservice)
{
if (iservice is T service)
{
ServiceLocator.OnServiceEnabled -= HandleServiceEnabled;
action(service);
}
}
}
}
}
// Used here in the Enable of a game state. Waits for
// the SceneService to be available before making calls
// to load scenes, then changes to a new game state.
_bindings.OnService<ScenesService>(async scs =>
{
await scs.PushLayerAsync(game.Config.Gameplay);
await scs.AddToLayerAsync(_gameWorldBase);
await scs.PushLayerAsync(_roomReached);
Change(new DemoLevelState());
});
For handling the multi-scene loading, I added an ObjectFinderService
that objects can declare its type’s creation/deletion to, and for other objects to get a Property<T>
of a requested type that updates its value between the found object of type T or null when there isn’t one to be found.
using UnityEngine;
using System;
using System.Collections.Generic;
using VirtualMaker.Bindings;
namespace OEG.FootSlog
{
public class ObjectFinderService : MonoBehaviour, IService
{
Dictionary<Type, Property<object>> _dict;
public void ServiceEnable()
{
_dict = new();
}
public void ServiceDisable()
{
foreach (var (_, prop) in _dict)
{
prop.Value = null;
}
_dict.Clear();
}
public void Report<T>(T obj) where T : class
{
if (_dict.TryGetValue(typeof(T), out var result))
{
if (result.Value == null)
{
result.Value = obj;
}
}
else
{
_dict[typeof(T)] = new(obj);
}
}
public void Cancel<T>(T obj) where T : class
{
if (_dict.TryGetValue(typeof(T), out var result))
{
if (result.Value == (object)obj)
{
result.Value = null;
}
}
}
public IProperty<T> Get<T>() where T : class
{
if (!_dict.TryGetValue(typeof(T), out var result))
{
_dict[typeof(T)] = new();
}
return Derived.From(_dict[typeof(T)], obj => obj == null ? null : (T)obj);
}
public bool TryGet<T>(out IProperty<T> result) where T : class
{
result = Get<T>();
return result.Value != null;
}
}
}
This, along with writing another bindings extension, BindFoundObject
, helped immensely.InitState
can listen for the main menu’s start button to be pressed, and controllers loaded in via an additive scene can find their manager to report for duty ASAP.
// Both extensions used here in the Enable of the InitState.
// Waits for the ObjectFinderService to be enabled, then
// Listens to the MainMenuController's OnStartGameClicked
// event whenever it can be found.
_bindings.OnService<ObjectFinderService>(finder =>
{
_bindings.BindFoundObject<MainMenuController>(finder, (menu, subBindings) =>
{
subBindings.On(menu.OnStartGameClicked, () => OnChange?.Invoke(new GameStartState()));
});
});
Other Services
Along with the Game State and Object Finder services, there’s a couple more worth pointing out.
- Audio: Created during the jam, the primary refactor since then is the creation of commands. The commands’ constructors will
TryGet
the service before calling one of its public methods likePlaySound2D
,PlaySound3D
, orPlayAttachedSound
. - Gizmo: Can temporily draw Gizmos lines and set colors that then disappear. Used for showing the firing lines of a scatter shot.
- FadeScreen: Used for the fade to and from colors for scene transitions that’ll change the active scene.
The Scene Service has plenty of work I’ve done on it, but I think I’ll keep those details for a future post.
Closing Thoughts
I’m very happy with how things are coming together. Looking at logs on the task board, this all took about eighteen and a half hours, of which eight were spent making the game state service from scratch. The time spent appears to have been worth it. I’m loving the game state interfaces and how much faster it is to track/fix the game state. I’m also loving the service locator not being needed in every scene thanks to the single entry point and editor initializers. It has been a remarkable improvement, and the game is much more likely to succeed with this new foundation.