// 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 UnityEngine; namespace nickmaltbie.OpenKCC.Utils { /// /// Data structure describing a bounce of the KCC when moving throughout a scene. /// public class KCCBounce { /// /// Initial position before moving. /// public Vector3 initialPosition; /// /// Final position once finishing this bounce. /// public Vector3 finalPosition; /// /// Initial momentum when starting the move. /// public Vector3 initialMomentum; /// /// Remaining momentum after this bounce. /// public Vector3 remainingMomentum; /// /// Action that ocurred during this bounce. /// public KCCUtils.MovementAction action; /// /// Collision data associated with the bounce. /// public IRaycastHit hit; /// /// Get the movement of a vector (from initial position to final position). /// public Vector3 Movement => finalPosition - initialPosition; public override string ToString() { return string.Join( ", ", $"{nameof(initialPosition)}:{initialPosition.ToString("F3")}", $"{nameof(finalPosition)}:{finalPosition.ToString("F3")}", $"{nameof(initialMomentum)}:{initialMomentum.ToString("F3")}", $"{nameof(remainingMomentum)}:{remainingMomentum.ToString("F3")}", $"{nameof(action)}:{action.ToString()}" ); } } /// /// Utility class for static functions involving kinematic character controller. /// public static class KCCUtils { /// /// Different actions associated with each update from the KCC Utils. /// public enum MovementAction { Invalid, Move, Bounce, SnapUp, Stop, } /// /// Maximum angle between two colliding objects. /// public const float MaxAngleShoveDegrees = 180.0f - BufferAngleShove; /// /// Small buffer angle when moving into objects. /// public const float BufferAngleShove = 120.0f; /// /// Epsilon value for spacing out the KCC very small distances. /// public const float Epsilon = 0.001f; /// /// Snap the player down onto the ground /// /// Position of the kcc /// Rotation of the kcc. /// Direction to snap the kcc down. /// Maximum distance the kcc can snap. /// Collider cast component associated with the KCC. /// Configuration for character controller. /// public static Vector3 GetSnapDelta( Vector3 position, Quaternion rotation, Vector3 dir, float dist, IColliderCast colliderCast, int layerMask = RaycastHelperConstants.DefaultLayerMask, float skinWidth = 0.0f) { bool didHit = colliderCast.CastSelf( position, rotation, dir, dist, out IRaycastHit hit, layerMask, QueryTriggerInteraction.Ignore, skinWidth); if (didHit) { return dir * hit.distance; } return Vector3.zero; } /// /// Snap the player down onto the ground /// /// Position of the kcc /// Rotation of the kcc. /// Direction to snap the kcc down. /// Maximum distance the kcc can snap. /// Collider cast component associated with the KCC. /// Configuration for character controller. /// Final position of player after snapping. public static Vector3 SnapPlayerDown( Vector3 position, Quaternion rotation, Vector3 dir, float dist, IColliderCast colliderCast, IKCCConfig kccConfig) { return position + GetSnapDelta(position, rotation, dir, dist, colliderCast, kccConfig.LayerMask, kccConfig.SkinWidth); } /// /// Attempt to snap the player up some distance. This will check if there /// is available space on the ledge above the point that the player collided with. /// If there is space, the player will be teleported up some distance. If /// there is not enough space on the ledge above, then this will move the player back to where /// they were before the attempt was made. /// /// Distance that the player is teleported up. /// The remaining momentum of the player. /// Position of the player. /// Rotation of the player. /// Config for controlling player movement. /// True if the player had space on the ledge and was able to move, false if /// there was not enough room the player is moved back to their original position public static bool AttemptSnapUp( float distanceToSnap, ref Vector3 momentum, ref Vector3 position, Quaternion rotation, IKCCConfig config) { // If we were to snap the player up and they moved forward, would they hit something? Vector3 snapPos = position + distanceToSnap * config.Up; bool didSnapHit = config.ColliderCast.CastSelf( snapPos, rotation, momentum.normalized, config.StepUpDepth, out IRaycastHit snapHit, config.LayerMask, QueryTriggerInteraction.Ignore, config.SkinWidth); // If they can move without instantly hitting something, then snap them up if (!didSnapHit || snapHit.distance > config.StepUpDepth) { // Have the player move up up to the remaining momentum float distanceMove = Mathf.Min(momentum.magnitude, distanceToSnap); position += distanceMove * Vector3.up; return true; } // Otherwise move the player back down return false; } /// /// Check if the character bounced /// into a surface perpendicular to /// itself. /// /// Surface the character bounces into. /// Current rotation of the player. /// Configuration for collider cast calls. /// True if the step is perpendicular to up, false otherwise. public static bool CheckPerpendicularBounce( IRaycastHit hit, Vector3 momentum, IKCCConfig config) { bool hitStep = config.ColliderCast.DoRaycastInDirection( hit.point - config.Up * Epsilon + hit.normal * Epsilon, momentum.normalized, momentum.magnitude, out IRaycastHit stepHit, config.LayerMask, QueryTriggerInteraction.Ignore); return hitStep && Vector3.Dot(stepHit.normal, config.Up) <= Epsilon; } /// /// Attempt to snap up at current height or maximum height of the player's snap up height. /// /// Collider cast associated with the player. /// Hit event where player collided with the step. /// Up direction for the player. /// Player's remaining movement, will be reduced by the amount /// the player moves up the current step. /// Player's current position, modify if the player moves while walking up the step. /// Player's current rotation. /// Maximum distance player can snap up. /// Minimum depth required to have the player step forward. /// True if the player snapped up, false otherwise. public static bool AttemptSnapUp( IRaycastHit hit, ref Vector3 momentum, ref Vector3 position, Quaternion rotation, IKCCConfig config) { // Snap character vertically up if they hit something // close enough to their feet Vector3 bottom = config.ColliderCast.GetBottom(position, rotation); Vector3 footVector = Vector3.Project(hit.point, config.Up) - Vector3.Project(bottom, config.Up); bool isAbove = Vector3.Dot(footVector, config.Up) > 0; float distanceToFeet = footVector.magnitude * (isAbove ? 1 : -1); bool snappedUp = false; if (distanceToFeet < config.VerticalSnapUp) { // Sometimes snapping up the exact distance leads to odd behaviour around steps and walls. // It's good to check the maximum and minimum snap distances and take whichever one works. // snap them up the minimum vertical distance snappedUp = AttemptSnapUp( distanceToFeet, ref momentum, ref position, rotation, config); if (!snappedUp) { // If that movement doesn't work, Attempt to snap up the maximum vertical distance snappedUp = AttemptSnapUp( config.VerticalSnapUp, ref momentum, ref position, rotation, config); } } return snappedUp; } /// /// Get player's projected movement along a surface. /// /// Remaining player momentum. /// Plane normal that the player is bouncing off of. /// Upwards direction relative to player. /// Remaining momentum of the player. public static Vector3 GetBouncedMomentumSafe(Vector3 momentum, Vector3 planeNormal, Vector3 up) { Vector3 projectedMomentum = Vector3.ProjectOnPlane(momentum, planeNormal).normalized * momentum.magnitude; // If projected momentum is less than original momentum (so if the projection broke due to float // operations), then change this to just project along the vertical. if (Mathf.Abs(projectedMomentum.magnitude - momentum.magnitude) > Epsilon) { return momentum; } return projectedMomentum; } /// /// Compute a single KCC Bounce from a given position. /// /// Starting position for bounce. /// Remaining momentum of the bounce. /// Initial player input movement. /// Rotation of the player. /// Configuration of movement. /// Returns KCC Bounce information for current movement. public static KCCBounce SingleKCCBounce( Vector3 position, Vector3 remainingMomentum, Vector3 movement, Quaternion rotation, IKCCConfig config) { Vector3 initialPosition = position; Vector3 initialMomentum = remainingMomentum; // Do a cast of the collider to see if an object is hit during this // movement bounce float distance = remainingMomentum.magnitude; if (!config.ColliderCast.CastSelf( position, rotation, remainingMomentum.normalized, distance, out IRaycastHit hit, config.LayerMask, QueryTriggerInteraction.Ignore, config.SkinWidth)) { // If there is no hit, move to desired position return new KCCBounce { initialPosition = initialPosition, finalPosition = remainingMomentum + initialPosition, initialMomentum = initialMomentum, remainingMomentum = Vector3.zero, hit = hit, action = MovementAction.Move, }; } float fraction = hit.distance / distance; // Set the fraction of remaining movement (minus some small value) Vector3 deltaBounce = remainingMomentum * fraction; deltaBounce = deltaBounce.normalized * Mathf.Max(0, deltaBounce.magnitude - Epsilon); position += deltaBounce; // Decrease remaining momentum by fraction of movement remaining remainingMomentum *= (1 - Mathf.Max(0, deltaBounce.magnitude / distance)); // Check if the player is running into a perpendicular surface bool perpendicularBounce = CheckPerpendicularBounce(hit, remainingMomentum, config); Vector3 snappedMomentum = remainingMomentum; Vector3 snappedPosition = position; if (perpendicularBounce && AttemptSnapUp(hit, ref snappedMomentum, ref snappedPosition, rotation, config)) { return new KCCBounce { initialPosition = initialPosition, finalPosition = snappedPosition, initialMomentum = initialMomentum, remainingMomentum = snappedMomentum, hit = hit, action = MovementAction.SnapUp, }; } // If we didn't snap up: // Only apply angular change if hitting something // Get angle between surface normal and remaining movement float angleBetween = Vector3.Angle(hit.normal, remainingMomentum); // Normalize angle between to be between 0 and 1 float normalizedAngle = Mathf.Max(angleBetween - BufferAngleShove, 0) / MaxAngleShoveDegrees; // Get the component of the vector on non vertical remainingMomentum *= Mathf.Pow(Mathf.Abs(1 - normalizedAngle), config.AnglePower); remainingMomentum = GetBouncedMomentumSafe(remainingMomentum, hit.normal, config.Up); return new KCCBounce { initialPosition = initialPosition, finalPosition = position, initialMomentum = initialMomentum, remainingMomentum = remainingMomentum, hit = hit, action = MovementAction.Bounce, }; } /// /// Get the bounces for a KCC Utils movement action with a set default behaviour. /// /// Position to start player movement from. /// Movement to move the player. /// Rotation of the player during movement. /// Configuration settings for player movement. /// Bounces that the player makes when hitting objects as part of it's movement. public static IEnumerable GetBounces( Vector3 position, Vector3 movement, Quaternion rotation, IKCCConfig config) { // Save current momentum Vector3 momentum = movement; // current number of bounces int bounces = 0; bool didSnapUp = false; // Continue computing while there is momentum and bounces remaining while (momentum.magnitude >= Epsilon && bounces <= config.MaxBounces) { KCCBounce bounce = SingleKCCBounce(position, momentum, movement, rotation, config); if (bounce.action == MovementAction.SnapUp) { didSnapUp = momentum.magnitude > KCCUtils.Epsilon; } else if (Vector3.Dot(bounce.Movement, movement) < 0) { break; } yield return bounce; momentum = bounce.remainingMomentum; position = bounce.finalPosition; // Track number of times the character has bounced bounces++; } if (didSnapUp) { position = SnapPlayerDown(position, rotation, -config.Up, config.VerticalSnapUp, config.ColliderCast, config); } // We're done, player was moved as part of loop yield return new KCCBounce { initialPosition = position, finalPosition = position, initialMomentum = Vector3.zero, remainingMomentum = Vector3.zero, action = MovementAction.Stop, }; yield break; } } }