projectEli/Assets/NeoFPS/Core/MotionGraphs/MotionController.cs
2022-11-06 20:28:33 -05:00

898 lines
31 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using NeoCC;
using NeoFPS.CharacterMotion.MotionData;
using NeoSaveGames.Serialization;
using NeoSaveGames;
namespace NeoFPS.CharacterMotion
{
//[RequireComponent(typeof(NeoCharacterController))]
[HelpURL("https://docs.neofps.com/manual/motiongraphref-mb-motioncontroller.html")]
public class MotionController : MonoBehaviour, IMotionController, ICharacterStepTracker, INeoSerializableComponent
{
[Header("Motion Graph & Data")]
[SerializeField, Tooltip("The motion graph for the controller to use (a unique instance will be instantiated from this).")]
private MotionGraphContainer m_MotionGraph = null;
[SerializeField, Tooltip("The motion data for the controller to use (a unique instance will be instantiated from this).")]
private MotionGraphDataOverrideAsset m_DataOverrides = null;
[Header("Colliders")]
[SerializeField, Tooltip("If this is enabled, then the collider will provide an offset that can be used to provide extra height to a jump so it appears the legs are tucked up instead of the head ducked down.")]
private bool m_UseCrouchJump = true;
[Header("Misc")]
[SerializeField, Tooltip("Should the component be initialised manually or automatically in Awake and Start? Switch this on for things like networked players.")]
private bool m_ManualInitialisation = false;
[SerializeField, NeoObjectInHierarchyField(false, required = true), Tooltip("The root transform of the head heirarchy (height interpolated when crouching).")]
private Transform m_UpperBodyRoot = null;
private List<MotionGraph> m_ParentChain = new List<MotionGraph>(8);
private List<MotionGraphConnectable> m_CyclicCheck = new List<MotionGraphConnectable>(8);
private bool m_CyclicError = false;
private Vector3 m_FrameMove = Vector3.zero;
private Vector3 m_PreviousVelocity = Vector3.zero;
private NeoCharacterCollisionFlags m_PreviousCollisionFlags = NeoCharacterCollisionFlags.None;
private MotionGraphContainer m_GraphInstance = null;
public MotionGraphContainer motionGraph
{
get
{
if (m_GraphInstance != null)
return m_GraphInstance;
else
return m_MotionGraph;
}
}
private MotionGraphState m_CurrentState = null;
public MotionGraphState currentState
{
get { return m_CurrentState; }
private set
{
if (value != null && m_CurrentState != value)
{
// Get shared parent between old and new state
MotionGraph commonParent = MotionGraphConnectable.GetSharedParent(m_CurrentState, value);
// Exit old state and parents up to shared
MotionGraphConnectable connectable = m_CurrentState;
while (connectable != commonParent)
{
connectable.OnExit();
connectable = connectable.parent;
}
// Set the new state
m_CurrentState = value;
// Enter new state and parents up to shared
connectable = m_CurrentState;
while (connectable != commonParent)
{
connectable.OnEnter();
connectable = connectable.parent;
}
// Fire event
m_OnMotionGraphStateChange.Invoke();
}
}
}
public event UnityAction onCurrentStateChanged
{
add { m_OnMotionGraphStateChange.AddListener(value); }
remove { m_OnMotionGraphStateChange.RemoveListener(value); }
}
public bool isLocal
{
get;
private set;
}
public Transform localTransform
{
get;
private set;
}
public INeoCharacterController characterController
{
get;
private set;
}
public IAimController aimController
{
get;
private set;
}
public Vector2 inputMoveDirection
{
get;
set;
}
public float inputMoveScale
{
get;
set;
}
#region GROUNDING & CONTACTS
public float groundSlopeAngle
{
get;
private set;
}
public Vector3 groundDownSlopeVector
{
get;
private set;
}
public Vector3 groundAcrossSlopeVector
{
get;
private set;
}
void CalculateSlopeEffect(bool grounded)
{
// Skip if airborne
if (!grounded)
{
groundSlopeAngle = 0f;
groundDownSlopeVector = Vector3.zero;
groundAcrossSlopeVector = Vector3.zero;
}
else
{
// Angle from vertical
groundSlopeAngle = Mathf.Acos(characterController.groundNormal.y) * Mathf.Rad2Deg;
// Get the down-slope direction vector
if (groundSlopeAngle > 1f)
{
groundDownSlopeVector = Vector3.ProjectOnPlane(Vector3.down, characterController.groundNormal).normalized;
groundAcrossSlopeVector = Vector3.Cross(characterController.groundNormal, groundDownSlopeVector);
}
else
{
groundDownSlopeVector = Vector3.zero;
groundAcrossSlopeVector = Vector3.zero;
}
}
}
#endregion
#region FORCES
[Header("Force Events")]
[SerializeField, Tooltip("This event is called whenever the controller graph state changes (does not include states that are immediately transitioned out of).")]
private UnityEvent m_OnMotionGraphStateChange = new UnityEvent();
[SerializeField, Tooltip("This event is called when the controller first contacts the ground after being airborne (parameters = impulse(Vector3), mass(float))")]
private ImpulseEvent m_OnGroundImpact = new ImpulseEvent();
[SerializeField, Tooltip("This event is called whenever the top of the controller capsule makes initial contact with a collider (parameters = impulse(Vector3), mass(float))")]
private ImpulseEvent m_OnHeadImpact = new ImpulseEvent();
[SerializeField, Tooltip("This event is called whenever the sides of the controller capsule makes initial contact with a collider (parameters = impulse(Vector3), mass(float))")]
private ImpulseEvent m_OnBodyImpact = new ImpulseEvent();
[Serializable]
public class ImpulseEvent : UnityEvent<Vector3> { }
public event UnityAction<Vector3> onGroundImpact
{
add { m_OnGroundImpact.AddListener(value); }
remove { m_OnGroundImpact.RemoveListener(value); }
}
public event UnityAction<Vector3> onHeadImpact
{
add { m_OnHeadImpact.AddListener(value); }
remove { m_OnHeadImpact.RemoveListener(value); }
}
public event UnityAction<Vector3> onBodyImpact
{
add { m_OnBodyImpact.AddListener(value); }
remove { m_OnBodyImpact.RemoveListener(value); }
}
#endregion
#region UPDATE
Vector3 m_HeadHitNormal = Vector3.zero;
Vector3 m_BodyHitNormal = Vector3.zero;
float m_HeadHitAvgSum = 0f;
int m_HeadHitCount = 0;
float m_BodyHitAvgSum = 0f;
int m_BodyHitCount = 0;
void UpdateConnectable(MotionGraphConnectable connectable)
{
if (connectable.parent != null)
UpdateConnectable(connectable.parent);
connectable.Update();
}
void GetMoveVector(out Vector3 move, out bool applyGravity, out bool stickToGround)
{
// Skip if time is effectively paused
if (Mathf.Approximately(Time.timeScale, 0.0f))
{
move = Vector3.zero;
applyGravity = false;
stickToGround = false;
return;
}
// Get the next state
if (currentState != null)
{
currentState = GetNextState(currentState);
UpdateConnectable(currentState);
m_FrameMove = currentState.moveVector;
// Apply gravity / grounding force
applyGravity = currentState.applyGravity;
stickToGround = currentState.applyGroundingForce;
}
else
{
// Only outside influences
applyGravity = true;
stickToGround = true;
}
// Reset checked triggers
motionGraph.ResetCheckedTriggers();
// Reset the impact tracker
ResetHits();
// Set the frame move
move = m_FrameMove;
#if UNITY_EDITOR
TickDebugger(move, applyGravity, stickToGround);
#endif
}
void ResetHits()
{
m_HeadHitNormal = Vector3.zero;
m_BodyHitNormal = Vector3.zero;
m_HeadHitAvgSum = 0f;
m_HeadHitCount = 0;
m_BodyHitAvgSum = 0f;
m_BodyHitCount = 0;
}
void OnMoved()
{
var flags = characterController.collisionFlags;
// Trigger ground impact event
if ((flags & NeoCharacterCollisionFlags.Below) == NeoCharacterCollisionFlags.Below &&
(m_PreviousCollisionFlags & NeoCharacterCollisionFlags.Below) == NeoCharacterCollisionFlags.None)
{
m_OnGroundImpact.Invoke(characterController.groundNormal * -Vector3.Dot(m_PreviousVelocity, characterController.groundNormal));
}
if (m_HeadHitCount > 0)
{
m_HeadHitNormal /= m_HeadHitAvgSum;
m_HeadHitNormal.Normalize();
m_OnHeadImpact.Invoke(m_HeadHitNormal * -Vector3.Dot(m_PreviousVelocity, m_HeadHitNormal));
}
if (m_BodyHitCount > 0)
{
m_BodyHitNormal /= m_BodyHitAvgSum;
m_BodyHitNormal.Normalize();
m_OnBodyImpact.Invoke(m_BodyHitNormal * -Vector3.Dot(m_PreviousVelocity, m_BodyHitNormal));
}
m_PreviousVelocity = characterController.velocity;
m_PreviousCollisionFlags = flags;
}
void OnControllerColliderHit(NeoCharacterControllerHit hit)
{
// Record head hits if none last frame
if (hit.collisionFlags == NeoCharacterCollisionFlags.Above && (m_PreviousCollisionFlags & NeoCharacterCollisionFlags.Above) == NeoCharacterCollisionFlags.None)
{
++m_HeadHitCount;
float dot = Vector3.Dot(hit.normal, characterController.up);
m_HeadHitNormal += hit.normal * dot;
m_HeadHitAvgSum += dot;
}
// Record body hits if none last frame
if ((hit.collisionFlags & NeoCharacterCollisionFlags.Sides) == NeoCharacterCollisionFlags.Sides &&
(m_PreviousCollisionFlags & NeoCharacterCollisionFlags.Sides) == NeoCharacterCollisionFlags.None)
{
++m_BodyHitCount;
++m_BodyHitAvgSum;
m_BodyHitNormal += hit.normal;
}
}
#endregion
#region INITIALISATION
private bool m_InitialisedGraph = false;
private bool m_Initialised = false;
protected virtual void Awake()
{
localTransform = transform;
aimController = GetComponent<IAimController>();
characterController = GetComponent<INeoCharacterController>();
characterController.Initialise();
characterController.SetMoveCallback(GetMoveVector, OnMoved);
characterController.onHeightChanged += OnHeightChanged;
characterController.onControllerHit += OnControllerColliderHit;
characterController.inheritPlatformVelocity = NeoCharacterVelocityInheritance.None;
InitialiseGraph();
InitialiseUpperBody();
if (!m_ManualInitialisation)
ManualInitialise(true);
}
void InitialiseGraph()
{
if (!m_InitialisedGraph)
{
// Future work: If this is the editor, and the player character, do not make unique
// This allows for changes to graph and data settings to be properly serialized
// while editing. Find a clean way to do this
if (m_MotionGraph != null)
m_GraphInstance = m_MotionGraph.DeepCopy();
if (motionGraph != null)
{
motionGraph.Initialise(this);
currentState = GetNextState(motionGraph.rootNode);
if (m_DataOverrides != null)
motionGraph.AddDataOverrides(m_DataOverrides);
}
m_InitialisedGraph = true;
}
}
public void ManualInitialise(bool local)
{
// Prevent double initialisation
if (m_Initialised)
{
#if UNITY_EDITOR
Debug.LogError("Attempting to initialise character mover multiple times. Make sure manual initialisation is toggled on if you want to use this.");
#endif
return;
}
m_Initialised = true;
// Set local (blocks calculations conflicting between server and client if relevant)
isLocal = local;
}
protected virtual void Start()
{
}
#endregion
#region COLLIDER
enum HeightState
{
Stable,
SizingDown,
SizingUp
}
private HeightState m_HeightState = HeightState.Stable;
private float m_HeightChangeRate = 1f;
private float m_StandingHeight = 0f;
private float m_TargetHeight = 0f;
private float m_UpperBodyOffset = 0f;
public float currentHeight
{
get { return m_UpperBodyRoot.localPosition.y + m_UpperBodyOffset; }
private set { m_UpperBodyRoot.localPosition = new Vector3(0f, value - m_UpperBodyOffset, 0f); }
}
public float currentHeightNormalised
{
get { return currentHeight / m_StandingHeight; }
}
void InitialiseUpperBody()
{
// Get upper body height
if (m_UpperBodyRoot != null)
{
m_StandingHeight = m_TargetHeight = characterController.height;
m_UpperBodyOffset = m_StandingHeight - m_UpperBodyRoot.localPosition.y;
}
}
public float GetHeightMultiplier()
{
return m_TargetHeight / m_StandingHeight;
}
public void SetHeightMultiplier(float multiplier, float duration, CharacterResizePoint point = CharacterResizePoint.Automatic)
{
if (m_HeightState == HeightState.SizingUp)
characterController.CancelHeightChange();
// Get height change rate
if (duration > 0.01f)
{
m_HeightChangeRate = 1f / duration;// (duration * diff);
}
else
m_HeightChangeRate = 100f;
float h = m_StandingHeight * multiplier;
if (h < currentHeight) // Should it be current, target, or controller?
{
switch (point)
{
case CharacterResizePoint.Automatic:
if (characterController.isGrounded || !m_UseCrouchJump)
{
// Wait for head interpolation and then crouch from base
m_TargetHeight = h;
m_HeightState = HeightState.SizingDown;
}
else
{
// Crouch from top (crouch jump). Skip sizing down state as head doesn't move.
characterController.SetHeight(h, 1f);
}
break;
case CharacterResizePoint.Bottom:
// Wait for head interpolation and then crouch from base
m_TargetHeight = h;
m_HeightState = HeightState.SizingDown;
break;
case CharacterResizePoint.Top:
// Crouch from top (crouch jump). Skip sizing down state as head doesn't move.
characterController.SetHeight(h, 1f);
break;
}
}
else
{
// Enter sizing up state as waiting for space to change height
m_HeightState = HeightState.SizingUp;
switch (point)
{
case CharacterResizePoint.Automatic:
// Stand from bottom or top depending on grounding
if (characterController.isGrounded || !m_UseCrouchJump)
characterController.SetHeight(h, 0f);
else
characterController.SetHeight(h, 1f);
break;
case CharacterResizePoint.Bottom:
characterController.SetHeight(h, 0f);
break;
case CharacterResizePoint.Top:
characterController.SetHeight(h, 1f);
break;
}
}
}
public bool CheckIsHeightMultiplierRestricted(float multiplier)
{
return characterController.IsHeightRestricted(Mathf.Clamp01(multiplier) * m_StandingHeight);
}
void OnHeightChanged(float newHeight, float rootOffset)
{
// Move the upper body to compensate for the root transform moving
m_UpperBodyRoot.localPosition += new Vector3(0f, -rootOffset, 0f);
m_TargetHeight = newHeight;
m_HeightState = HeightState.Stable;
}
void LateUpdate()
{
if (!Mathf.Approximately(currentHeight, m_TargetHeight))
{
if (currentHeight < m_TargetHeight)
{
// Move head up
float to = currentHeight + m_HeightChangeRate * Time.deltaTime;
if (to > m_TargetHeight)
to = m_TargetHeight;
currentHeight = to;
}
else
{
// Move head down
float to = currentHeight - m_HeightChangeRate * Time.deltaTime;
if (to < m_TargetHeight)
{
to = m_TargetHeight;
if (m_HeightState == HeightState.SizingDown)
{
// In sizing down state, waits for head to lower before resizing capsule to
// prevent head clipping through low obstacles at start of crouch
characterController.SetHeight(m_TargetHeight, 0f);
}
}
currentHeight = to;
// Add grounded check during sizing up, so if it loses grounding, switch to
// crouch jump style duck and calculate normalised height for origin??
}
}
}
#endregion
#region MOTION GRAPH
MotionGraphState GetNextState(MotionGraphConnectable c)
{
m_CyclicCheck.Clear();
MotionGraphState result = CheckParents(c);
if (result != null)
return result;
return GetNextStateInternal(c);
}
MotionGraphState CheckParents(MotionGraphConnectable c)
{
if (c == null)
return null;
// Get parent chain (we want to start from root)
m_ParentChain.Clear();
while (c.parent != null)
{
m_ParentChain.Add(c.parent);
c = c.parent;
}
// Iterate through parents from root to current
// Skip the absolute root, as we only want transitions OUT of the
// Sub-graph, which the root cannot have
for (int p = m_ParentChain.Count - 2; p >= 0; --p)
{
MotionGraph parent = m_ParentChain[p];
for (int i = 0; i < parent.connections.Count; ++i)
{
MotionGraphConnection transition = parent.connections[i];
if (transition.destination.parent != parent)
{
if (transition.CheckConditions())
{
return GetNextStateInternal(transition.destination);
}
}
}
}
return null;
}
MotionGraphState GetNextStateInternal(MotionGraphConnectable c)
{
if (m_CyclicCheck.Contains(c))
{
if (!m_CyclicError)
{
Debug.Log("Cyclic transition detected in motion graph. Using starting state", this);
string sequence = "sequence: ";// + c.name;
for (int i = 0; i < m_CyclicCheck.Count; ++i)
{
if (i > 0)
sequence += ", ";
sequence += m_CyclicCheck[i].name;
}
sequence += ", " + c.name;
Debug.Log(sequence);
m_CyclicError = true;
}
return null;
}
else
m_CyclicCheck.Add(c);
// Check connectable transitions
for (int i = 0; i < c.connections.Count; ++i)
{
MotionGraphConnection transition = c.connections[i];
if (transition.destination.CheckCanEnter() && transition.CheckConditions())
return GetNextStateInternal(transition.destination);
}
// No transitions. If it's a graph, return default. If state, return this
MotionGraph graph = c as MotionGraph;
if (graph != null)
{
if (graph.defaultEntry != null)
return GetNextStateInternal(graph.defaultEntry);
else
return null;
}
else
return c as MotionGraphState;
}
#endregion
#region STEP TRACKING
[Header("Step Tracking")]
[SerializeField, Tooltip("The maximum speed when calculating distance travelled for footsteps.")]
private float m_StepSpeedCap = 20f;
[SerializeField, Tooltip("Switch this to true if you aren't tracking steps in the motion graph, and they will simply be counted at a default rate whenever the character is grounded.")]
private bool m_UseDumbStepping = false;
public event UnityAction onStep;
private float m_StrideLength = 0f;
public float smoothedStepRate
{
get;
private set;
}
public float stepCounter
{
get;
private set;
}
public float strideLength
{
get { return m_StrideLength; }
set
{
if (value < 0.001f)
m_StrideLength = 0f;
else
m_StrideLength = Mathf.Clamp(value, 0.5f, 50f);
}
}
public void SetWholeStep()
{
stepCounter = Mathf.Ceil(stepCounter);
}
void Update()
{
// Get the stride length (if using dumb stepping, steps are counted at a strideLength of 3m when grounded)
float sl = strideLength;
if (m_UseDumbStepping)
sl = characterController.isGrounded ? 3f : 0f;
if (sl > 0f)
{
// Get a smoothed version of the speed value (prevents jitter when changing direction)
smoothedStepRate = Mathf.Lerp(smoothedStepRate, Mathf.Min(characterController.velocity.magnitude, m_StepSpeedCap) / sl, Time.deltaTime * 5f);
if (onStep != null)
{
// Record old step count (floor)
float oldStepCounter = Mathf.Floor(stepCounter);
// Increment step counter
stepCounter += smoothedStepRate * Time.deltaTime;
// If whole number has increased, fire event
if (Mathf.Floor(stepCounter) > oldStepCounter)
onStep();
}
else
stepCounter += smoothedStepRate * Time.deltaTime;
}
else
{
// Fade out smoothedStepRate (so that it starts at zero when steps are next enabled instead of popping)
smoothedStepRate = Mathf.Lerp(smoothedStepRate, 0f, Time.deltaTime);
stepCounter += smoothedStepRate * Time.deltaTime;
}
}
#endregion
#region SERIALIZATION
private static readonly NeoSerializationKey k_CurrentStateKey = new NeoSerializationKey("currentState");
public void WriteProperties(INeoSerializer writer, NeoSerializedGameObject nsgo, SaveMode saveMode)
{
if (saveMode == SaveMode.Default)
{
// Save graph
writer.PushContext(SerializationContext.ObjectNeoSerialized, 0);
m_GraphInstance.WriteProperties(writer);
writer.PopContext(SerializationContext.ObjectNeoSerialized);
// Save current state
writer.WriteValue(k_CurrentStateKey, currentState.serializationKey);
writer.PushContext(SerializationContext.ObjectNeoSerialized, currentState.serializationKey);
currentState.WriteProperties(writer);
writer.PopContext(SerializationContext.ObjectNeoSerialized);
}
}
public void ReadProperties(INeoDeserializer reader, NeoSerializedGameObject nsgo)
{
InitialiseGraph();
// Load graph
if (m_GraphInstance != null)
{
// Load current state
int key;
if (reader.TryReadValue(k_CurrentStateKey, out key, 0))
{
var found = m_GraphInstance.GetStateFromKey(key);
if (found != null)
{
currentState = found;
if (reader.PushContext(SerializationContext.ObjectNeoSerialized, key))
{
try
{
currentState.ReadProperties(reader);
}
finally
{
reader.PopContext(SerializationContext.ObjectNeoSerialized, key);
}
}
}
}
if (reader.PushContext(SerializationContext.ObjectNeoSerialized, 0))
{
try
{
m_GraphInstance.ReadProperties(reader);
}
finally
{
reader.PopContext(SerializationContext.ObjectNeoSerialized, 0);
}
}
}
}
#endregion
#region DEBUG GIZMOS
#if UNITY_EDITOR
void OnDrawGizmos()
{
if (!Application.isPlaying && gameObject.scene.IsValid())
return;
if (characterController == null)
return;
float radius = characterController.radius;
float height = characterController.height;
Vector3 position = localTransform.position;
Quaternion rotation = localTransform.rotation;
Vector3 up = rotation * Vector3.up;
// Draw capsule
ExtendedGizmos.DrawCapsuleMarker(radius, height, position + up * (height * 0.5f), Color.white);
// Draw input arrow
if (inputMoveScale > 0.01f)
{
float angle = Vector2.SignedAngle(inputMoveDirection, Vector2.up);
ExtendedGizmos.DrawArrowMarkerFlat(
position + up * height * 0.5f,
rotation,
angle,
inputMoveScale,
Color.blue
);
}
// Draw velocity arrow
var velocity = characterController.rawVelocity;
if (velocity.sqrMagnitude > 0.001f)
{
ExtendedGizmos.DrawArrowMarker3D(
position + up * height * 0.5f,
velocity.normalized,
velocity.magnitude * 0.2f,
Color.cyan
);
}
// Draw ground normals
if (characterController.isGrounded)
{
var normal = characterController.groundNormal;
var surfaceNormal = characterController.groundSurfaceNormal;
// Draw ground normal
Vector3 contactPoint = position + (up * radius) - (normal * radius);
ExtendedGizmos.DrawArrowMarker3D(
contactPoint,
normal,
radius,
Color.magenta
);
// Draw ground surface normal
ExtendedGizmos.DrawRay(contactPoint, surfaceNormal, radius, Color.green);
}
}
#endif
#endregion
#region DEBUGGER
#if UNITY_EDITOR
public delegate void OnDestroyDelegate(MotionController mc);
public delegate void OnTickDelegate(MotionController mc, Vector3 targetMove, bool applyGravity, bool snapToGround);
public event OnDestroyDelegate onDestroy;
public event OnTickDelegate onTick;
void TickDebugger(Vector3 targetMove, bool applyGravity, bool snapToGround)
{
if (onTick != null)
onTick(this, targetMove, applyGravity, snapToGround);
}
void OnDestroy()
{
if (onDestroy != null)
onDestroy(this);
}
#endif
#endregion
}
}