Files
2026-03-03 05:27:03 +05:00

654 lines
23 KiB
C#

using System;
using System.Linq;
using System.Reactive.Subjects;
using System.Collections.Generic;
using UnityEngine;
using UHFPS.Input;
using UHFPS.Tools;
using UHFPS.Scriptable;
using ThunderWire.Attributes;
namespace UHFPS.Runtime
{
[RequireComponent(typeof(CharacterController))]
[Docs("https://docs.twgamesdev.com/uhfps/guides/state-machines/adding-player-states")]
public class PlayerStateMachine : PlayerComponent
{
#region Getters / Setters
private CharacterController m_controller;
public CharacterController Controller
{
get
{
if (m_controller == null)
m_controller = GetComponent<CharacterController>();
return m_controller;
}
}
public Vector3 FeetOffset
{
get
{
float height = Controller.height;
float skinWidth = Controller.skinWidth;
float center = height / 2;
return ControllerOffset switch
{
PositionOffset.Ground => new Vector3(0, skinWidth, 0),
PositionOffset.Feet => new Vector3(0, 0, 0),
PositionOffset.Center => new Vector3(0, -center, 0),
PositionOffset.Head => new Vector3(0, -center * 2, 0),
_ => Controller.center
};
}
}
public Vector3 ControllerFeet
{
get
{
Vector3 position = transform.position;
return position + FeetOffset;
}
}
public Vector3 ControllerCenter
{
get
{
Vector3 position = transform.position;
return position += Controller.center;
}
}
public State CurrentState => currentState;
public State PreviousState => previousState;
public string CurrentStateKey => CurrentState?.stateData.stateAsset.StateKey;
#endregion
#region Structures
public const string PREVIOUS_STATE = "_Previous";
public const string IDLE_STATE = "Idle";
public const string WALK_STATE = "Walk";
public const string RUN_STATE = "Run";
public const string CROUCH_STATE = "Crouch";
public const string JUMP_STATE = "Jump";
public const string LADDER_STATE = "Ladder";
public const string ZIPLINE_STATE = "Zipline";
public const string SLIDING_STATE = "Sliding";
public const string PUSHING_STATE = "Pushing";
public const string DEATH_STATE = "Death";
[Serializable]
public sealed class BasicSettings
{
public float WalkSpeed = 3;
public float RunSpeed = 7;
public float CrouchSpeed = 2;
public float JumpHeight = 1;
}
[Serializable]
public sealed class ControllerFeatures
{
public bool EnableStamina = false;
public bool RunToggle = false;
public bool CrouchToggle = false;
public bool NormalizeMovement = false;
}
[Serializable]
public sealed class SlidingSettings
{
public LayerMask SlidingMask;
public float SlideRayLength = 1f;
public float SlopeLimit = 45f;
}
[Serializable]
public sealed class StaminaSettings
{
public float JumpExhaustion = 1f;
public float RunExhaustionSpeed = 1f;
public float StaminaRegenSpeed = 1f;
public float RegenerateAfter = 2f;
}
[Serializable]
public sealed class ControllerSettings
{
public float BaseGravity = -9.81f;
public float PlayerWeight = 70f;
public float SkinWidthOffset = 0.05f;
public float FeetRadius = 0.1f;
public float AntiBumpFactor = 4.5f;
public float WallRicochet = 0.1f;
public float StateChangeSmooth = 1.35f;
}
[Serializable]
public sealed class ControllerState
{
public float ControllerHeight;
public Vector3 CameraOffset;
}
public sealed class State
{
public PlayerStateData stateData;
public FSMPlayerState fsmState;
}
#endregion
public enum PositionOffset { Ground, Feet, Center, Head }
public PlayerStatesGroup StatesAsset;
public PlayerStatesGroup StatesAssetRuntime;
public LayerMask SurfaceMask;
public PositionOffset ControllerOffset;
public BasicSettings PlayerBasicSettings;
public ControllerFeatures PlayerFeatures;
public SlidingSettings PlayerSliding;
public StaminaSettings PlayerStamina;
public ControllerSettings PlayerControllerSettings;
public ControllerState StandingState;
public ControllerState CrouchingState;
public bool DrawPlayerGizmos = true;
public bool DrawPlayerWireframe = true;
public float ScaleOffset = 0f;
public Color GizmosColor = Color.white;
public Vector2 Input;
public Vector3 Motion;
public ControllerColliderHit ControllerHit { get; private set; }
public BehaviorSubject<float> Stamina { get; set; } = new(1f);
public bool IsGrounded { get; private set; }
public bool IsPlayerDead { get; private set; }
/// <summary>
/// Check that the player is not airborne by checking the ground status and the current status of the player.
/// </summary>
public bool StateGrounded
{
get => IsGrounded
|| IsCurrent(SLIDING_STATE)
|| IsCurrent(LADDER_STATE)
|| IsCurrent(PUSHING_STATE);
}
/// <summary>
/// The name of the current active state.
/// </summary>
public string StateName
{
get
{
if (currentState != null)
return currentState?.stateData.stateAsset.StateKey;
return "None";
}
}
/// <summary>
/// The name of the current active state as observable.
/// </summary>
public BehaviorSubject<string> ObservableState = new("None");
private readonly List<ICharacterControllerHit> currentSurfaces = new();
private MultiKeyDictionary<string, Type, State> playerStates;
private State currentState;
private State previousState;
private bool stateEntered;
private float staminaRegenTime;
private Vector3 externalForce;
private Mesh playerGizmos;
private void Awake()
{
playerStates = new MultiKeyDictionary<string, Type, State>();
StatesAssetRuntime = Instantiate(StatesAsset);
if (StatesAsset != null)
{
// initialize all states
foreach (var playerState in StatesAssetRuntime.GetStates(this))
{
Type stateType = playerState.stateData.stateAsset.GetType();
string stateKey = playerState.stateData.stateAsset.StateKey;
playerStates.Add(stateKey, stateType, playerState);
}
// select initial player state
if (playerStates.Count > 0)
{
stateEntered = false;
ChangeState(playerStates.subDictionary.Keys.First());
}
}
}
private void Update()
{
if (isEnabled) GetInput();
else Input = Vector2.zero;
// player death event
if (currentState != null && !IsPlayerDead && PlayerManager.PlayerHealth.IsDead)
{
currentState?.fsmState.OnPlayerDeath();
IsPlayerDead = true;
}
if (!stateEntered)
{
// enter state
currentState?.fsmState.OnStateEnter();
string stateName = currentState?.stateData.stateAsset.StateKey;
ObservableState.OnNext(stateName);
stateEntered = true;
}
else if (currentState != null)
{
// update state
currentState?.fsmState.OnStateUpdate();
// check state transitions
if (currentState.fsmState.Transitions != null)
{
foreach (var transition in currentState.fsmState.Transitions)
{
string nextStateKey = transition.NextStateKey;
if (playerStates.TryGetValue(nextStateKey, out State state))
{
if (state.stateData.isEnabled && StateName != nextStateKey && transition.Value)
{
if (playerStates.ContainsKey(nextStateKey))
{
if (nextStateKey == PREVIOUS_STATE)
ChangeToPreviousState();
else ChangeState(state);
break;
}
}
}
}
}
}
// regenerate player stamina
if (PlayerFeatures.EnableStamina)
{
bool runHold = InputManager.ReadButton(Controls.SPRINT);
if (IsCurrent(RUN_STATE) || IsCurrent(JUMP_STATE) || runHold)
{
staminaRegenTime = PlayerStamina.RegenerateAfter;
}
else if(staminaRegenTime > 0f)
{
staminaRegenTime -= Time.deltaTime;
}
else if(Stamina.Value < 1f)
{
float stamina = Stamina.Value;
stamina = Mathf.MoveTowards(stamina, 1f, Time.deltaTime * PlayerStamina.StaminaRegenSpeed);
Stamina.OnNext(stamina);
staminaRegenTime = 0f;
}
}
float feetRadius = PlayerControllerSettings.FeetRadius;
float maxDistance = Controller.skinWidth + PlayerControllerSettings.SkinWidthOffset + feetRadius;
Vector3 rayOrigin = ControllerFeet + Vector3.up * (feetRadius + Controller.skinWidth);
Ray groundRay = new(rayOrigin, Vector3.down);
// raycast for character controller enter/exit event
if (Physics.SphereCast(groundRay, feetRadius, out RaycastHit hit, maxDistance, SurfaceMask, QueryTriggerInteraction.Collide))
{
if (hit.collider.gameObject.TryGetComponent(out ICharacterControllerHit newSurface))
{
if (!currentSurfaces.Contains(newSurface))
{
newSurface.OnCharacterControllerEnter(Controller);
currentSurfaces.ForEach(x => x.OnCharacterControllerExit());
currentSurfaces.Clear();
currentSurfaces.Add(newSurface);
}
}
else
{
currentSurfaces.ForEach(x => x.OnCharacterControllerExit());
currentSurfaces.Clear();
}
}
else
{
currentSurfaces.ForEach(x => x.OnCharacterControllerExit());
currentSurfaces.Clear();
}
// apply external force
if (externalForce != Vector3.zero)
{
Motion += externalForce;
externalForce = Vector3.zero;
}
// apply movement direction
if (Controller.enabled) IsGrounded = (Controller.Move(Motion * Time.deltaTime) & CollisionFlags.Below) != 0;
}
private void FixedUpdate()
{
if (currentState != null)
{
// update state
currentState?.fsmState.OnStateFixedUpdate();
}
}
private void OnControllerColliderHit(ControllerColliderHit hit)
{
if (!IsGrounded)
{
Vector3 normal = hit.normal;
if (normal.y > 0) return;
Vector3 ricochet = Vector3.Reflect(Motion, normal);
ricochet.y = Motion.y;
float ricochetDot = Mathf.Clamp01(Vector3.Dot(ricochet.normalized, Motion.normalized));
float wallDot = Mathf.Clamp01(Vector3.Dot(Motion.normalized, -normal));
float ricochetMul = Mathf.Lerp(1f, PlayerControllerSettings.WallRicochet, wallDot);
ricochet *= ricochetMul;
Vector3 newMotion = Vector3.Lerp(ricochet, Motion, ricochetDot);
newMotion.y = Motion.y;
Motion = newMotion;
}
ControllerHit = hit;
}
/// <summary>
/// Calculate movement input vector.
/// </summary>
private void GetInput()
{
Input = Vector2.zero;
if (InputManager.ReadInput(Controls.MOVEMENT, out Vector2 _rawInput))
{
if (PlayerFeatures.NormalizeMovement)
{
_rawInput.y = _rawInput.y > 0.1f ? 1 : _rawInput.y < -0.1f ? -1 : 0;
_rawInput.x = _rawInput.x > 0.1f ? 1 : _rawInput.x < -0.1f ? -1 : 0;
}
Input = _rawInput;
}
}
/// <summary>
/// Set state enabled value. (Can transition to the state condition.)
/// </summary>
public void SetStateEnabled(string stateKey, bool enabled)
{
if (playerStates.TryGetValue(stateKey, out State state))
{
state.stateData.isEnabled = enabled;
}
}
/// <summary>
/// Add external force to the player motion.
/// </summary>
public void AddForce(Vector3 force, ForceMode mode)
{
float mass = PlayerControllerSettings.PlayerWeight;
externalForce += mode switch
{
ForceMode.Force => force * (1f / mass),
ForceMode.Acceleration => force * Time.deltaTime,
ForceMode.Impulse => force * (1f / mass),
ForceMode.VelocityChange => force,
_ => throw new ArgumentException(nameof(mode)),
};
}
/// <summary>
/// Set player controller state.
/// </summary>
public Vector3 SetControllerState(ControllerState state)
{
float height = state.ControllerHeight;
float skinWidth = Controller.skinWidth;
float center = height / 2;
Vector3 controllerCenter = ControllerOffset switch
{
PositionOffset.Ground => new Vector3(0, center + skinWidth, 0),
PositionOffset.Feet => new Vector3(0, center, 0),
PositionOffset.Center => new Vector3(0, 0, 0),
PositionOffset.Head => new Vector3(0, -center, 0),
_ => Controller.center
};
Controller.height = height;
Controller.center = controllerCenter;
Vector3 cameraTop = state.CameraOffset;
cameraTop.y += center + controllerCenter.y;
return cameraTop;
}
/// <summary>
/// Change player FSM state.
/// </summary>
public void ChangeState<TState>() where TState : PlayerStateAsset
{
if (playerStates.TryGetValue(typeof(TState), out State state))
{
if (!isEnabled && !state.fsmState.CanTransitionWhenDisabled)
return;
if ((currentState == null || !currentState.Equals(state)) && state.stateData.isEnabled)
{
currentState?.fsmState.OnStateExit();
if (currentState != null) previousState = currentState;
currentState = state;
stateEntered = false;
}
return;
}
throw new MissingReferenceException($"Could not find a state with type '{typeof(TState).Name}'");
}
/// <summary>
/// Change player FSM state.
/// </summary>
public void ChangeState(Type nextState)
{
if (playerStates.TryGetValue(nextState, out State state))
{
if (!isEnabled && !state.fsmState.CanTransitionWhenDisabled)
return;
if ((currentState == null || !currentState.Equals(state)) && state.stateData.isEnabled)
{
currentState?.fsmState.OnStateExit();
if (currentState != null) previousState = currentState;
currentState = state;
stateEntered = false;
}
return;
}
throw new MissingReferenceException($"Could not find a state with type '{nextState.Name}'");
}
/// <summary>
/// Change player FSM state.
/// </summary>
public void ChangeState(State state)
{
if (!isEnabled && !state.fsmState.CanTransitionWhenDisabled)
return;
if ((currentState == null || !currentState.Equals(state)))
{
currentState?.fsmState.OnStateExit();
if (currentState != null) previousState = currentState;
currentState = state;
stateEntered = false;
}
}
/// <summary>
/// Change player FSM state.
/// </summary>
public void ChangeState(string nextState)
{
if (playerStates.TryGetValue(nextState, out State state))
{
if (!isEnabled && !state.fsmState.CanTransitionWhenDisabled)
return;
if ((currentState == null || !currentState.Equals(state)) && state.stateData.isEnabled)
{
currentState?.fsmState.OnStateExit();
if (currentState != null) previousState = currentState;
currentState = state;
stateEntered = false;
}
return;
}
throw new MissingReferenceException($"Could not find a state with key '{nextState}'");
}
/// <summary>
/// Change player FSM state and set the state data.
/// </summary>
public void ChangeState(string nextState, StorableCollection stateData)
{
if (playerStates.TryGetValue(nextState, out State state))
{
if (!isEnabled && !state.fsmState.CanTransitionWhenDisabled)
return;
if ((currentState == null || !currentState.Equals(state)) && state.stateData.isEnabled)
{
currentState?.fsmState.OnStateExit();
if (currentState != null) previousState = currentState;
state.fsmState.StateData = stateData;
currentState = state;
stateEntered = false;
}
return;
}
throw new MissingReferenceException($"Could not find a state with key '{nextState}'");
}
/// <summary>
/// Change player FSM state to previous state.
/// </summary>
public void ChangeToPreviousState()
{
if (previousState != null && !currentState.Equals(previousState) && previousState.stateData.isEnabled)
{
if (!isEnabled && !previousState.fsmState.CanTransitionWhenDisabled)
return;
currentState?.fsmState.OnStateExit();
State temp = currentState;
currentState = previousState;
previousState = temp;
stateEntered = false;
}
}
/// <summary>
/// Check if current state is of the specified type.
/// </summary>
public bool IsCurrent(Type stateType)
{
return currentState?.stateData.stateAsset.GetType() == stateType;
}
/// <summary>
/// Check if current state matches the specified state key.
/// </summary>
public bool IsCurrent(string stateKey)
{
return currentState?.stateData.stateAsset.StateKey == stateKey;
}
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.red;
Gizmos.DrawSphere(ControllerFeet, 0.02f);
Gizmos.color = Color.cyan;
Gizmos.DrawSphere(ControllerCenter, 0.05f);
if (currentState != null)
currentState?.fsmState.OnDrawGizmos();
if (PlayerManager.MainVirtualCamera != null)
{
Vector3 camForward = PlayerManager.MainVirtualCamera.transform.forward;
Vector3 lookForward = LookController.RotationX * Vector3.forward;
Vector3 lookRotation = Application.isPlaying ? lookForward : camForward;
Gizmos.color = Color.red;
GizmosE.DrawGizmosArrow(ControllerFeet, lookRotation * 0.5f);
}
Gizmos.color = Color.white;
Gizmos.DrawRay(ControllerFeet, Vector3.down * (Controller.skinWidth + PlayerControllerSettings.SkinWidthOffset));
}
private void OnDrawGizmos()
{
if (DrawPlayerGizmos)
{
if (playerGizmos == null)
{
playerGizmos = Resources.Load<Mesh>("Gizmos/Player");
}
else
{
float height = Controller.height;
Vector3 scale = (0.73f + ScaleOffset) * height * Vector3.one;
Quaternion lookRotation = Application.isPlaying ? LookController.RotationX : transform.rotation;
Quaternion rotation = lookRotation * Quaternion.Euler(-90, 0, 0);
Gizmos.color = GizmosColor.Alpha(0.1f);
if(DrawPlayerWireframe) Gizmos.DrawWireMesh(playerGizmos, transform.position, rotation, scale);
else Gizmos.DrawMesh(playerGizmos, transform.position, rotation, scale);
}
}
}
}
}