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 m_ParentChain = new List(8); private List m_CyclicCheck = new List(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 { } public event UnityAction onGroundImpact { add { m_OnGroundImpact.AddListener(value); } remove { m_OnGroundImpact.RemoveListener(value); } } public event UnityAction onHeadImpact { add { m_OnHeadImpact.AddListener(value); } remove { m_OnHeadImpact.RemoveListener(value); } } public event UnityAction 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(); characterController = GetComponent(); 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 } }