// 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.Environment.MovingGround;
using nickmaltbie.OpenKCC.Tests.TestCommon;
using nickmaltbie.OpenKCC.Utils;
using nickmaltbie.OpenKCC.Utils.ColliderCast;
using nickmaltbie.TestUtilsUnity.Tests.TestCommon;
using NUnit.Framework;
using UnityEngine;
namespace nickmaltbie.OpenKCC.Tests.EditMode.Utils
{
public class MovingGroundComponent : MonoBehaviour, IMovingGround
{
public bool avoidTransferMomentum = false;
public bool shouldAttach = true;
public float movementWeight = 1.0f;
public float transferMomentumWeight = 1.0f;
public Vector3 velocity;
public bool AvoidTransferMomentum()
{
return avoidTransferMomentum;
}
public float GetMovementWeight(Vector3 point, Vector3 playerVelocity)
{
return movementWeight;
}
public float GetTransferMomentumWeight(Vector3 point, Vector3 playerVelocity)
{
return transferMomentumWeight;
}
public Vector3 GetVelocityAtPoint(Vector3 point)
{
return velocity;
}
public bool ShouldAttach()
{
return shouldAttach;
}
}
///
/// Basic tests for KCCUtils in edit mode.
///
[TestFixture]
public class KCCUtilsTests : TestBase
{
///
/// Collider cast mock used for testing.
///
public MockColliderCast colliderCastMock;
///
/// Mock for controlling character pushes used for testing.
///
public MockCharacterPush characterPushMock;
///
/// Setup test with basic collider cast mock.
///
[SetUp]
public void SetUp()
{
GameObject go = CreateGameObject();
colliderCastMock = go.AddComponent();
characterPushMock = new MockCharacterPush();
}
///
/// Test maximum bounces by having the object hit something multiple times.
///
[Test]
public void Validate_KCCMaxBouncesTest([Values(0, 1, 5, 10)] int maxBounces)
{
// Have the object hit some collider and not move at all
Vector3 initialPosition = Vector3.zero;
Vector3 movement = Vector3.forward * 10;
// Have collider return hitting something... but it shouldn't be called due to no movement
SetupColliderCast(true, KCCTestUtils.SetupRaycastHitMock(distance: 0.1f, normal: (Vector3.right + Vector3.back).normalized));
// Simulate bounces
var bounces = GetBounces(initialPosition, movement, maxBounces: maxBounces, anglePower: 0.0f).ToList();
// Should hit max bounces
Debug.Log(string.Join("\n", bounces));
Assert.IsTrue(bounces.Count == maxBounces + 2, $"Expected to find {maxBounces + 2} bounce but instead found {bounces.Count}");
Enumerable.Range(0, maxBounces + 1).ToList().ForEach(idx => KCCValidation.ValidateKCCBounce(bounces[idx], KCCUtils.MovementAction.Bounce));
KCCValidation.ValidateKCCBounce(bounces[maxBounces + 1], KCCUtils.MovementAction.Stop);
}
///
/// Test maximum bounces by having the object overlap with another.
///
[Test]
public void Validate_KCCOverlap()
{
// Have the object hit some collider and not move at all
Vector3 initialPosition = Vector3.zero;
Vector3 movement = Vector3.forward;
// Have collider return hitting something... but it shouldn't be called due to no movement
SetupColliderCast(true, KCCTestUtils.SetupRaycastHitMock(distance: 0));
// Simulate bounces
var bounces = GetBounces(initialPosition, movement, anglePower: 0.0f).ToList();
// Should just return just one element with action stop
KCCValidation.ValidateKCCBounce(bounces[bounces.Count - 1], KCCUtils.MovementAction.Stop, finalPosition: initialPosition, remainingMomentum: Vector3.zero);
}
///
/// Test no movement from KCC.
///
[Test]
public void Validate_KCCNoMovement()
{
// Have collider return hitting something... but it shouldn't be called due to no movement
SetupColliderCast(false, KCCTestUtils.SetupRaycastHitMock());
// Simulate bounces
var bounces = GetBounces(Vector3.zero, Vector3.zero).ToList();
// Should just return just one element with action stop
Assert.IsTrue(bounces.Count == 1, $"Expected to find {1} bounce but instead found {bounces.Count}");
// Validate bounce properties
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Stop, Vector3.zero, Vector3.zero, Vector3.zero, Vector3.zero);
}
///
/// Validate the snap up behavior of the KCC second method
///
[Test]
public void Validate_KCCSecondSnapUpAction()
{
SetupColliderCast(new[]
{
// First hit should be simulating hitting a step slightly above foot position
(true, KCCTestUtils.SetupRaycastHitMock(distance: KCCUtils.Epsilon, point: Vector3.up * 0.05f)),
// Second hit should be simulating hitting a step and having to stop (hitting middle of step)
(true, KCCTestUtils.SetupRaycastHitMock(distance: KCCUtils.Epsilon, point: Vector3.up * 0.05f)),
// Next hit should not collide with anything as we are above the step
(false, KCCTestUtils.SetupRaycastHitMock(distance: 0.2f, point: Vector3.up * 0.1f)),
});
// Have the snap up simulate hitting a step that is slightly above feet and has a normal perpendicular to up
IRaycastHit wallCollision = KCCTestUtils.SetupRaycastHitMock(normal: Vector3.back, distance: float.Epsilon);
colliderCastMock.OnDoRaycastInDirection =
(Vector3 v1, Vector3 v2, float v3, out IRaycastHit hit, int v4, QueryTriggerInteraction v5) =>
{
hit = wallCollision;
return true;
};
colliderCastMock.OnGetBottom = (Vector3, Quaternion) => Vector3.zero;
// Simulate bounces
var bounces = GetBounces(Vector3.zero, Vector3.forward * 5, stepUpDepth: KCCUtils.Epsilon).ToList();
// Validate bounce properties
Assert.IsTrue(bounces.Count >= 2, $"Expected to find at least {2} bounce but instead found {bounces.Count}");
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.SnapUp);
Assert.IsTrue(
bounces[0].remainingMomentum.magnitude >= 0,
$"Expected remaining momentum to be grater than zero, but instead found {bounces[0].remainingMomentum.magnitude}.");
}
///
/// Validate the snap up behavior of the KCC Bounce method
///
[Test]
public void Validate_KCCSnapUpAction([NUnit.Framework.Range(0.1f, 2f, 0.1f)] float snapUpDistance)
{
SetupColliderCast(new[]
{
// First hit should be simulating hitting a step slightly above foot position
(true, KCCTestUtils.SetupRaycastHitMock(distance: 0.1f, point: Vector3.up * snapUpDistance / 2, normal: Vector3.back)),
// Next hit should not collide with anything as we are above the step
(false, KCCTestUtils.SetupRaycastHitMock()),
});
// Have the snap up simulate hitting a step that is slightly above feet and has a normal perpendicular to up
IRaycastHit wallCollision = KCCTestUtils.SetupRaycastHitMock(normal: Vector3.back, distance: float.Epsilon);
colliderCastMock.OnDoRaycastInDirection =
(Vector3 v1, Vector3 v2, float v3, out IRaycastHit hit, int v4, QueryTriggerInteraction v5) =>
{
hit = wallCollision;
return true;
};
colliderCastMock.OnGetBottom = (Vector3 v1, Quaternion v2) => Vector3.zero;
// Simulate bounces
var bounces = GetBounces(Vector3.zero, Vector3.forward * 10, verticalSnapUp: snapUpDistance).ToList();
// Validate bounce properties
Assert.IsTrue(bounces.Count == 3, $"Expected to find {3} bounce but instead found {bounces.Count}");
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.SnapUp);
KCCValidation.ValidateKCCBounce(bounces[1], KCCUtils.MovementAction.Move);
KCCValidation.ValidateKCCBounce(bounces[2], KCCUtils.MovementAction.Stop);
}
///
/// Test kcc hit nothing.
///
[Test]
[TestCaseSource(nameof(TestDirections))]
public void Validate_KCCMoveUnblocked(Vector3 movement)
{
Vector3 initialPosition = Vector3.zero;
Vector3 expectedFinalPosition = initialPosition + movement;
// Have collider return hitting something... but it shouldn't be called due to no movement
SetupColliderCast(false, new MockRaycastHit());
// Simulate bounces
var bounces = GetBounces(initialPosition, movement).ToList();
// Validate bounce properties
if (movement.magnitude > 0)
{
Assert.IsTrue(bounces.Count == 2, $"Expected to find {2} bounce but instead found {bounces.Count}");
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Move, expectedFinalPosition, initialPosition, Vector3.zero, movement);
}
else
{
Assert.IsTrue(bounces.Count == 1, $"Expected to find {1} bounce but instead found {bounces.Count}");
}
KCCValidation.ValidateKCCBounce(bounces.Last(), KCCUtils.MovementAction.Stop, expectedFinalPosition, expectedFinalPosition, Vector3.zero, Vector3.zero);
}
///
/// Verify that player will snap down as expected.
///
[Test]
public void Verify_KCCSnapPlayerDown()
{
SetupColliderCast(true, KCCTestUtils.SetupRaycastHitMock(null, Vector3.zero, Vector3.up, 0.01f));
Vector3 displacement = KCCUtils.SnapPlayerDown(Vector3.zero, Quaternion.identity, Vector3.down, 0.1f, colliderCastMock, new KCCConfig());
Assert.IsTrue(displacement.magnitude > 0.0f, $"Expected displacement to have a magnitude grater than zero but instead found {displacement.ToString("F3")}");
}
///
/// Verify that invalid projected momentum will retain its original magnitude
///
/// Input player movement for invalid movement value.
[Test]
[TestCaseSource(nameof(TestDirections))]
public void Verify_KCCInvalidProjectedMomentum(Vector3 move)
{
Vector3 projected = KCCUtils.GetBouncedMomentumSafe(move, Vector3.forward, Vector3.up);
Assert.IsTrue((move.magnitude - projected.magnitude) <= 0.001f, $"Expected projected vector to have magnitude of {move.magnitude} but instead found {projected.magnitude}");
}
///
/// Validate player won't bounce backwards when they hit a wall
/// and could slide backwards of original direction.
///
/// Distance the player should move forward.
[Test]
public void Verify_KCCNoJitterBackwards([Values(5, 10)] float distance)
{
// Have first hit hit a pushable object
SetupColliderCast(new[]
{
// First hit should be simulating hitting an object and sliding to the left
(true, KCCTestUtils.SetupRaycastHitMock(
distance: distance / 5,
normal: (Vector3.back * 2 + Vector3.left).normalized)),
// Next hit should simulate hitting another wall and sliding back
(true, KCCTestUtils.SetupRaycastHitMock(
distance: distance / 5,
normal: (Vector3.back * 2 + Vector3.right).normalized)),
});
// Simulate bounces
var bounces = GetBounces(Vector3.zero, Vector3.forward * distance, anglePower: 1, canSnapUp: false).ToList();
Debug.Log(string.Join("\n", bounces));
// Validate bounce properties, should bounce once then stop before
// moving backwards.
Assert.IsTrue(bounces.Count == 3, $"Expected to find {3} bounce but instead found {bounces.Count}");
ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Bounce);
ValidateKCCBounce(bounces[1], KCCUtils.MovementAction.Bounce);
ValidateKCCBounce(bounces[2], KCCUtils.MovementAction.Stop);
foreach (KCCBounce bounce in bounces.AsEnumerable().Reverse().Skip(1))
{
Assert.IsTrue(
Vector3.Dot(bounce.Movement, Vector3.forward) >= 0,
$"Expected player to move forward but instead found movement {bounce.Movement.ToString("F3")}");
}
}
///
/// Test the KCC Pushing a kinematic or no dynamic object.
///
[Test]
public void Validate_KCCPush()
{
// Have first hit hit a pushable object
SetupColliderCast(new[]
{
// First hit should be simulating hitting a a pushable object
(true, KCCTestUtils.SetupRaycastHitMock(
collider: null,
distance: 0.1f,
normal: (Vector3.back + Vector3.left).normalized)),
// Next hit should not collide with anything as we are above the step
(false, KCCTestUtils.SetupRaycastHitMock()),
});
characterPushMock.OnCanPushObject = _ => true;
characterPushMock.OnPushObject = _ => { };
// Simulate bounces
var bounces = GetBounces(Vector3.zero, Vector3.forward).ToList();
// Validate bounce properties
Assert.IsTrue(bounces.Count == 3, $"Expected to find {3} bounce but instead found {bounces.Count}");
ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Bounce);
ValidateKCCBounce(bounces[1], KCCUtils.MovementAction.Move);
ValidateKCCBounce(bounces[2], KCCUtils.MovementAction.Stop);
}
///
/// Validate a KCC bounce for a specified set of properties.
///
/// Bounce to validate.
/// Expected movement action, or null if not required to check.
/// Expected final position, or null if not required to check.
/// Expected initial position, or null if not required to check.
/// Expected remaining momentum, or null if not required to check.
/// Expected initial momentum, or null if not required to check.
public void ValidateKCCBounce(
KCCBounce bounce,
KCCUtils.MovementAction? movementAction = null,
Vector3? finalPosition = null,
Vector3? initialPosition = null,
Vector3? remainingMomentum = null,
Vector3? initialMomentum = null)
{
Debug.Log($"Evaluating bounce {bounce} for properties finalPosition:{finalPosition}, initialPosition:{initialPosition}, remainingMomentum:{remainingMomentum}, initialMomentum:{initialMomentum}");
Assert.IsTrue(movementAction == null || bounce.action == movementAction, $"Expected {nameof(bounce.action)} to be {movementAction} but instead found {bounce.action}");
Assert.IsTrue(finalPosition == null || bounce.finalPosition == finalPosition, $"Expected {nameof(bounce.finalPosition)} to be {finalPosition} but instead found {bounce.finalPosition}");
Assert.IsTrue(initialPosition == null || bounce.initialPosition == initialPosition, $"Expected {nameof(bounce.initialPosition)} to be {initialPosition} but instead found {bounce.initialPosition}");
Assert.IsTrue(remainingMomentum == null || bounce.remainingMomentum == remainingMomentum, $"Expected {nameof(bounce.remainingMomentum)} to be {remainingMomentum} but instead found {bounce.remainingMomentum}");
Assert.IsTrue(initialMomentum == null || bounce.initialMomentum == initialMomentum, $"Expected {nameof(bounce.initialMomentum)} to be {initialMomentum} but instead found {bounce.initialMomentum}");
}
private delegate void RaycastHitCallback(Vector3 pos, Quaternion rot, Vector3 dir, float dist, out IRaycastHit hit, int layerMask, QueryTriggerInteraction queryTriggerInteraction);
private delegate void RaycastHitReturns(out IRaycastHit hit);
///
/// Setup the collider cast for a given set of hits in a specific order.
///
/// Enumerable set of didHit and raycastHit in the order they should be returned.
public void SetupColliderCast(IEnumerable<(bool, IRaycastHit)> hitData)
{
IEnumerator<(bool, IRaycastHit)> hitEnumerator = hitData.GetEnumerator();
(bool, IRaycastHit) nextHit = (false, null);
colliderCastMock.OnCastSelf = (Vector3 pos, Quaternion rot, Vector3 dir, float dist, out IRaycastHit hit, int layetMask, QueryTriggerInteraction query, float skinWidth) =>
{
hit = nextHit.Item2;
if (hitEnumerator.MoveNext())
{
nextHit = hitEnumerator.Current;
hit = nextHit.Item2;
}
return nextHit.Item1;
};
}
///
/// Setup the collider cast.
///
/// Has this collided with anything.
/// Raycast hit object to return.
public void SetupColliderCast(bool didHit, IRaycastHit raycastHit)
{
colliderCastMock.OnCastSelf = (Vector3 pos, Quaternion rot, Vector3 dir, float dist, out IRaycastHit hit, int layetMask, QueryTriggerInteraction query, float skinWidth) =>
{
hit = raycastHit;
return didHit;
};
}
///
/// 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.
/// Maximum bounces when moving the player.
/// Vertical snap up distance the player can snap up.
/// Minimum depth required for a stair when moving onto a step.
/// Angle power for decaying momentum when bouncing off a surface.
/// Can the player snap up steps.
/// Up direction relative to the player.
/// Collider cast for checking what the player is colliding with.
/// Character push for checking fi the character should push objects.
/// Bounces that the player makes when hitting objects as part of it's movement.
private IEnumerable GetBounces(
Vector3 position,
Vector3 movement,
Quaternion? rotation = null,
int maxBounces = 5,
float verticalSnapUp = 0.1f,
float stepUpDepth = 0.1f,
float anglePower = 0.9f,
bool canSnapUp = true,
Vector3? up = null,
IColliderCast colliderCast = null,
ICharacterPush push = null)
{
rotation = rotation ?? Quaternion.Euler(Vector3.zero);
up = up ?? Vector3.up;
colliderCast = colliderCast ?? colliderCastMock;
_ = push ?? characterPushMock;
return KCCUtils.GetBounces(
position,
movement,
rotation.Value,
new KCCConfig
{
MaxBounces = maxBounces,
VerticalSnapUp = verticalSnapUp,
StepUpDepth = stepUpDepth,
AnglePower = anglePower,
CanSnapUp = canSnapUp,
Up = up.Value,
ColliderCast = colliderCast,
}
);
}
}
}