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