Skip to content

Instantly share code, notes, and snippets.

@thsbrown
Last active August 5, 2025 04:22
Show Gist options
  • Select an option

  • Save thsbrown/af0be2c7193a7b5ffeca09ad020ff9e6 to your computer and use it in GitHub Desktop.

Select an option

Save thsbrown/af0be2c7193a7b5ffeca09ad020ff9e6 to your computer and use it in GitHub Desktop.
Example of a Service in Command Center Earth (Singleton)
using System;
using System.Linq;
using _StudioName.Scripts.Runtime.Attributes;
using Sirenix.OdinInspector;
using Sirenix.Serialization;
using StudioName.Runtime;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace _Game_Assets.Scripts.Systems
{
[ShowOdinSerializedPropertiesInInspector]
[Singleton("",false)]
public class ControlsService : Singleton<ControlsService>, DefaultControls.IMissileActions, ISerializationCallbackReceiver
{
/// <summary>
/// The parent gameobject that contains our mobile button basic layout. <see cref="MobileButtonLayout.Basic"/>
/// </summary>
[Tooltip("The parent gameobject that contains our mobile button basic layout. See MobileButtonLayout.Basic")]
public GameObject mobileBasicButtonLayout;
/// <summary>
/// The canvas we will use to render our controls on screen.
/// </summary>
public Canvas controlsCanvas;
/// <summary>
/// Settings that will be used to render our controls on screen.
/// </summary>
public ControlsScreenSpaceRenderModeSettings controlsScreenSpaceRenderModeSettings;
/// <summary>
/// Fires when <see cref="IsLeftThrustControlActivated"/> value has been changed from true to false or vice versa
/// <remarks>The value returned is the new value of the property</remarks>
/// </summary>
public event Action<bool> OnLeftThrustControlActivatedChanged;
/// <summary>
/// Fires when <see cref="IsRightThrustControlActivated"/> value has been changed from true to false or vice versa
/// <remarks>The value returned is the new value of the property</remarks>
/// </summary>
public event Action<bool> OnRightThrustControlActivatedChanged;
/// <summary>
/// Fires when <see cref="IsBoostControlActivated"/> value has been changed from true to false or vice versa
/// <remarks>The value returned is the new value of the property</remarks>
/// </summary>
public event Action<bool> OnBoostControlActivatedChanged;
/// <summary>
/// Fires when <see cref="isSelfDestructControlActivated"/> value has been changed from true to false or vice versa
/// <remarks>The value returned is the new value of the property</remarks>
/// </summary>
public event Action<bool> OnSelfDestructControlActivatedChanged;
/// <summary>
/// Fires when the control scheme updates.
/// <remarks>The value returned is the new control scheme set. See <see cref="DEFAULT_MOBILE_CONTROL_SCHEME"/> <see cref="DEFAULT_PC_CONTROL_SCHEME"/>, <see cref="DEFAULT_GAMEPAD_CONTROL_SCHEME"/></remarks>
/// </summary>
public event Action<string> OnControlSchemeChanged;
/// <summary>
/// The basic control scheme that will be used for mobile .
/// </summary>
public const string DEFAULT_MOBILE_CONTROL_SCHEME = "Touch - Basic";
/// <summary>
/// The controls scheme that will be used for mouse and keybaord.
/// </summary>
public const string DEFAULT_PC_CONTROL_SCHEME = "Keyboard&Mouse";
/// <summary>
/// The controls scheme that will be used for gamepad.
/// </summary>
public const string DEFAULT_GAMEPAD_CONTROL_SCHEME = "Gamepad";
/// <summary>
/// The default controls input action collection used in our game
/// </summary>
private DefaultControls defaultControls;
private bool isLeftThrustControlActivated;
private bool isRightThrustControlActivated;
private bool isBoostControlActivated;
private bool isSelfDestructControlActivated;
private bool isLeftThrustControlDisabled;
private bool isRightThrustControlDisabled;
private bool isBoostControlDisabled;
private bool isSelfDestructControlDisabled;
private bool callTerminateOnDestroy;
private void OnEnable()
{
DefaultControls.Missile.AddCallbacks(this);
}
private void OnDisable()
{
DefaultControls.Missile.RemoveCallbacks(this);
}
protected override void SingletonOnDestroy()
{
if (!callTerminateOnDestroy)
{
return;
}
Terminate();
}
/// <summary>
/// Initializes our control service
/// </summary>
/// <param name="controlsRenderCamera">The camera that we will use to render our controls.</param>
/// <param name="callTerminateOnDestroy">
/// If true, will call <see cref="Terminate"/> when the singleton is destroyed. Set false if you want to call <see cref="Terminate"/> manually.
/// </param>
public void Initialize(Camera controlsRenderCamera, bool callTerminateOnDestroy = false)
{
controlsCanvas.renderMode = RenderMode.ScreenSpaceCamera;
controlsCanvas.worldCamera = controlsRenderCamera;
controlsCanvas.planeDistance = controlsScreenSpaceRenderModeSettings.planeDistance;
controlsCanvas.sortingLayerID = controlsScreenSpaceRenderModeSettings.sortingLayer;
controlsCanvas.sortingOrder = controlsScreenSpaceRenderModeSettings.sortingOrder;
this.callTerminateOnDestroy = callTerminateOnDestroy;
//ensure all controls are disabled by default
ToggleControls(false);
//in editor or on pc, mac, or linux enable basic pc button layout by default
#if UNITY_EDITOR || UNITY_STANDALONE
DefaultControls.bindingMask = new InputBinding
{
groups = DEFAULT_PC_CONTROL_SCHEME
};
//on mobile enable basic mobile button layout by default
#elif UNITY_ANDROID || UNITY_IOS
DefaultControls.bindingMask = new InputBinding
{
groups = DEFAULT_MOBILE_CONTROL_SCHEME
};
#endif
InputSystem.onEvent += OnInputSystemOnEventUpdateControlSchemeAndMobileButtonsHandler;
OnControlSchemeChanged += OnControlSchemeChangedHideCursorHandler;
//hide or show our cursor immediately depending on our current control scheme
OnControlSchemeChangedHideCursorHandler(ActiveControlScheme);
}
/// <summary>
/// Terminates our control service.
/// </summary>
public void Terminate()
{
InputSystem.onEvent -= OnInputSystemOnEventUpdateControlSchemeAndMobileButtonsHandler;
OnControlSchemeChanged -= OnControlSchemeChangedHideCursorHandler;
}
/// <summary>
/// Turns all controls on or off. If any controls were toggled using ToggleBlankControl will reset to isEnabled value
/// </summary>
/// <param name="isEnabled">true to enable all controls, false to disable all controls</param>
public void ToggleControls(bool isEnabled)
{
ToggleLeftThrustControl(isEnabled);
ToggleRightThrustControl(isEnabled);
ToggleBoostControl(isEnabled);
ToggleSelfDestructControl(isEnabled);
}
/// <summary>
/// Enable / Disables the left thrust control
/// </summary>
/// <param name="isEnabled">true if left thrust control is enabled, false to disable it</param>
/// <returns>The control service to allow chaining</returns>
/// TODO given that most logic is handled in the property setter we should consider if replacing this with public property is better
public ControlsService ToggleLeftThrustControl(bool isEnabled)
{
IsLeftThrustControlDisabled = !isEnabled;
return this;
}
/// <summary>
/// Enable / Disables the right thrust control
/// </summary>
/// <param name="isEnabled">true if right thrust control is enabled, false to disable it</param>
/// <returns>The control service to allow chaining</returns>
/// TODO given that most logic is handled in the property setter we should consider if replacing this with public property is better
public ControlsService ToggleRightThrustControl(bool isEnabled)
{
IsRightThrustControlDisabled = !isEnabled;
return this;
}
/// <summary>
/// Enable / Disabled the boost control
/// </summary>
/// <param name="isEnabled">true if boost control is enabled, false to disable it</param>
/// <returns>The control service to allow chaining</returns>
/// TODO given that most logic is handled in the property setter we should consider if replacing this with public property is better
public ControlsService ToggleBoostControl(bool isEnabled)
{
IsBoostControlDisabled = !isEnabled;
return this;
}
/// <summary>
/// Enable / Disabled the self destruct control
/// </summary>
/// <param name="isEnabled">true if self destruct control is enabled, false to disable it</param>
/// <returns>The control service to allow chaining</returns>
/// TODO given that most logic is handled in the property setter we should consider if replacing this with public property is better
public ControlsService ToggleSelfDestructControl(bool isEnabled)
{
IsSelfDestructControlDisabled = !isEnabled;
return this;
}
/// <summary>
/// Will immediately enable our on-screen mobile button controls if the current control scheme is a touch control scheme.
/// Additionally will enable <see cref="OnScreenControlsDisplaySessionIsActive"/> to ensure on screen controls
/// are toggled on and off depending on the current state of our control scheme.
/// <remarks>
/// Control Scheme is always auto updated depending on the state of our most recent input.
/// This means when our sessions is active on-screen controls will be enabled when a touch control scheme is active
/// and disabled when any other type of control scheme is active.
/// </remarks>
/// </summary>
/// TODO if this method proves troublesome we can tie auto updating on-screen controls visibility to
/// TODO DefaultControls.Missile.enabled. Meaning that we would just automatically show or hide our on-screen
/// TODO controls when our missile controls are enabled or disabled.
public void BeginOnScreenControlsDisplaySession()
{
//TODO update this when we do our advanced mobile controls.
if(ActiveControlScheme == DEFAULT_MOBILE_CONTROL_SCHEME)
{
SetMobileButtonLayout(MobileButtonLayout.Basic);
}
OnScreenControlsDisplaySessionIsActive = true;
}
/// <summary>
/// Will immediately disable our on-screen mobile button controls if they are visible and end
/// our display session by disabling <see cref="OnScreenControlsDisplaySessionIsActive"/>
/// </summary>
public void EndOnScreenControlsDisplaySession()
{
OnScreenControlsDisplaySessionIsActive = false;
SetMobileButtonLayout(MobileButtonLayout.Disabled);
}
public void OnSelfDestruct(InputAction.CallbackContext context)
{
IsSelfDestructControlActivated = context.phase switch
{
InputActionPhase.Performed => true,
InputActionPhase.Canceled => false,
_ => IsSelfDestructControlActivated
};
}
public void OnBoost(InputAction.CallbackContext context)
{
IsBoostControlActivated = context.phase switch
{
InputActionPhase.Performed => true,
InputActionPhase.Canceled => false,
_ => IsBoostControlActivated
};
}
public void OnHorizontalThrust(InputAction.CallbackContext context)
{
var value = context.ReadValue<float>();
switch (value)
{
case < 0:
IsLeftThrustControlActivated = true;
IsRightThrustControlActivated = false;
break;
case > 0:
IsLeftThrustControlActivated = false;
IsRightThrustControlActivated = true;
break;
case 0:
IsLeftThrustControlActivated = false;
IsRightThrustControlActivated = false;
break;
}
}
/// <summary>
/// Sets the mobile button layout that will be active when our control service is active
/// </summary>
/// TODO update this when we do our advanced mobile controls.
private void SetMobileButtonLayout(MobileButtonLayout mobileButtonLayout)
{
switch (mobileButtonLayout)
{
case MobileButtonLayout.Disabled:
mobileBasicButtonLayout.SetActive(false);
break;
case MobileButtonLayout.Basic:
mobileBasicButtonLayout.SetActive(true);
break;
}
}
/// <summary>
/// If we are on <see cref="DEFAULT_PC_CONTROL_SCHEME"/> show our cursor, otherwise hide it.
/// </summary>
/// <param name="controlScheme">The control scheme we just switched to</param>
private void OnControlSchemeChangedHideCursorHandler(string controlScheme)
{
switch (controlScheme)
{
case DEFAULT_PC_CONTROL_SCHEME:
Cursor.visible = true;
break;
default:
Cursor.visible = false;
break;
}
}
//TODO probably best to make this private as our controls service should manage our controls and not let anything else do so
public DefaultControls DefaultControls => defaultControls ??= new DefaultControls();
/// <summary>
/// Returns true if the left thrust control is considered activated after input processing.
/// <remarks>If <see cref="IsLeftThrustControlDisabled"/> is true false will always be returned!</remarks>
/// </summary>
[Title("Debugging")]
[ToggleLeft]
[LabelText("Left Pressed")]
[ShowInInspector,ReadOnly]
public bool IsLeftThrustControlActivated
{
get => isLeftThrustControlActivated && !IsLeftThrustControlDisabled;
private set
{
//when left thrust control is disabled don't set or fire any events on set
if (IsLeftThrustControlDisabled)
{
return;
}
//only fire event on first move to true
if (value && !isLeftThrustControlActivated)
{
OnLeftThrustControlActivatedChanged?.Invoke(true);
}
//only fire vent on first move to false
if (!value && isLeftThrustControlActivated)
{
OnLeftThrustControlActivatedChanged?.Invoke(false);
}
isLeftThrustControlActivated = value;
}
}
/// <summary>
/// Returns true if the right thrust control is considered activated after input processing
/// <remarks>If <see cref="IsRightThrustControlDisabled"/> is true false will always be returned!</remarks>
/// </summary>
[ToggleLeft]
[LabelText("Right Pressed")]
[ShowInInspector, ReadOnly]
public bool IsRightThrustControlActivated
{
get => isRightThrustControlActivated && !IsRightThrustControlDisabled;
private set
{
//when right thrust control is disabled don't set or fire any events on set
if (IsRightThrustControlDisabled)
{
return;
}
//only fire event on first move to true
if (value && !isRightThrustControlActivated)
{
OnRightThrustControlActivatedChanged?.Invoke(true);
}
//only fire event on first move to false
if (!value && isRightThrustControlActivated)
{
OnRightThrustControlActivatedChanged?.Invoke(false);
}
isRightThrustControlActivated = value;
}
}
/// <summary>
/// Returns true if the boost control is considered activated after input processing
/// </summary>
[LabelText("Both Pressed")]
[ToggleLeft]
[ShowInInspector, ReadOnly]
public bool IsBoostControlActivated
{
get => isBoostControlActivated;
private set {
//when boost control is disabled don't set or fire any events on set
//TODO technically this shouldn't be possible with boost because our boost input action is tied directly
//TODO to our boost control disabled state. However doing this to be safe just in case things change in the future
if (IsBoostControlDisabled)
{
return;
}
//only fire event on first move to true
if (value && !isBoostControlActivated)
{
OnBoostControlActivatedChanged?.Invoke(true);
}
//only fire event on first move to false
if (!value && isBoostControlActivated)
{
OnBoostControlActivatedChanged?.Invoke(false);
}
isBoostControlActivated = value;
}
}
/// <summary>
/// Returns true if the self destruct control is considered activated after input processing
/// </summary>
[LabelText("Self Destruct Pressed")]
[ToggleLeft]
[ShowInInspector, ReadOnly]
public bool IsSelfDestructControlActivated
{
get => isSelfDestructControlActivated;
private set {
//when boost control is disabled don't set or fire any events on set
//TODO technically this shouldn't be possible with self destruct because our boost input action is tied directly
//TODO to our self destruct control disabled state. However doing this to be safe just in case things change in the future
if (IsSelfDestructControlDisabled)
{
return;
}
//only fire event on first move to true
if (value && !isSelfDestructControlActivated)
{
OnSelfDestructControlActivatedChanged?.Invoke(true);
}
//only fire event on first move to false
if (!value && isSelfDestructControlActivated)
{
OnSelfDestructControlActivatedChanged?.Invoke(false);
}
isSelfDestructControlActivated = value;
}
}
/// <summary>
/// If true will disable <see cref="IsLeftThrustControlActivated"/>
/// </summary>
//TODO I wonder if it would make more sense to handle this type of thing in the input system itself (processor?)
[LabelText("Left Disabled")]
[ToggleLeft]
[ShowInInspector, ReadOnly]
[PropertyTooltip("If true will disable IsLeftThrustControlActivated")]
public bool IsLeftThrustControlDisabled
{
get => isLeftThrustControlDisabled;
private set
{
//TODO We may want to move this back to the ToggleLeftThrustControl() as this side effect might produce unwanted unexpected behavior
//if we are enabling the left thrust control and our horizontal thrust is off ensure we turn it on
if (!value && !DefaultControls.Missile.HorizontalThrust.enabled)
{
DefaultControls.Missile.HorizontalThrust.Enable();
}
//if we disable our left thrust control and it is currently activated ensure we deactivate it
if (value && IsLeftThrustControlActivated)
{
isLeftThrustControlActivated = false;
}
isLeftThrustControlDisabled = value;
}
}
/// <summary>
/// If true will disable <see cref="IsRightThrustControlActivated"/>
/// </summary>
//TODO I wonder if it would make more sense to handle this type of thing in the input system itself (processor?)
[LabelText("Right Disabled")]
[ToggleLeft]
[ShowInInspector, ReadOnly]
[PropertyTooltip("If true will disable IsRightThrustControlDisabled")]
public bool IsRightThrustControlDisabled
{
get => isRightThrustControlDisabled;
private set
{
//TODO We may want to move this back to the ToggleRightThrustControl() as this side effect might produce unwanted unexpected behavior
//if we are enabling the right thrust control and our horizontal thrust is off ensure we turn it on
if (!value && !DefaultControls.Missile.HorizontalThrust.enabled)
{
DefaultControls.Missile.HorizontalThrust.Enable();
}
//if we disable our right thrust control and it is currently activated ensure we deactivate it
if(value && IsRightThrustControlActivated)
{
isRightThrustControlActivated = false;
}
isRightThrustControlDisabled = value;
}
}
/// <summary>
/// If true will disable <see cref="IsBoostControlActivated"/>
/// </summary>
[LabelText("Boost Disabled")]
[ToggleLeft]
[ShowInInspector, ReadOnly]
[PropertyTooltip("If true will disable IsBoostControlDisabled")]
public bool IsBoostControlDisabled
{
get => isBoostControlDisabled;
private set
{
//TODO We may want to move this back to the ToggleBoostControl() as this side effect might produce unwanted unexpected behavior
//our field is tied directly to the input system action so match the input action enabled state to our field
if (!value)
{
DefaultControls.Missile.Boost.Enable();
}
else
{
DefaultControls.Missile.Boost.Disable();
}
//if we disable our boost control and it is currently activated ensure we deactivate it
if (value && isBoostControlActivated)
{
isBoostControlActivated = false;
}
isBoostControlDisabled = value;
}
}
/// <summary>
/// If true will disable <see cref="IsSelfDestructControlActivated"/>
/// </summary>
[LabelText("Self Destruct Disabled")]
[ToggleLeft]
[ShowInInspector, ReadOnly]
[PropertyTooltip("If true will disable IsSelfDestructControlDisabled")]
public bool IsSelfDestructControlDisabled
{
get => isSelfDestructControlDisabled;
private set
{
//TODO We may want to move this back to the ToggleSelfDestructControl() as this side effect might produce unwanted unexpected behavior
//our field is tied directly to the input system action so match the input action enabled state to our field
if (!value)
{
DefaultControls.Missile.SelfDestruct.Enable();
}
else
{
DefaultControls.Missile.SelfDestruct.Disable();
}
//if we disable our self destruct control and it is currently activated ensure we deactivate it
if (value && isSelfDestructControlActivated)
{
isSelfDestructControlActivated = false;
}
isSelfDestructControlDisabled = value;
}
}
/// <summary>
/// The control scheme that is currently being utilized by our control service and input system.
/// </summary>
[ValueDropdown("GetDefinedControlSchemes")]
[ShowInInspector]
[PropertyTooltip("The control scheme that is currently being utilized by our control service and input system.")]
public string ActiveControlScheme
{
get => DefaultControls.bindingMask.GetValueOrDefault().groups;
private set => DefaultControls.bindingMask = new InputBinding { groups = value };
}
/// <summary>
/// True if we have started an on screen controls displays session using <see cref="BeginOnScreenControlsDisplaySession"/>.
/// This means that our on-screen controls will be automatically toggled on and off depending on whether or not any of our
/// mobile controls schemes are active or not.
/// </summary>
public bool OnScreenControlsDisplaySessionIsActive { get; private set;}
/// <summary>
/// Handles the <see cref="InputSystem.onEvent"/> to ensure we update our control scheme and mobile buttons
/// based on the most recent device we are using.
/// </summary>
/// <example>
/// 1. Keyboard or mouse are moved or pressed, we should switch to our pc control scheme
/// 2. Gamepad sticks moved or buttons pressed, we should switch to our gamepad control scheme
/// 3. Touchscreen is touched, we should switch to our mobile control scheme and enable our mobile on-screen buttons.
/// </example>
/// <param name="inputEventPtr"></param>
/// <param name="inputDevice"></param>
private void OnInputSystemOnEventUpdateControlSchemeAndMobileButtonsHandler(InputEventPtr inputEventPtr, InputDevice inputDevice)
{
// Ignore anything that isn't a state event. These events signal that some input has changed
if (!inputEventPtr.IsA<StateEvent>() && !inputEventPtr.IsA<DeltaStateEvent>())
{
return;
}
//ignore input from on screen controls. See SetupInputControl() method in OnScreenControls.cs for more info
//on the usage we are filtering out here
if (inputDevice.usages.Any(x => x == "OnScreen"))
{
return;
}
//instead of finding our controls scheme manually with this method we should also be able to do it using
//var scheme = InputControlScheme.FindControlSchemeForDevices(currentInputDevice, inputActionMap.controlSchemes);
//inputActionAsset.bindingMask = new InputBinding { groups = scheme.bindingGroup };
//we would need to ensure that required devices + usages are set appropriately in our input action asset
var bindingMaskGroups = inputDevice switch
{
//note to return multiple control schemes for a binding mask you can do the following:
//${DEFAULT_PC_CONTROL_SCHEME};${DEFAULT_GAMEPAD_CONTROL_SCHEME} where the ; is the separator between multiple control schemes
Keyboard or Mouse => DEFAULT_PC_CONTROL_SCHEME,
Gamepad => DEFAULT_GAMEPAD_CONTROL_SCHEME,
Touchscreen => DEFAULT_MOBILE_CONTROL_SCHEME,
_ => null
};
//if we have no control scheme for this device then we can ignore it
if (bindingMaskGroups == null)
{
return;
}
//if our binding mask is already set to the same value then we can ignore it
if (bindingMaskGroups == DefaultControls.bindingMask?.groups)
{
return;
}
//TODO re-evaluate if this is the best place to do this.
//during an active on screen controls display session we need to ensure we auto toggle on-screen controls on
//when a touch control scheme is active and off when one is not.
if (OnScreenControlsDisplaySessionIsActive)
{
SetMobileButtonLayout(bindingMaskGroups == DEFAULT_MOBILE_CONTROL_SCHEME? MobileButtonLayout.Basic :MobileButtonLayout.Disabled);
}
DefaultControls.bindingMask = new InputBinding
{
groups = bindingMaskGroups
};
OnControlSchemeChanged?.Invoke(bindingMaskGroups);
}
/// <summary>
/// Defines the types of mobile button layouts available
/// </summary>
public enum MobileButtonLayout
{
/// <summary>
/// Disables mobile button layout
/// </summary>
Disabled,
/// <summary>
/// Two button layout that that DOES NOT allow for boosting while turning.
/// </summary>
Basic,
//TODO uncomment this section when we add our advanced layout!
/// <summary>
/// Three button layout that DOES allow for boosting while turning.
/// </summary>
//Advanced
}
[Serializable]
public class ControlsScreenSpaceRenderModeSettings
{
[SortingLayer]
public int sortingLayer;
public int sortingOrder;
public int planeDistance;
}
#region Editor Code
#if UNITY_EDITOR
[OnInspectorGUI]
private void Repaint()
{
Sirenix.Utilities.Editor.GUIHelper.RequestRepaint();
}
/// <summary>
/// Gets the control schemes we have defined in our <see cref="DefaultControls"/> input actions.
/// <remarks>Primarily used in <see cref="ActiveControlScheme"/> to allow changing control scheme in the inspector for easier debugging.</remarks>
/// </summary>
/// <returns>A list of the controls scheme names defined in our <see cref="DefaultControls"/></returns>
private ValueDropdownList<string> GetDefinedControlSchemes()
{
var result = new ValueDropdownList<string>();
foreach (var scheme in DefaultControls.controlSchemes)
{
result.Add(scheme.name);
}
return result;
}
#endif
#endregion
#region Serialization
[SerializeField, HideInInspector]
private SerializationData serializationData;
void ISerializationCallbackReceiver.OnAfterDeserialize()
{
UnitySerializationUtility.DeserializeUnityObject(this, ref this.serializationData);
}
void ISerializationCallbackReceiver.OnBeforeSerialize()
{
UnitySerializationUtility.SerializeUnityObject(this, ref this.serializationData);
}
public SerializationData SerializationData
{
get => serializationData;
set => serializationData = value;
}
#endregion
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment