// Copyright (C) 2023 Nicholas Maltbie
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
// associated documentation files (the "Software"), to deal in the Software without restriction,
// including without limitation the rights to use, copy, modify, merge, publish, distribute,
// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
using System.Collections.Generic;
using System.Linq;
using nickmaltbie.OpenKCC.Character.Config;
using nickmaltbie.OpenKCC.Environment.MovingGround;
using nickmaltbie.OpenKCC.Utils;
using nickmaltbie.TestUtilsUnity;
using UnityEngine;
namespace nickmaltbie.OpenKCC.Character
{
///
/// Movement engine for the kcc state machine
/// to abstract calls to the KCC Utils
/// for a basic character controller.
///
[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(IColliderCast))]
public class KCCMovementEngine : MonoBehaviour, IKCCConfig, ISerializationCallbackReceiver
{
///
/// Default value for distance from ground player is considered grounded.
///
public const float DefaultGroundedDistance = 0.05f;
///
/// Default value for distance to check for ground below player.
///
public const float DefaultGroundCheckDistance = 0.25f;
///
/// Default maximum number of bounces for computing player movement.
///
public const int DefaultMaxBounces = 5;
///
/// Default expected depth of a step for walking up staircases.
///
public const float DefaultStepUpDepth = 0.1f;
///
/// Default angle power for bouncing and sliding off surfaces.
///
public const float DefaultAnglePower = 2.0f;
///
/// Default max push speed when moving the player.
///
public const float DefaultMaxPushSpeed = 100.0f;
///
/// Modifier of step height for snapping the player down.
///
public const float SnapDownModifier = 2.0f;
///
/// Max launch speed of player form moving ground.
///
public const float DefaultMaxLaunchVelocity = 2.0f;
///
/// Serialization version for the KCCMovementEngine.
///
public const string CurrentSerializationVersion = "v1.0.0";
///
/// Current serialization version of this serialized KCCMovementEngine.
///
[HideInInspector]
[SerializeField]
internal string serializationVersion;
///
/// Height of a step that the player can climb up.
///
[Tooltip("Height of a step that the player can climb up.")]
public float stepHeight = 0.35f;
///
/// Max angle the player can walk up before slipping.
///
[Tooltip("Max angle the player can walk up before slipping.")]
public float maxWalkAngle = 60.0f;
///
/// Skin width for player collisions.
///
[Tooltip("Skin width for player collisions.")]
public float skinWidth = 0.01f;
///
/// Layermask for computing player collisions.
///
[Tooltip("Layermask for computing player collisions.")]
public LayerMask layerMask = RaycastHelperConstants.DefaultLayerMask;
///
/// Unity service for managing calls to static variables in
/// a testable manner.
///
protected IUnityService unityService = UnityService.Instance;
///
/// Collider cast for player shape.
///
public IColliderCast _colliderCast;
///
/// Previous position of the player for calculating moving ground position.
///
protected Vector3 previousPosition = Vector3.zero;
///
/// Approximated world velocity of the player.
///
public SmoothedVector worldVelocity = new SmoothedVector(10);
///
public bool CanSnapUp => GroundedState.OnGround;
///
/// Snap down distance for player snapping down.
///
public virtual float SnapDown => stepHeight * 2f;
///
/// Max default launch velocity for the player from unlabeled
/// surfaces.
///
public virtual float MaxDefaultLaunchVelocity => DefaultMaxLaunchVelocity;
///
/// Maximum speed at which the player can snap down surfaces.
///
public virtual float MaxSnapDownSpeed => 5.0f;
///
/// Upwards direction for the KCC Movement engine.
///
public virtual Vector3 Up => Vector3.up;
///
public virtual int MaxBounces => DefaultMaxBounces;
///
public virtual float VerticalSnapUp => stepHeight;
///
public virtual float StepUpDepth => DefaultStepUpDepth;
///
public virtual float AnglePower => DefaultAnglePower;
///
public virtual float SkinWidth => skinWidth;
///
public virtual LayerMask LayerMask => layerMask;
///
/// Max push speed of the player in units per second when pushing
/// out of overlapping objects.
///
public float MaxPushSpeed => DefaultMaxPushSpeed;
///
/// Collider cast for player movement.
///
public virtual IColliderCast ColliderCast => _colliderCast = _colliderCast ?? GetComponent();
///
/// Distance to ground at which player is considered grounded.
///
public virtual float GroundedDistance => DefaultGroundedDistance;
///
/// Distance to check player distance to ground.
///
public virtual float GroundCheckDistance => DefaultGroundCheckDistance;
///
/// Maximum angle at which the player can walk (in degrees).
///
public virtual float MaxWalkAngle => maxWalkAngle;
///
/// Relative parent configuration for following the ground.
///
public RelativeParentConfig RelativeParentConfig { get; protected set; } = new RelativeParentConfigWithFeet();
///
/// Current grounded state of the character.
///
public KCCGroundedState GroundedState { get; protected set; }
///
/// Setup and configure the movement engine.
///
public void Awake()
{
previousPosition = transform.position;
}
///
/// Is the a movement vector is moving in the direction
/// upwards relative to player direction.
///
public bool MovingUp(Vector3 move)
{
return Vector3.Dot(move, Up) > 0;
}
///
/// Get the bounces for a KCC Utils movement action with a set default behaviour.
///
/// Movement to move the player.
/// Bounces that the player makes when hitting objects as part of it's movement.
public virtual IEnumerable GetMovement(Vector3 movement)
{
foreach (KCCBounce bounce in KCCUtils.GetBounces(transform.position, movement, transform.rotation, this))
{
if (bounce.action == KCCUtils.MovementAction.Stop)
{
transform.position = bounce.finalPosition;
}
yield return bounce;
}
}
///
/// Gets the velocity of the ground the player is standing on where the player is currently
///
/// The velocity of the ground at the point the player is standing on
public virtual Vector3 GetGroundVelocity()
{
Vector3 groundVelocity = Vector3.zero;
IMovingGround movingGround = GroundedState.Floor?.GetComponent();
Rigidbody rb = GroundedState.Floor?.GetComponent();
if (movingGround != null)
{
if (movingGround.AvoidTransferMomentum())
{
return Vector3.zero;
}
// Weight movement of ground by ground movement weight
groundVelocity = movingGround.GetVelocityAtPoint(GroundedState.GroundHitPosition);
float velocityWeight =
movingGround.GetMovementWeight(GroundedState.GroundHitPosition, groundVelocity);
float transferWeight =
movingGround.GetTransferMomentumWeight(GroundedState.GroundHitPosition, groundVelocity);
groundVelocity *= velocityWeight;
groundVelocity *= transferWeight;
}
else if (rb != null && !rb.isKinematic)
{
Vector3 groundVel = rb.GetPointVelocity(GroundedState.GroundHitPosition);
float velocity = Mathf.Min(groundVel.magnitude, MaxDefaultLaunchVelocity);
groundVelocity = groundVel.normalized * velocity;
}
else if (GroundedState.StandingOnGround)
{
Vector3 avgVel = worldVelocity.Average();
float velocity = Mathf.Min(avgVel.magnitude, MaxDefaultLaunchVelocity);
groundVelocity = avgVel.normalized * velocity;
}
return groundVelocity;
}
///
/// Have the player to visually move with the ground.
///
public void Update()
{
RelativeParentConfig.FollowGround(transform);
}
///
/// Applies player movement based current configuration.
/// Includes pushing out overlapping objects, updating grounded state, jumping,
/// moving the player, and updating the grounded state.
///
/// Desired player movement in world space.
public virtual KCCBounce[] MovePlayer(params Vector3[] moves)
{
RelativeParentConfig.FollowGround(transform);
Vector3 previousVelocity = (transform.position - previousPosition) / unityService.deltaTime;
worldVelocity.AddSample(previousVelocity);
Vector3 start = transform.position;
// Push player out of overlapping objects
transform.position += ColliderCast.PushOutOverlapping(
transform.position,
transform.rotation,
MaxPushSpeed * unityService.fixedDeltaTime,
layerMask,
QueryTriggerInteraction.Ignore,
SkinWidth / 2);
// Allow player to move
KCCBounce[] bounces = moves.SelectMany(move => GetMovement(move)).ToArray();
// Compute player relative movement state based on final pos
bool snappedUp = bounces.Any(bounce => bounce.action == KCCUtils.MovementAction.SnapUp);
bool snappedDown = ShouldSnapDown(snappedUp, moves);
// Only snap down if the player was grounded before they started
// moving and are not currently trying to move upwards.
if (snappedDown)
{
SnapPlayerDown();
}
CheckGrounded(snappedUp, snappedDown);
Vector3 delta = transform.position - start;
transform.position += RelativeParentConfig.UpdateMovingGround(start, transform.rotation, GroundedState, delta, unityService.fixedDeltaTime);
previousPosition = transform.position;
return bounces;
}
///
/// Teleport player to a given position.
///
/// Position to teleport player to.
public virtual void TeleportPlayer(Vector3 position)
{
RelativeParentConfig.Reset();
transform.position = position;
foreach (IOnPlayerTeleport tele in GetComponents())
{
tele.OnPlayerTeleport(position, transform.rotation);
}
}
///
/// Get the movement of the player projected onto the plane
/// they are standing on if they are not falling.
///
/// How the player is attempting to move.
/// Projected movement onto the plane the player is standing on.
public virtual Vector3 GetProjectedMovement(Vector3 movement)
{
// If the player is standing on the ground, project their movement onto the ground plane
// This allows them to walk up gradual slopes without facing a hit in movement speed
if (GroundedState.StandingOnGround && !GroundedState.Sliding)
{
Vector3 projectedMovement = Vector3.ProjectOnPlane(movement, GroundedState.SurfaceNormal).normalized * movement.magnitude;
if (projectedMovement.magnitude + KCCUtils.Epsilon >= movement.magnitude)
{
movement = projectedMovement;
}
}
return movement;
}
///
/// Update the current grounded state of this kinematic character controller.
///
public virtual KCCGroundedState CheckGrounded(bool snappedUp, bool snappedDown)
{
Vector3 groundCheckPos = transform.position;
// If snapped up, use the snapped position to check grounded
if (snappedUp || snappedDown)
{
Vector3 snapDelta = KCCUtils.GetSnapDelta(
transform.position,
transform.rotation,
-Up,
SnapDown,
ColliderCast,
LayerMask,
SkinWidth);
groundCheckPos += snapDelta;
}
bool didHit = ColliderCast.CastSelf(
groundCheckPos,
transform.rotation,
-Up,
GroundCheckDistance,
out IRaycastHit hit,
layerMask,
skinWidth: SkinWidth);
Vector3 normal = hit.normal;
if (snappedUp)
{
normal = GroundedState.SurfaceNormal;
}
else if (!snappedUp && snappedDown)
{
// Check if we're walking down stairs
bool overrideNormal = ColliderCast.DoRaycastInDirection(
transform.position + skinWidth * Up,
-Up,
GroundCheckDistance + skinWidth,
out IRaycastHit stepHit,
layerMask);
if (overrideNormal)
{
normal = stepHit.normal;
}
}
GroundedState = new KCCGroundedState(
distanceToGround: hit.distance,
onGround: didHit,
angle: Vector3.Angle(normal, Up),
surfaceNormal: normal,
groundHitPosition: hit.point,
floor: hit.collider?.gameObject,
groundedDistance: GroundedDistance,
maxWalkAngle: MaxWalkAngle);
return GroundedState;
}
public void OnBeforeSerialize()
{
// Nothing needed here.
}
public void OnAfterDeserialize()
{
// If the serialization version is unset, update it.
if (string.IsNullOrEmpty(serializationVersion))
{
serializationVersion = CurrentSerializationVersion;
// Set default value for layer mask
if (layerMask == default)
{
layerMask = ~0;
}
}
}
///
/// Should the player snap down after a movement.
///
/// Did the player snap up during their movement.
/// Movement bounces of the player.
/// True if the player should snap down, false otherwise.
protected virtual bool ShouldSnapDown(bool snappedUp, IEnumerable moves)
{
return !snappedUp &&
GroundedState.StandingOnGround &&
!GroundedState.Sliding &&
!moves.Any(move => MovingUp(move));
}
///
/// Snap the player down onto the ground
///
protected virtual void SnapPlayerDown()
{
Vector3 delta = KCCUtils.GetSnapDelta(
transform.position,
transform.rotation,
-Up,
SnapDown,
ColliderCast,
LayerMask,
skinWidth);
transform.position += Vector3.ClampMagnitude(delta, MaxSnapDownSpeed * unityService.fixedDeltaTime);
}
}
}