The thought came early (in part 3) that a game balance config for values like StartingUnits
,
would be valuable to have for balancing out during the development period.
The config should be accessible and validate the values (ideally immediately when changed),
and then later at runtime be injected to the Health
instance.
There are other values in Health
that are suitable, thus the config will include:
StartingUnits
PointsPerUnit
MaxUnits
MaxNegativeUnitsForInstantKillProtection
ScriptableObject
Assuming that the game designer can do basic work in Unity and the version control system,
we can keep the config solution completely within Unity.
I'll thus base it on Unity's ScriptableObject,
a data container that can be used to save large amounts of data.
I create folder ScriptableObjects/ at Assets/Scripts/Runtime/ and create script GameConfig
within it.
// GameConfig.cs
using UnityEngine;
[CreateAssetMenu(fileName = "GameConfigInstance",
menuName = "Avatar Health/Create GameConfig Instance",
order = 1)]
public class GameConfig : ScriptableObject {
public int StartingUnits = 3;
public int PointsPerUnit = 4;
public int MaxUnits = 30;
public int MaxNegativeUnitsForInstantKillProtection = -5
}
I create another folder ScriptableObjectsInstances/ at Assets/
and because of the code above I can now right click on the folder / click at Assets at top menu,
and select menu item Create > Avatar Health > Create GameConfig Instance.
As the name applies this creates a scriptable object instance based on the GameConfig
class 'template'.
I use the given default filename GameConfigInstance
(file ends with .asset).
I can now view and edit the Game Config Instance via the Inspector window.
I can change the values as I see fit and the change will be saved on the instance.
But there is no domain validation for the values, so I TDD cycle it.
Unit testing logic in scriptable objects can quickly become complicated.
For this reason, and also for Reusability and Separation of Concern,
I will implement the generic validation logic in a utility class which will be easy to TDD.
Then I'll make the config process the validation using the utility logic.
I create the folder Utils in the test folder
(Assets/Scripts/Tests/EditMode/) and create script ValidationTest
within it.
I take into consideration the requirements for both the config
and the other validations that Health
does, and then write the tests.
// ValidationTest.cs
using NUnit.Framework;
[TestFixture]
public class ValidationTest
{
public class Validate
{
[TestCase(12, 1)]
[TestCase(1, 1)]
[TestCase(4, 2)]
[TestCase(2, 2)]
[TestCase(-20, int.MinValue, -1)]
[TestCase(-1, int.MinValue, -1)]
public void Passes(int v, int lowestValidV, int highestValidV = int.MaxValue)
{
(bool, int, string) ret = Validation.Validate(v, lowestValidV, highestValidV);
Assert.That(ret.Item1, Is.True);
Assert.That(ret.Item2, Is.EqualTo(v));
Assert.That(ret.Item3, Is.EqualTo(""));
}
[TestCase(0, 1)]
[TestCase(-1, 0)]
public void Fails_WhenValueLower(int v, int lowestValidV)
{
(bool, int, string) ret = Validation.Validate(v, lowestValidV, int.MaxValue);
Assert.That(ret.Item1, Is.False);
Assert.That(ret.Item2, Is.EqualTo(lowestValidV));
Assert.That(ret.Item3, Does.Match("invalid").IgnoreCase);
}
[TestCase(0, int.MinValue, -1)]
[TestCase(1, int.MinValue, 0)]
public void Fails_WhenValueHigher(int v, int lowestValidV, int highestValidV)
{
(bool, int, string) ret = Validation.Validate(v, lowestValidV, highestValidV);
Assert.That(ret.Item1, Is.False);
Assert.That(ret.Item2, Is.EqualTo(highestValidV));
Assert.That(ret.Item3, Does.Match("invalid").IgnoreCase);
}
}
}
I wrote tests for all meaningful cases based on the requirements in one step.
The validation logic returns a tuple:
/*success*/(bool IsValid = true, int Value, string failMessage = "")
/*fail*/(bool IsValid = false, int Value /*corrected to valid edge*/, string failMessage)
The using code should then decide what to do.
I mirror the location of the test, thus create the folder Utils in the production code folder (Assets/Scripts/Runtime/) and create script Validation
within it.
I implement it gradually until I get all the tests passing.
// Validation.cs
public static class Validation
{
public static (bool, int, string) Validate(int v, int lowestValidV, int highestValidV = int.MaxValue)
{
var message = "";
if (v >= lowestValidV && v <= highestValidV)
{
return (true, v, message);
}
message = $"Value {v} is invalid, it should be within the range of {lowestValidV} and {highestValidV}";
int retV = highestValidV == int.MaxValue ? lowestValidV : highestValidV;
return (false, retV, message);
}
}
I want to make sure the validation can't be bypassed
thus I'll prevent the game designer from saving invalid values to the config,
and provide some message in the Unity editor when an invalid value is corrected.
// GameConfig.cs
private void OnValidate()
{
var v = Validation.Validate(StartingUnits, 1);
StartingUnits = ProcessValidation(v, nameof(StartingUnits));
v = Validation.Validate(PointsPerUnit, 2);
PointsPerUnit = ProcessValidation(v, nameof(PointsPerUnit));
v = Validation.Validate(MaxNegativeUnitsForInstantKillProtection, int.MinValue, -1);
MaxNegativeUnitsForInstantKillProtection = ProcessValidation(v, nameof(MaxNegativeUnitsForInstantKillProtection));
v = Validation.Validate(MaxUnits, StartingUnits);
MaxUnits = ProcessValidation(v, nameof(MaxUnits));
}
private int ProcessValidation((bool IsValid, int Value, string FailMessage) validation, string fieldName)
{
if (!validation.IsValid)
{
Debug.LogWarning(validation.FailMessage + $", for '{fieldName}'. Will set value to {validation.Value}.");
}
return validation.Value;
}
I use OnValidate
, a editor-only function that Unity calls when the script is loaded or a value changes in the Inspector
(part of the ScriptableObject).
As the gif shows above, the logic accepts valid values (the first one)
but prevents invalid values being set by reverting to the valid 'edge' value, and logging a message to the Console.
1
for StartingUnits
2
for PointsPerUnit
-1
for MaxNegativePointsForInstantKillProtection
Below is the gif for the MaxUnits
validation.
MaxUnits
validation is based on the values of StartingUnits
thus MaxUnits
can become invalid when StartingUnits
is set to a valid value,
(as the latter half of the gif conveys).
The logged message in the Console clarifies this occurrence.
Health
use the config
I want Health
to use the config
and not provide another way to inject those values,
thus I don't need to validate those values again in Health
.
(I have ownership of the config code and
I am confident that its validation will always work as expected).
I will refactor the health code in the steps presented below.
This is about a design change in the production code (wholistic refactoring),
and it this case it makes more sense to start with the production code.
Health
// Health.cs
private readonly GameConfig config;
public Health(GameConfig gameConfig)
{
config = gameConfig;
FullPoints = CurrentPoints = config.StartingUnits * config.PointsPerUnit;
}
I inject the GameConfig
instance into the constructor,
and store it in a member and then access the values from the member.
The member is private readonly
and thus encapsulated and immutable within the class.
I remove the old startingUnits
validation (as the config is already validated).
// Health.cs
public int PointsPerUnit => config.PointsPerUnit;
public int MaxUnits => config.MaxUnits;
public int MaxNegativeUnitsForInstantKillProtection => config.MaxNegativeUnitsForInstantKillProtection;
I refactor these constants into read-only properties,
getting the value from the config.
This way the config values can't be set via the Health
instance (encapsulation).
HealthTest
Now many tests will fail,
mainly because they are not injecting the config into the constructor.
Let's refactor and fix them.
// HealthTest.cs
public class HealthTest
{
public static Health MakeHealth(int startingUnits)
{
var config = MakeConfig(startingUnits);
var health = new Health(config);
return health;
}
public static GameConfig MakeConfig(int startingUnits = 3)
{
var config = ScriptableObject.CreateInstance<GameConfig>();
config.StartingUnits = startingUnits;
config.PointsPerUnit = 4;
config.MaxUnits = 30;
return config;
}
I create these helper methods above.MakeConfig
creates the config
and sets the startingUnits
via parameter.
It also sets the other values for better Resistance to refactoring
(the tests will still work if the default values are changed in the scriptable object).MakeHealth
creates and return a Health
instance (using MakeConfig
).
For most tests it is sufficient to swap the MakeHeath
for the new Health
construction, as shown below
(one command execution with the IDE).
HealthTest.cs
[Test]
public void IsDead_IsFalse()
{
var health = MakeHealth(3);
Assert.That(health.IsDead, Is.False);
}
There are 3 tests that refer to maxUnits
to set up StartingUnits
the the test.
These tests use MakeConfig
directly and then create the Health
instance.
// HealthTest.cs
[Test]
public void ReturnsTrue_WhenStartingUnitsAtMax()
{
var config = MakeConfig();
config.StartingUnits = config.MaxUnits;
var health = new Health(config);
Assert.That(health.IsMaxUnitsReached, Is.True);
}
Now only test ThrowsError_WhenStartingPointsIsInvalid
fails.
I remove it as I'm no longer validating StartingUnits
in Health
.
Now all the tests pass again.
Validation
Since now I have a utility Validation
class
I want to reuse it in the Health
methods,
thus I refactor the ValidatePoints
method:
// Health.cs
private static void ValidatePoints(int points, int lowestValidValue)
{
(bool IsValid, int Value, string FailMessage) v = Validation.Validate(points, lowestValidValue);
if (!v.IsValid)
{
throw new ArgumentOutOfRangeException(nameof(points), v.FailMessage);
}
}
Now the config has been added and used, the Unity Test Runner looks like this: