Play as an officer in charge of keeping the injured alive amidst a futuristic war, where life and death are a roll of a dice. In this UI-based management sim, carefully assess each day's incoming patients and assign care providers to keep as many alive while balancing the hospital morale.
Role: Programmer
Project Type: Game Jam - GMTK 2022
Tools: Unity, FMOD, Jetbrains Rider, Github
Duration: July 2022
Team Size: 7
Genre: Management
Platform: PC
Project Status: Completed
When designing the back-end, I had to reframe my thinking pattern - the game was effectively moving data from place to place. That meant realizing I wasn't so much "making a game" as "making a database." This helped inform a lot of the design choices in programming and has helped guide my understanding on managing UI elements.
public class Patient
{
public string Name;
public Injury Injury;
public List AssignedProviders;
public PatientBackground Background;
public Patient(string name, PatientBackground background, Injury injury)
{
AssignedProviders = new List();
Background = background;
Name = name;
Injury = injury;
}
public int GetDiceThrow()
{
return Random.Range(1, 21);
}
public Throw GetThrow()
{
return new Throw(1, 20, GetDiceThrow());
}
public void GetSurvivalData(out Throw naturalThrow,
out List providerThrows, out int survivalThreshold,
out int throwSum, out bool survived)
{
naturalThrow = GetThrow();
throwSum = naturalThrow.GetThrow();
providerThrows = new List();
foreach (CareProvider careProvider in AssignedProviders)
{
int provThrow = careProvider.GetDiceThrow();
providerThrows.Add(new Throw(careProvider.CurrentMorale, careProvider.Role.MaxDiceValue, provThrow));
throwSum += provThrow;
}
survivalThreshold = Injury.SurvivalThreshold;
survived = throwSum >= survivalThreshold;
}
public void UpdateCareProviderMorales(bool survived)
{
foreach (CareProvider careProvider in AssignedProviders)
{
careProvider.MoraleChangeFromPatientOutcome = survived ? 0 :
-Background.Rank.PrestigeLevel;
}
}
}
public class CareProvider : ScriptableObject
{
public string Name;
public Color indicatorColor = Color.white;
public CareProviderRole Role;
public int CurrentMorale { get; set; }
public int PreviousMorale { get; set; }
public int MoraleChangeFromBreakroom = 0;
public int MoraleChangeFromPatientOutcome = 0;
public int MoraleChangeFromGlobalEvent = 0;
public Sprite ProviderSprite;
public int GetDiceThrow()
{
return Random.Range(CurrentMorale, Role.MaxDiceValue + 1);
}
public void UpdateCurrentMorale(out int previousMorale,
out int patientMorale, out int breakroomMorale,
out int hospitalMorale, out int finalMorale)
{
previousMorale = CurrentMorale;
patientMorale = MoraleChangeFromPatientOutcome;
breakroomMorale = MoraleChangeFromBreakroom;
hospitalMorale = MoraleChangeFromGlobalEvent;
int totalMoraleDelta = MoraleChangeFromBreakroom +
MoraleChangeFromPatientOutcome + MoraleChangeFromGlobalEvent;
CurrentMorale =
Mathf.Clamp(CurrentMorale + totalMoraleDelta, 1, Role.MaxMorale);
finalMorale = CurrentMorale;
// Reset morale changes
MoraleChangeFromPatientOutcome = 0;
MoraleChangeFromBreakroom = 0;
MoraleChangeFromGlobalEvent = 0;
}
}
When changing the UI, a heavy emphasis was placed on the "when." For UI on the active gameplay screen, we knew that we wanted to update the UI whenever the value changed. However, screens like the results display needed to only be updated when the user submitted information. From there, it was simply understanding when data was generated.
public void CalculatePatientRoll()
{
observedPatient.GetSurvivalData(out Throw naturalThrow,
out List providerThrows, out int survivalThreshold,
out int throwSum, out bool survived);
observedPatient.UpdateCareProviderMorales(survived);
if (!survived)
{
deathCountVariable.Value += 1;
}
NaturalThrow.RandomDiceRoll(naturalThrow);
for (int i = 0; i < observedPatient.AssignedProviders.Count; i++)
{
if (i == 0)
{
ProviderThrowA.RandomDiceRoll(providerThrows[i]);
}
else
{
ProviderThrowB.RandomDiceRoll(providerThrows[i]);
}
}
throwSumText.text = throwSum.ToString();
int spriteIndex = survived ? 0 : 1;
survivalSprite.sprite = liveDieSprite[spriteIndex];
UpdateDisplay();
}
private void UpdateDisplay()
{
nameText.text = observedPatient.Name.ToUpper();
prestigeText.text = "PRESTIGE CLASS: " +
observedPatient.Background.Rank.GetPrestigeGroup().ToString();
severityText.text = "ROLL NEEDED: " + observedPatient.Injury.SurvivalThreshold.ToString();
for (int i = 0; i < observedPatient.AssignedProviders.Count; i++)
{
if (i == 0)
{
ProviderAText.text = observedPatient.AssignedProviders[i].Name;
}
else
{
ProviderBText.text = observedPatient.AssignedProviders[i].Name;
}
}
public void UpdateDisplay()
{
NameField.text = provider.Name;
RoleField.text = provider.Role.RoleName;
DiceField.text = "Care Bonus: " + provider.CurrentMorale + " - " + provider.Role.MaxDiceValue;
backgroundTint.color = provider.indicatorColor;
moraleSlider.maxValue = provider.Role.MaxMorale;
moraleSlider.minValue = 1;
moraleSlider.value = provider.CurrentMorale;
moraleColor.color = Color.Lerp(Color.red,Color.green,
(provider.CurrentMorale) / provider.Role.MaxMorale);
if (AssignedToBreakroom && AssignedToPatient)
{
StatusField.text = "ERROR";
}
else if (AssignedToBreakroom)
{
StatusField.text = "Break";
}
else if (AssignedToPatient)
{
StatusField.text = "Patient";
}
else
{
StatusField.text = "";
}
}
public void OnDrag(PointerEventData eventData)
{
transform.position = eventData.position;
}
public void OnBeginDrag(PointerEventData eventData)
{
_layoutElement.ignoreLayout = true;
currentProvider.Value = provider;
}
public void OnEndDrag(PointerEventData eventData)
{
_layoutElement.ignoreLayout = false;
PointerEventData pointer = new PointerEventData(EventSystem.current);
pointer.position = Input.mousePosition;
List raycastResults = new List();
EventSystem.current.RaycastAll(pointer, raycastResults);
if (raycastResults.Count > 0)
{
foreach (var go in raycastResults)
{
// TODO: Signal the player the doctor cannot be assigned because with patient.
BreakroomDisplay breakroomDisplay =
go.gameObject.GetComponent();
if (breakroomDisplay != null && !AssignedToPatient && !AssignedToBreakroom)
{
int breakroomDisplayIndex = breakroomDisplay.index;
if(breakroomProvider.CheckValidPlacement(breakroomDisplayIndex)){
breakroomProvider.AddAtIndexUnique(currentProvider.Value,
breakroomDisplayIndex);
AssignedToBreakroom = true;
UpdateDisplay();
}
break;
}
PatientListItemObserver patientObserver =
go.gameObject.GetComponent();
if (patientObserver != null && !AssignedToBreakroom && !AssignedToPatient)
{
// TODO: Signal the player the doctor cannot be assigned because in breakroom.
bool success = patientObserver.TryAddProvider(currentProvider.Value);
if (success)
{
AssignedToPatient = true;
UpdateDisplay();
break;
}
}
}
currentProvider.Value = null;
}
}
public void CalculatePatientRoll()
{
observedPatient.GetSurvivalData(out Throw naturalThrow,
out List providerThrows, out int survivalThreshold,
out int throwSum, out bool survived);
observedPatient.UpdateCareProviderMorales(survived);
if (!survived)
{
deathCountVariable.Value += 1;
}
NaturalThrow.RandomDiceRoll(naturalThrow);
for (int i = 0; i < observedPatient.AssignedProviders.Count; i++)
{
if (i == 0)
{
ProviderThrowA.RandomDiceRoll(providerThrows[i]);
}
else
{
ProviderThrowB.RandomDiceRoll(providerThrows[i]);
}
}
throwSumText.text = throwSum.ToString();
int spriteIndex = survived ? 0 : 1;
survivalSprite.sprite = liveDieSprite[spriteIndex];
UpdateDisplay();
}
private void UpdateDisplay()
{
nameText.text = observedPatient.Name.ToUpper();
prestigeText.text = "PRESTIGE CLASS: " +
observedPatient.Background.Rank.GetPrestigeGroup().ToString();
severityText.text = "ROLL NEEDED: " + observedPatient.Injury.SurvivalThreshold.ToString();
for (int i = 0; i < observedPatient.AssignedProviders.Count; i++)
{
if (i == 0)
{
ProviderAText.text = observedPatient.AssignedProviders[i].Name;
}
else
{
ProviderBText.text = observedPatient.AssignedProviders[i].Name;
}
}
FMOD is an audio middleware used to support dynamic audio and make collaboration between composers and programmers as seamless as possible. While playing the game, users will notice that the layers making up the background audio may change. This is done by reading the total morale across all providers and changing a parameter in FMOD.
private void OnEnable()
{
TotalMorale.ValueUpdated += UpdateGameplayLayers;
selectedPatient.ValueUpdated += UpdateInView;
currentProvider.ValueUpdated += HandlePickup;
}
private void OnDisable()
{
TotalMorale.ValueUpdated -= UpdateGameplayLayers;
selectedPatient.ValueUpdated -= UpdateInView;
currentProvider.ValueUpdated -= HandlePickup;
}
private void UpdateInView()
{
if (selectedPatient.Value != null)
{
FMODUnity.RuntimeManager.StudioSystem.setParameterByName("In Patient View", 1);
}
else
{
FMODUnity.RuntimeManager.StudioSystem.setParameterByName("In Patient View", 0);
}
}
private void UpdateGameplayLayers()
{
float value;
int val= ((TotalMorale.Value - 2) / 2) - 1;
index = val;
FMOD.RESULT result = FMODUnity.RuntimeManager.StudioSystem.setParameterByName("Morale Level", index);
FMODUnity.RuntimeManager.StudioSystem.getParameterByName("Morale Level", out value);
}
public void startBackground()
{
_soundInstance = FMODUnity.RuntimeManager.CreateInstance("event:/SFX/Gameplay Ambience");
_soundInstance.start();
}
To control the behavior of monobehaviors, the scripts listen for a signal emitted by a scriptable object. In that same vein, monobehaviors can tell the scriptable object to emit the signal. This is how flow can be determined.
[SerializeField] private List listeners = new List();
/// <summary>
/// Calls the OnEventRaised method of all registered listeners in reverse
/// order. The reverse order allows listeners to be removed from the
/// listener list as a result of a raised event.
/// </summary>
public void Raise()
{
if (listeners.Count > 0) {
for (int i = listeners.Count - 1; i >= 0; i--)
{
listeners[i].OnEventRaised();
}
}
}
/// <summary>
/// Adds a listener to the list of listeners. Each listener in the list
/// will have its OnEventRaised method called when this GameEvent is raised.
/// </summary>
/// <param name="listener">The listener to add to this GameEvent's list of
/// listeners.</param>
public void RegisterListener(GameEventListener listener)
{
listeners.Add(listener);
}
/// <summary>
/// Removes a listener to the list of listeners, if the passed listener
/// exists in the list.
/// </summary>
/// <param name="listener">The listener to remove from this GameEvent's list
/// of listeners.</param>
public void UnregisterListener(GameEventListener listener) {
if (listeners.Contains(listener)) {
listeners.Remove(listener);
}
}
}
public class GameEventListener : MonoBehaviour
{
/// <summary>
/// GameEvent that this listener will listen to.
/// </summary>
[Tooltip("GameEvent that this listener will listen to.")]
public GameEvent Event;
/// <summary>
/// Responses to trigger when the event is raised.
/// </summary>
[Tooltip("Responses to trigger when the event is raised.")]
public UnityEvent Response;
#region MonoBehaviour Methods
private void OnEnable()
{
Event.RegisterListener(this);
}
private void OnDisable()
{
Event.UnregisterListener(this);
}
#endregion
/// <summary>
/// Responds the the assigned GameEvent's raise call by invoking a Unity
/// Event.
/// </summary>
public void OnEventRaised()
{
Response.Invoke();
}