// 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;
using nickmaltbie.OpenKCC.Utils;
using UnityEngine;
using UnityEngine.InputSystem;
namespace nickmaltbie.OpenKCC.Demo
{
///
/// Example of the KCC class with a simplified movement script for learning purposes with an additional feature of a
/// Jump and snap down mechanic.
///
[RequireComponent(typeof(CapsuleCollider))]
public class SimplifiedKCCWithJump : MonoBehaviour
{
///
/// Minimum pitch for camera movement.
///
public const float minPitch = -90;
///
/// Maximum pitch for camera movement.
///
public const float maxPitch = 90;
[Header("Input Actions")]
///
/// Action realted to moving the camera, should be a two component vector.
///
[Tooltip("Action with two axis to rotate player camera around.")]
public InputActionReference lookAround;
///
/// Action realted to moving the player, should be a two component vector.
///
[Tooltip("Action with two axis used to move the player around.")]
public InputActionReference movePlayer;
///
/// Action realted to moving the jumping, should be a button input.
///
[Tooltip("Action realted to moving the jumping, should be a button input.")]
public InputActionReference jumpAction;
[Header("Camera Settings")]
///
/// How fast the player can rotate in degrees per second.
///
[Tooltip("Rotation speed when moving the camera.")]
[SerializeField]
public float rotateSpeed = 90f;
///
/// Transform holding camera position.
///
[Tooltip("Transform holding camera position.")]
[SerializeField]
public Transform cameraTransform;
[Header("Movement Settings")]
///
/// Maximum number of bounces when moving the player.
///
[Tooltip("Maximum number of bounces when moving the player.")]
[SerializeField]
public int maxBounces = 5;
///
/// Player movement speed.
///
[Tooltip("Move speed of the player character.")]
[SerializeField]
public float moveSpeed = 5.0f;
///
/// Decrease in momentum factor due to angle change when walking.
/// Should be a positive float value. It's an exponential applied to
/// values between [0, 1] so values smaller than 1 create a positive
/// curve and grater than 1 for a negative curve.
///
[Tooltip("Decrease in momentum when walking into objects (such as walls) at an angle as an exponential." +
"Values between [0, 1] so values smaller than 1 create a positive curve and grater than 1 for a negative curve")]
[SerializeField]
public float anglePower = 0.5f;
///
/// Distance that the character can "snap down" vertical steps
///
[Tooltip("Snap down distance when snapping onto the floor")]
[SerializeField]
private float verticalSnapDown = 0.45f;
///
/// Speed at which the player falls.
///
[Tooltip("Speed at which the player falls.")]
[SerializeField]
public Vector3 gravity = new Vector3(0, -20, 0);
[Header("Grounded Settings")]
///
/// Distance at which the player is considered grounded.
///
[Tooltip("Distance from ground at which the player will stop falling.")]
[SerializeField]
public float groundDist = 0.01f;
///
/// Max angle at which the player can walk.
///
[Tooltip("Max angle at which the player can walk.")]
[SerializeField]
public float maxWalkingAngle = 60f;
[Header("Jump Settings")]
///
/// Velocity of player jump in units per second
///
[Tooltip("Vertical velocity of player jump")]
[SerializeField]
private float jumpVelocity = 5.0f;
///
/// Max angle at which the player can jump.
///
[Tooltip("Max angle at which the player can jump.")]
[SerializeField]
public float maxJumpAngle = 80f;
///
/// Minimum cooldown time between player jumps.
///
[Tooltip("Minimum cooldown time between player jumps.")]
[SerializeField]
public float jumpCooldown = 0.25f;
///
/// Time in which player can jump after they walk off the edge off a surface.
///
[Tooltip("Time in which player can jump after they walk off the edge off a surface.")]
[SerializeField]
public float coyoteTime = 0.05f;
///
/// Time in seconds that an input can be buffered for jumping.
///
[Tooltip("Time in seconds that an input can be buffered for jumping")]
[SerializeField]
private float jumpBufferTime = 0.05f;
///
/// Weight to which the player's jump is weighted towards the direction
/// of the surface they are standing on.
///
[Tooltip("Weight to which the player's jump is weighted towards the angle of their surface")]
[SerializeField]
[Range(0, 1)]
private float jumpAngleWeightFactor = 0.0f;
///
/// Time since the player last tried to hit the jump button.
///
private float jumpInputElapsed = Mathf.Infinity;
///
/// Time since the player last successfully jumped.
///
private float timeSinceLastJump = 0.0f;
///
/// Time in which the player has been falling.
///
private float elapsedFalling = 0f;
///
/// Has the player jumped while sliding?
///
private bool notSlidingSinceJump = true;
///
/// Velocity at which player is moving.
///
private Vector3 velocity;
///
/// Current angle at which the player is looking.
///
private Vector2 cameraAngle;
///
/// Configuration for capsule collider to compute player collision.
///
private CapsuleCollider capsuleCollider;
///
/// Is the player pressing jump action.
///
private bool jumpInputPressed => jumpAction.action.IsPressed();
public void Start()
{
capsuleCollider = GetComponent();
Rigidbody rigidbody = GetComponent();
rigidbody.isKinematic = true;
}
public void Update()
{
// Read input values from player
Vector2 cameraMove = lookAround.action.ReadValue();
Vector2 playerMove = movePlayer.action.ReadValue();
// If player is not allowed to move, stop player input
if (PlayerInputUtils.playerMovementState == PlayerInputState.Deny)
{
playerMove = Vector2.zero;
cameraMove = Vector2.zero;
}
// Camera move on x (horizontal movement) controls the yaw (look left or look right)
// Camera move on y (vertical movement) controls the pitch (look up or look down)
cameraAngle.x += -cameraMove.y * rotateSpeed * Time.deltaTime;
cameraAngle.y += cameraMove.x * rotateSpeed * Time.deltaTime;
cameraAngle.x = Mathf.Clamp(cameraAngle.x, minPitch, maxPitch);
cameraAngle.y %= 360;
// Rotate player based on mouse input, ensure pitch is bounded to not overshoot
transform.rotation = Quaternion.Euler(0, cameraAngle.y, 0);
// Only rotate camera pitch
cameraTransform.rotation = Quaternion.Euler(cameraAngle.x, cameraAngle.y, 0);
// Check if the player is falling
(bool onGround, float groundAngle) = CheckGrounded(out RaycastHit groundHit);
bool falling = !(onGround && groundAngle <= maxWalkingAngle);
// If falling, increase falling speed, otherwise stop falling.
if (falling)
{
velocity += gravity * Time.deltaTime;
elapsedFalling += Time.deltaTime;
}
else
{
velocity = Vector3.zero;
elapsedFalling = 0;
notSlidingSinceJump = true;
}
// If the player is attemtping to jump and can jump allow for player jump
if (jumpInputPressed)
{
jumpInputElapsed = 0.0f;
}
// Player is attempting to jump if they hit jump this frame or within the last buffer time.
bool attemptingJump = jumpInputElapsed <= jumpBufferTime;
// Player can jump if they are (1) on the ground, (2) within the ground jump angle,
// (3) has not jumped within the jump cooldown time period, and (4) has only jumped once while sliding
bool canJump = (onGround || elapsedFalling <= coyoteTime) &&
groundAngle <= maxJumpAngle &&
timeSinceLastJump >= jumpCooldown &&
(!falling || notSlidingSinceJump);
// Have player jump if they can jump and are attempting to jump
if (canJump && attemptingJump)
{
velocity = Vector3.Lerp(Vector3.up, groundHit.normal, jumpAngleWeightFactor) * jumpVelocity;
timeSinceLastJump = 0.0f;
jumpInputElapsed = Mathf.Infinity;
// Mark if the player is jumping while they are sliding
notSlidingSinceJump = false;
}
else
{
timeSinceLastJump += Time.deltaTime;
jumpInputElapsed += Time.deltaTime;
}
// Read player input movement
var inputVector = new Vector3(playerMove.x, 0, playerMove.y);
// Rotate movement by current viewing angle
var viewYaw = Quaternion.Euler(0, cameraAngle.y, 0);
Vector3 rotatedVector = viewYaw * inputVector;
Vector3 normalizedInput = rotatedVector.normalized * Mathf.Min(rotatedVector.magnitude, 1.0f);
// Scale movement by speed and time
Vector3 movement = normalizedInput * moveSpeed * Time.deltaTime;
// If the player is standing on the ground, project their movement onto that plane
// This allows for walking down slopes smoothly.
if (!falling)
{
movement = Vector3.ProjectOnPlane(movement, groundHit.normal);
}
// Attempt to move the player based on player movement
transform.position = MovePlayer(movement);
// Move player based on falling speed
transform.position = MovePlayer(velocity * Time.deltaTime);
// If player was on ground at the start of the ground, snap the player down
if (onGround && !attemptingJump)
{
SnapPlayerDown();
}
}
///
/// Check if the player is standing on the ground.
///
/// Hit event for standing on the ground.
/// A tuple of a (boolean, float), the boolean is whether the player is within groundDist of
/// the ground, the float is the angle between the surface and the ground.
private (bool, float) CheckGrounded(out RaycastHit groundHit)
{
bool onGround = CastSelf(transform.position, transform.rotation, Vector3.down, groundDist, out groundHit);
float angle = Vector3.Angle(groundHit.normal, Vector3.up);
return (onGround, angle);
}
///
/// Snap the player down if they are within a specific distance of the ground.
///
public void SnapPlayerDown()
{
bool closeToGround = CastSelf(
transform.position,
transform.rotation,
Vector3.down,
verticalSnapDown,
out RaycastHit groundHit);
// If within the threshold distance of the ground
if (closeToGround && groundHit.distance > 0)
{
// Snap the player down the distance they are from the ground
transform.position += Vector3.down * (groundHit.distance - KCCUtils.Epsilon * 2);
}
}
///
/// Move the player with a bounce and slide motion.
///
/// Movement of the player.
/// Final position of player after moving and bouncing.
public Vector3 MovePlayer(Vector3 movement)
{
Vector3 position = transform.position;
Quaternion rotation = transform.rotation;
Vector3 remaining = movement;
int bounces = 0;
while (bounces < maxBounces && remaining.magnitude > KCCUtils.Epsilon)
{
// Do a cast of the collider to see if an object is hit during this
// movement bounce
float distance = remaining.magnitude;
if (!CastSelf(position, rotation, remaining.normalized, distance, out RaycastHit hit))
{
// If there is no hit, move to desired position
position += remaining;
// Exit as we are done bouncing
break;
}
// If we are overlapping with something, just exit.
if (hit.distance == 0)
{
break;
}
float fraction = hit.distance / distance;
// Set the fraction of remaining movement (minus some small value)
position += remaining * (fraction);
// Push slightly along normal to stop from getting caught in walls
position += hit.normal * KCCUtils.Epsilon * 2;
// Decrease remaining movement by fraction of movement remaining
remaining *= (1 - fraction);
// Plane to project rest of movement onto
Vector3 planeNormal = hit.normal;
// Only apply angular change if hitting something
// Get angle between surface normal and remaining movement
float angleBetween = Vector3.Angle(hit.normal, remaining) - 90.0f;
// Normalize angle between to be between 0 and 1
// 0 means no angle, 1 means 90 degree angle
angleBetween = Mathf.Min(KCCUtils.MaxAngleShoveDegrees, Mathf.Abs(angleBetween));
float normalizedAngle = angleBetween / KCCUtils.MaxAngleShoveDegrees;
// Reduce the remaining movement by the remaining movement that ocurred
remaining *= Mathf.Pow(1 - normalizedAngle, anglePower) * 0.9f + 0.1f;
// Rotate the remaining movement to be projected along the plane
// of the surface hit (emulate pushing against the object)
Vector3 projected = Vector3.ProjectOnPlane(remaining, planeNormal).normalized * remaining.magnitude;
// If projected remaining movement is less than original remaining movement (so if the projection broke
// due to float operations), then change this to just project along the vertical.
if (projected.magnitude + KCCUtils.Epsilon < remaining.magnitude)
{
remaining = Vector3.ProjectOnPlane(remaining, Vector3.up).normalized * remaining.magnitude;
}
else
{
remaining = projected;
}
// Track number of times the character has bounced
bounces++;
}
// We're done, player was moved as part of loop
return position;
}
///
/// Cast self in a given direction and get the first object hit.
///
/// Position of the object when it is being raycast.
/// Rotation of the objecting when it is being raycast.
/// Direction of the raycast.
/// Maximum distance of raycast.
/// First object hit and related information, will have a distance of Mathf.Infinity if none
/// is found.
/// True if an object is hit within distance, false otherwise.
public bool CastSelf(Vector3 pos, Quaternion rot, Vector3 dir, float dist, out RaycastHit hit)
{
// Get Parameters associated with the KCC
Vector3 center = rot * capsuleCollider.center + pos;
float radius = capsuleCollider.radius;
float height = capsuleCollider.height;
// Get top and bottom points of collider
Vector3 bottom = center + rot * Vector3.down * (height / 2 - radius);
Vector3 top = center + rot * Vector3.up * (height / 2 - radius);
// Check what objects this collider will hit when cast with this configuration excluding itself
IEnumerable hits = Physics.CapsuleCastAll(
top, bottom, radius, dir, dist, ~0, QueryTriggerInteraction.Ignore)
.Where(hit => hit.collider.transform != transform);
bool didHit = hits.Count() > 0;
// Find the closest objects hit
float closestDist = didHit ? Enumerable.Min(hits.Select(hit => hit.distance)) : 0;
IEnumerable closestHit = hits.Where(hit => hit.distance == closestDist);
// Get the first hit object out of the things the player collides with
hit = closestHit.FirstOrDefault();
// Return if any objects were hit
return didHit;
}
}
}