// 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); } } }