I fotgot about this…
I wont paste the state machine here, as I posted the video that I based that, and the theme here is the transition.
Also, be aware that by the end of it, I had already proven my concept, but just to finish the game, I used a lot of boiler plate code. Ignore it.
First create the action maps with your actions, and flag it to generate the C# class.
After that, insted of using the Player Input component (GameObject components for input | Input System | 1.0.2 (unity3d.com)) I created my own Input Handler:
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerInputHandler : MonoBehaviour
{
private PlayerInput _playerInput;
private PlayerFSMHandler _playerFSMHandler;
public static event Action LaunchDebug;
public static event Action<bool> TryInteraction;
public static event Action MenuAccepted;
private void Awake()
{
_playerInput = new PlayerInput();
_playerFSMHandler = GetComponent<PlayerFSMHandler>();
}
private void OnEnable()
{
EnableAllGamePlayActionsMaps();
_playerInput.Grounded.Move.performed += TryingToMove;
_playerInput.Grounded.Move.canceled += TryingToMove;
_playerInput.Grounded.Jump.started += TryingToJump;
_playerInput.Grounded.Jump.canceled += TryingToJump;
_playerInput.Grounded.LockDirection.performed += TryingToLock;
_playerInput.Grounded.LockDirection.canceled += TryingToLock;
_playerInput.Grounded.Run.started += TryingToRun;
_playerInput.Grounded.Run.canceled += TryingToRun;
_playerInput.Grounded.Attack.started += TryingToAttack;
_playerInput.Grounded.SpinAttack.performed += TryingToSpinAttack;
_playerInput.Grounded.SpinAttack.canceled += TryingToAttack;
_playerInput.Grounded.Interact.performed += TryingToInteract;
_playerInput.Grounded.Interact.canceled += TryingToInteract;
_playerInput.Menu.Accept.started += AcceptMenu;
_playerInput.Debug.activate.started += TriggerDebug;
}
private void OnDisable()
{
_playerInput.Grounded.Move.Disable();
_playerInput.Grounded.Jump.Disable();
_playerInput.Grounded.LockDirection.Disable();
_playerInput.Grounded.Run.Disable();
_playerInput.Grounded.Attack.Disable();
_playerInput.Grounded.SpinAttack.Disable();
_playerInput.Grounded.Move.performed -= TryingToMove;
_playerInput.Grounded.Move.canceled -= TryingToMove;
_playerInput.Grounded.Jump.started -= TryingToJump;
_playerInput.Grounded.Jump.canceled -= TryingToJump;
_playerInput.Grounded.LockDirection.performed -= TryingToLock;
_playerInput.Grounded.LockDirection.canceled -= TryingToLock;
_playerInput.Grounded.Run.started -= TryingToRun;
_playerInput.Grounded.Run.canceled -= TryingToRun;
_playerInput.Grounded.Attack.started -= TryingToAttack;
_playerInput.Grounded.SpinAttack.performed -= TryingToSpinAttack;
_playerInput.Grounded.SpinAttack.canceled -= TryingToAttack;
_playerInput.Grounded.Interact.performed -= TryingToInteract;
_playerInput.Grounded.Interact.canceled -= TryingToInteract;
_playerInput.Menu.Accept.started -= AcceptMenu;
_playerInput.Debug.activate.started -= TriggerDebug;
}
private void TriggerDebug(InputAction.CallbackContext context)
{
LaunchDebug?.Invoke();
}
public void TryingToMove(InputAction.CallbackContext context)
{
_playerFSMHandler.tryingToMove = context.performed;
_playerFSMHandler.walkInput = context.ReadValue<Vector2>();
}
public void TryingToJump(InputAction.CallbackContext context)
{
_playerFSMHandler.tryingToJump = context.started;
}
public void TryingToLock(InputAction.CallbackContext context)
{
_playerFSMHandler.tryingToLock = context.performed;
}
public void TryingToRun(InputAction.CallbackContext context)
{
_playerFSMHandler.tryingToRun = context.started;
}
public void TryingToAttack(InputAction.CallbackContext context)
{
_playerFSMHandler.tryingToAttack = context.started;
}
public void TryingToSpinAttack(InputAction.CallbackContext context)
{
_playerFSMHandler.tryingToSpinAttack = context.performed;
}
public void TryingToInteract(InputAction.CallbackContext context)
{
TryInteraction?.Invoke(context.performed);
}
public void AcceptMenu(InputAction.CallbackContext context)
{
MenuAccepted?.Invoke();
}
public void EnableAllGamePlayActionsMaps()
{
_playerInput.Grounded.Move.Enable();
_playerInput.Grounded.Jump.Enable();
_playerInput.Grounded.LockDirection.Enable();
_playerInput.Grounded.Run.Enable();
_playerInput.Grounded.Attack.Enable();
_playerInput.Grounded.SpinAttack.Enable();
_playerInput.Grounded.Interact.Enable();
}
public void DisableAllGamePlayActionsMaps()
{
_playerInput.Grounded.Move.Disable();
_playerInput.Grounded.Jump.Disable();
_playerInput.Grounded.LockDirection.Disable();
_playerInput.Grounded.Run.Disable();
_playerInput.Grounded.Attack.Disable();
_playerInput.Grounded.SpinAttack.Disable();
_playerInput.Grounded.Interact.Disable();
}
private void EnableDebugActionMap()
{
_playerInput.Debug.activate.Enable();
}
private void DisableDebugActionMap()
{
_playerInput.Debug.activate.Disable();
}
public void EnableMenuActionMaps()
{
_playerInput.Menu.Enable();
}
public void DisableMenuActionMaps()
{
_playerInput.Menu.Disable();
}
}
It has a reference to the Action maps (using the generated C# class), and a reference to the FSM Handler.
It basically gets the input, and passes it to the FSM Handler, using the methods “TrySomething()”. For instance:
public void TryingToMove(InputAction.CallbackContext context)
{
_playerFSMHandler.tryingToMove = context.performed;
_playerFSMHandler.walkInput = context.ReadValue<Vector2>();
}
It just passes if the person is moving the thumbstick or nor (.performed phase) and its value as a Vector2.
The FSMManager is the one who decides if the character can jump or not, based on its current state.
So the input system is only a bridge between the phycical action of pressing a button, and the FSM Handler.
This is the manager I was using:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Animations;
using System;
public class PlayerFSMHandler : MonoBehaviour
{
public StateMachine _playerStateMachine { get; private set; }
private CharacterController _characterController;
private Animator _animator;
//Controller inputs
//Move
public bool tryingToMove;
public Vector2 walkInput;
//Jump and fall
public bool tryingToJump;
public float lastJumpEndTime;
public float lastLandingTime;
public bool jumpAnimEnded; //The transition from jump to fall needs this trigger because the jump is done with root motion, but the fall is via script
public AnimationCurve fallSpeed;
//Run
public bool tryingToRun;
public float lastRunningTime;
public bool runningEnded = true;
//Lock (shield)
public bool tryingToLock;
//Attack
public bool tryingToAttack;
public bool windowToAttack2;
public bool attack1AnimEnded = true;
public bool attack2AnimEnded = true;
public float lastAttackEndTime;
//Spin attack
public bool tryingToSpinAttack;
public bool spinAttackAnimEnded = true;
public float lastSpinAttackEndTime;
//LevelUp
public bool playVictory;
//GettingHit
public bool hitAnimEnded = true;
public bool playerHit;
//Jump down
private int jumpDownParam = Animator.StringToHash("flip");
//Camera relative vectors
public Vector3 cameraRight;
public Vector3 cameraForward;
//Character controller related to apply a little gravity 100% of the time, to stabilize isGrounded attribute. When root motion is not applied, the gravity should be applied whtin the state (ex.: Falling State)
private Vector3 gravity = Vector3.up * -1;
private float coyoteTime = 0.5f;
private float? coyoteTimeCounter;
//Player powerups and other controls
private bool SpinAttackUnlocked;
public bool RunningDashUnlocked { get; private set; }
private bool _isDead;
//Variables for getting root motion, ground motion and slope adjustment
Vector3 adjFrameVelocity;
RaycastHit rayHit;
Vector3 frameVelocity;
//Player gameplay colliders
[SerializeField] private Collider _attackCollider;
[SerializeField] private Collider _spinAttackCollider;
[SerializeField] private Collider _shieldCollider;
//Particles systems
[SerializeField] private ParticleSystem _deathEffect; //This will be triggered by an animation event on the death animation
[SerializeField] private ParticleSystem _shieldEffect;
[SerializeField] private ParticleSystem _slashEfect;
[SerializeField] private ParticleSystem _stepEffect;
//Audio Sources
[SerializeField] private AudioSource _stepsAudio;
[SerializeField] private AudioSource _powerUpAudio;
[SerializeField] private AudioSource _swingAudio;
//Events
public static event Action PlayerIsDead;
private void Awake()
{
//Get components
_characterController = GetComponent<CharacterController>();
_animator = GetComponent<Animator>();
//Instanciate state machine and states - NOTE: In this area, it is only possible to pass components in the contructors, not variables, because some are no initiated yet.
_playerStateMachine = new StateMachine(false, false);
var Walking = new Walking(this, _animator, _stepEffect, _stepsAudio);
var Jumping = new Jumping(this, _animator, _characterController);
var LockedWalking = new LockedWalking(this, _animator, _shieldCollider, _shieldEffect, _stepEffect, _stepsAudio);
var Falling = new Falling(this, _animator, _characterController);
var Running = new Running(this, _animator, _shieldEffect, _stepEffect, _stepsAudio);
var Attacking = new Attacking(this, _animator, _slashEfect, _swingAudio);
var SpinAttacking = new SpinAttacking(this, _animator, _characterController, _slashEfect, _swingAudio);
var Dead = new Dead(this, _animator, _characterController);
var Victory = new Victory(this, _animator, _powerUpAudio);
var GettingHit = new GettingHit(this, _animator);
//Create transitions (to-from and any-to)
_playerStateMachine.AddTransition(Walking, LockedWalking, IsGroundedAndLocked());
_playerStateMachine.AddTransition(LockedWalking, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(Walking, Jumping, JumpingReady());
_playerStateMachine.AddTransition(Jumping, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(Jumping, LockedWalking, IsGroundedAndLocked());
_playerStateMachine.AddTransition(Jumping, Falling, WasJumpingButEnded());
_playerStateMachine.AddTransition(Falling, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(Falling, LockedWalking, IsGroundedAndLocked());
_playerStateMachine.AddTransition(Walking, Falling, IsNotGroundedAndNotJumping());
_playerStateMachine.AddTransition(LockedWalking, Falling, IsNotGroundedAndNotJumping());
_playerStateMachine.AddTransition(Running, LockedWalking, IsGroundedAndLocked());
_playerStateMachine.AddTransition(Running, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(Running, Falling, IsNotGroundedAndNotJumping());
_playerStateMachine.AddTransition(LockedWalking, Running, RunningReady());
_playerStateMachine.AddTransition(Walking, Running, RunningReady());
_playerStateMachine.AddTransition(Jumping, Running, RunningReady());
_playerStateMachine.AddTransition(Falling, Running, RunningReady());
_playerStateMachine.AddTransition(LockedWalking, Attacking, AttackReady());
_playerStateMachine.AddTransition(Walking, Attacking, AttackReady());
_playerStateMachine.AddTransition(Falling, Attacking, AttackReady());
_playerStateMachine.AddTransition(Attacking, LockedWalking, IsGroundedAndLocked());
_playerStateMachine.AddTransition(Attacking, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(LockedWalking, SpinAttacking, SpinAttackReady());
_playerStateMachine.AddTransition(Walking, SpinAttacking, SpinAttackReady());
_playerStateMachine.AddTransition(SpinAttacking, LockedWalking, IsGroundedAndLocked());
_playerStateMachine.AddTransition(SpinAttacking, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(Victory, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(GettingHit, Walking, IsGroundedAndNotLocked());
_playerStateMachine.AddTransition(GettingHit, LockedWalking, IsGroundedAndLocked());
_playerStateMachine.AddAnyTransition(Dead, PlayerIsDead());
_playerStateMachine.AddAnyTransition(Victory, PlayVictory());
_playerStateMachine.AddAnyTransition(GettingHit, PlayerHit());
//Create context controls for transitions
Func<bool> IsGroundedAndNotLocked() => () => IsGroundedCoyote() && !tryingToLock && runningEnded && attack1AnimEnded && attack2AnimEnded && spinAttackAnimEnded && !playerHit && jumpAnimEnded;
Func<bool> IsGroundedAndLocked() => () => IsGroundedCoyote() && tryingToLock && runningEnded && attack1AnimEnded && attack2AnimEnded && spinAttackAnimEnded && !playerHit;
Func<bool> JumpingReady() => () => tryingToJump && (Time.realtimeSinceStartup - lastLandingTime > 0.15f); //(Time.realtimeSinceStartup - lastJumpEndTime > 0.15f)
Func<bool> IsNotGroundedAndNotJumping() => () => !IsGroundedCoyote();
Func<bool> WasJumpingButEnded() => () => jumpAnimEnded;
Func<bool> RunningReady() => () => (Time.realtimeSinceStartup - lastRunningTime > 2f) && tryingToRun;
Func<bool> AttackReady() => () => IsGroundedCoyote() && runningEnded && tryingToAttack
&& (Time.realtimeSinceStartup - lastAttackEndTime > 0.15f)
&& (Time.realtimeSinceStartup - lastJumpEndTime > 0.15f)
&& (Time.realtimeSinceStartup - lastLandingTime > 0.3f); //There is the need to apply the jumping/landing cooldown so the transition happens
//it would be good to have a general cooldown between states, or even to deal in a better way with the
Func<bool> SpinAttackReady() => () => IsGroundedCoyote() && runningEnded
&& (Time.realtimeSinceStartup - lastSpinAttackEndTime > 0.3f)
&& tryingToSpinAttack
&& (Time.realtimeSinceStartup - lastJumpEndTime > 0.15f)
&& (Time.realtimeSinceStartup - lastLandingTime > 0.15f)
&& SpinAttackUnlocked;
Func<bool> PlayerIsDead() => () => _isDead;
Func<bool> PlayVictory() => () => playVictory;
Func<bool> PlayerHit() => () => playerHit;
//Initialize state machine (needs to be done last, because is depends on all transitions
_playerStateMachine.InitializeStateMachine(Falling);
}
private void OnEnable()
{
GemManager.NoMoreGems += SetPlayVictorty;
DashRunSpeaker.DashRunUnlocked += UnlockDashRun;
DashRunSpeaker.DashRunUnlocked += SetPlayVictorty;
BonusHeartSpeaker.SpinAttackUnlocked += UnlockSpin;
BonusHeartSpeaker.SpinAttackUnlocked += SetPlayVictorty;
HealthManager.PlayerDied += SetPlayerDeath;
BonusHeartSpeaker.SpinAttackUnlocked += SetPlayVictorty;
JumpDownSpeaker.JumpDownlocked += SetJumpDown;
}
private void OnDisable()
{
GemManager.NoMoreGems -= SetPlayVictorty;
DashRunSpeaker.DashRunUnlocked -= UnlockDashRun;
DashRunSpeaker.DashRunUnlocked -= SetPlayVictorty;
BonusHeartSpeaker.SpinAttackUnlocked -= UnlockSpin;
BonusHeartSpeaker.SpinAttackUnlocked -= SetPlayVictorty;
HealthManager.PlayerDied -= SetPlayerDeath;
BonusHeartSpeaker.SpinAttackUnlocked -= SetPlayVictorty;
JumpDownSpeaker.JumpDownlocked -= SetJumpDown;
}
private void Start()
{
hitAnimEnded = true;
lastSpinAttackEndTime = 0;
lastJumpEndTime = 0;
lastLandingTime = 0;
lastAttackEndTime = 0;
jumpAnimEnded = true;
}
public void PlayerGotHit()
{
playerHit = true;
}
void Update()
{
_playerStateMachine.Tick();
}
private void OnAnimatorMove()
{
//Now the root motion is managed by this method, and not the Animator component in 100% of the time, even when root motion is applied
frameVelocity = gravity * Time.deltaTime + _animator.deltaPosition; //This sums the gravity to the root motion of the current clip being played, regarthless of the current state.
if (Physics.Raycast(transform.position, -transform.up, out rayHit, 0.3f) && IsGroundedCoyote())
{
_characterController.Move(AdjustFrameMovementOnSlope(frameVelocity)); //This method treats the movement direction in downwards slopes
}
else
{
_characterController.Move(frameVelocity);
}
//transform.Rotate(_animator.deltaRotation.eulerAngles); //The rotation is not needed now because no animation has root motion rotation
}
private Vector3 AdjustFrameMovementOnSlope(Vector3 frameVelocity) //Can be optimized with cached variables
{
if (IsGroundedCoyote())
{
var slopeRotation = Quaternion.FromToRotation(transform.up, rayHit.normal);
adjFrameVelocity = slopeRotation * frameVelocity;
if (adjFrameVelocity.y < 0 && Mathf.Abs(_animator.deltaPosition.magnitude) > 0.01f)
{
return adjFrameVelocity;
}
}
return frameVelocity;
}
private void UnlockSpin()
{
SpinAttackUnlocked = true;
}
private void UnlockDashRun()
{
RunningDashUnlocked = true;
}
private bool IsGroundedCoyote()
{
if (_characterController.isGrounded)
{
coyoteTimeCounter = coyoteTime;
return true;
}
else
{
coyoteTimeCounter -= Time.fixedDeltaTime;
return coyoteTimeCounter > 0;
}
}
private void SetPlayerDeath()
{
_isDead = true;
PlayerIsDead?.Invoke();
}
#region "Methods to be called by the animator"
public void SetJumpAnimEnded()
{
jumpAnimEnded = true;
}
public void SetWindowToAttack2(int setTo)
{
windowToAttack2 = Convert.ToBoolean(setTo);
}
public void SetAttack1AnimEnded()
{
attack1AnimEnded = true;
}
public void SetAttack2AnimEnded()
{
attack2AnimEnded = true;
}
public void SetAttack4AnimEnded()
{
spinAttackAnimEnded = true;
}
public void SetAttackCollider(int setTo)
{
_attackCollider.enabled = Convert.ToBoolean(setTo);
}
public void SetSpinAttackCollider(int setTo)
{
_spinAttackCollider.enabled = Convert.ToBoolean(setTo);
}
public void SetStopVictorty()
{
playVictory = false;
}
public void SetPlayVictorty()
{
playVictory = true;
}
public void SetHitAnimEnded()
{
hitAnimEnded = true;
playerHit = false;
}
public void SetJumpDown()
{
_animator.SetBool(jumpDownParam, true);
}
public void SetDieAndGameOver()
{
Instantiate(_deathEffect, transform.position, Quaternion.identity);
var Renderers = GetComponentsInChildren<Renderer>();
foreach (var renderer in Renderers)
{
renderer.enabled = false;
}
//Destroy(gameObject, 0.5f); //It is better to turn the render off, due to the post processing trigger
//TO DO: chamar o scene manager para voltar para o menu principal
}
#endregion "Methods to be called by the animator"
}
Skiping the FSM implementation (watch the video I posted early, he explains 1000x better I could), this class has several Delegates that are a sum of bool values. They are evaluated to set the FSM state, respecting the existing transitions.
Func<bool> IsGroundedAndNotLocked() => () => IsGroundedCoyote() && !tryingToLock && runningEnded && attack1AnimEnded && attack2AnimEnded && spinAttackAnimEnded && !playerHit && jumpAnimEnded;
Func<bool> IsGroundedAndLocked() => () => IsGroundedCoyote() && tryingToLock && runningEnded && attack1AnimEnded && attack2AnimEnded && spinAttackAnimEnded && !playerHit;
Func<bool> JumpingReady() => () => tryingToJump && (Time.realtimeSinceStartup - lastLandingTime > 0.15f); //(Time.realtimeSinceStartup - lastJumpEndTime > 0.15f)
Func<bool> IsNotGroundedAndNotJumping() => () => !IsGroundedCoyote();
Func<bool> WasJumpingButEnded() => () => jumpAnimEnded;
Func<bool> RunningReady() => () => (Time.realtimeSinceStartup - lastRunningTime > 2f) && tryingToRun;
Func<bool> AttackReady() => () => IsGroundedCoyote() && runningEnded && tryingToAttack
&& (Time.realtimeSinceStartup - lastAttackEndTime > 0.15f)
&& (Time.realtimeSinceStartup - lastJumpEndTime > 0.15f)
&& (Time.realtimeSinceStartup - lastLandingTime > 0.3f); //There is the need to apply the jumping/landing cooldown so the transition happens
//it would be good to have a general cooldown between states, or even to deal in a better way with the
Func<bool> SpinAttackReady() => () => IsGroundedCoyote() && runningEnded
&& (Time.realtimeSinceStartup - lastSpinAttackEndTime > 0.3f)
&& tryingToSpinAttack
&& (Time.realtimeSinceStartup - lastJumpEndTime > 0.15f)
&& (Time.realtimeSinceStartup - lastLandingTime > 0.15f)
&& SpinAttackUnlocked;
Func<bool> PlayerIsDead() => () => _isDead;
Func<bool> PlayVictory() => () => playVictory;
Func<bool> PlayerHit() => () => playerHit;
So, how does the states receive the inputs? The FSM Handler has variables (that could be properties with a better protection level, but… meh) and each state has a reference to the state machine, and gets the information it needs.
This is the walking state:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public class Walking : IState
{
private readonly PlayerFSMHandler _playerFSMHandler;
private Animator _animator;
private ParticleSystem _stepEffect;
private AudioSource _stepsAudio;
//Setup of player input variables
private Vector3 _characterForwardGoal;
//Setup of animation and audio clips
private int animId = Animator.StringToHash("walking");
private int animFWDInput = Animator.StringToHash("forwardInput");
public Walking(PlayerFSMHandler playerFSMHandler, Animator animator, ParticleSystem stepEffect, AudioSource stepsAudio)
{
_playerFSMHandler = playerFSMHandler;
_animator = animator;
_stepEffect = stepEffect;
_stepsAudio = stepsAudio;
}
public void OnEnter()
{
_animator.SetBool(animId, true);
}
public void OnExit()
{
_animator.ResetBoolParam();
_stepEffect.Stop();
_stepsAudio.Stop();
}
public void Tick()
{
LookAtInputRelativeToCamera();
AnimBlendControl();
StepEffectControl(_animator.velocity.magnitude);
}
private void LookAtInputRelativeToCamera()
{
if (_playerFSMHandler.walkInput != Vector2.zero)
{
_characterForwardGoal = (_playerFSMHandler.cameraRight * _playerFSMHandler.walkInput.x) + (_playerFSMHandler.cameraForward * _playerFSMHandler.walkInput.y);
_playerFSMHandler.transform.rotation = Quaternion.RotateTowards(_playerFSMHandler.transform.rotation, Quaternion.LookRotation(_characterForwardGoal), 10f);
}
}
private void AnimBlendControl()
{
_animator.SetFloat(animFWDInput, _playerFSMHandler.walkInput.magnitude);
}
private void StepEffectControl(float playerVel)
{
if (playerVel > 0 && !_stepEffect.isPlaying)
{
_stepEffect.Play();
_stepsAudio.Play();
}
if(playerVel <= 0 && _stepEffect.isPlaying)
{
_stepEffect.Stop();
_stepsAudio.Stop();
}
}
}
In the end,d it is the state that sets the animation to be played (and blend trees, layers, etc), the audio to be played, the camera behaviour, the movement, and whatever it is needed in that state. In this walking example i was using root motion, but in the jump state I used physics.
To sum up:
- Action map generates c# class
- Custom Input Handler gets the input and passes to FSM Handler without any treatment
- FSM Handler uses (or not) the input received based on it current state
- FSM Handler decides which state to be based on context of the scene.
There is a lot of room for improvement, but for a first time implementation of this two things together, I think it was ok.
If you want, I can create a repo on github with all the scripts. Not the whole project though, it is too big.