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