// 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;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using nickmaltbie.OpenKCC.Character;
using nickmaltbie.OpenKCC.Environment.Pushable;
using nickmaltbie.OpenKCC.Tests.TestCommon;
using nickmaltbie.OpenKCC.Utils;
using nickmaltbie.OpenKCC.Utils.ColliderCast;
using nickmaltbie.TestUtilsUnity.Tests.TestCommon;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.ProBuilder;
using UnityEngine.TestTools;
namespace nickmaltbie.OpenKCC.Tests.PlayMode.Utils
{
///
/// Test basic movement scenarios for KCC Scenarios.
///
public class KCCUtilsScenarioTests : TestBase
{
///
/// Position fo the player.
///
public Transform playerPosition;
///
/// Collider cast associated with the player.
///
public CapsuleColliderCast playerColliderCast;
///
/// Character push associated with the player.
///
public ICharacterPush characterPush;
///
/// Create a KCCConfig with default parameters.
///
///
public KCCConfig CreateKCCConfig() => new KCCConfig
{
MaxBounces = 5,
VerticalSnapUp = 0.3f,
StepUpDepth = 0.1f,
AnglePower = 1,
CanSnapUp = true,
Up = Vector3.up,
ColliderCast = playerColliderCast,
};
///
/// Setup and create game objects for the test.
///
[SetUp]
public void SetUp()
{
GameObject character = CreateGameObject();
playerPosition = character.transform;
playerColliderCast = character.AddComponent();
playerColliderCast.center = new Vector3(0, 1, 0);
playerColliderCast.radius = 0.5f;
playerColliderCast.height = 2.0f;
playerColliderCast.SetupCollider();
Mesh capsuleMesh = CapsuleMaker.CapsuleData(depth: 1.0f);
capsuleMesh.vertices = capsuleMesh.vertices.Select(vert => vert + Vector3.up).ToArray();
MeshFilter meshFilter = character.AddComponent();
meshFilter.mesh = capsuleMesh;
MeshRenderer mr = character.AddComponent();
mr.material = new Material(Shader.Find("Standard"));
}
///
/// Get the bounces for character movement from current position.
///
/// movement of teh player
/// Configuration for the movement.
/// Character bounces for player given current movement.
public IEnumerable GetBounces(Vector3 movement, IKCCConfig kccConfig = null)
{
kccConfig = kccConfig ?? CreateKCCConfig();
return KCCUtils.GetBounces(
playerPosition.position,
movement,
playerPosition.rotation,
kccConfig);
}
///
/// Generate basic set of movements.
///
/// Set of 9 possible movements.
public static IEnumerable MovementGenerator()
{
return new[]
{
Vector3.right,
Vector3.left,
Vector3.back,
Vector3.forward,
Vector3.up,
Vector3.down,
};
}
///
/// Basic test of player walking forward.
///
[Test]
[TestCaseSource(nameof(MovementGenerator))]
public void TestWalkForward(Vector3 movement)
{
// Get the bounces from moving forward.
var bounces = GetBounces(movement).ToList();
// Assert that there are two bounces, one from moving forward and one from stepping down.
Assert.IsTrue(bounces.Count == 2, $"Expected to find 2 bounces, but instead found {bounces.Count}");
// Validate bounce states
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Move, movement, playerPosition.position, Vector3.zero, movement);
KCCValidation.ValidateKCCBounce(bounces[1], KCCUtils.MovementAction.Stop);
}
///
/// Basic test of player walking forward.
///
[UnityTest]
public IEnumerator TestPushBox([Values(3, 5)] float distance, [Values(true, false)] bool isKinematic, [Values(true, false)] bool isPushable, [Values(true, false)] bool addRigidbody)
{
// Setup object to walk into
var pushable = GameObject.CreatePrimitive(PrimitiveType.Cube);
pushable.transform.position = Vector3.forward * distance + Vector3.up * 0.5f;
if (isPushable)
{
pushable.AddComponent();
}
if (addRigidbody)
{
pushable.AddComponent();
}
Rigidbody rigidbody = pushable.GetComponent();
if (rigidbody != null)
{
rigidbody.isKinematic = isKinematic;
rigidbody.useGravity = false;
}
RegisterGameObject(pushable);
yield return null;
yield return new WaitForFixedUpdate();
// Have character walk forward into object
var bounces = GetBounces(Vector3.forward * (distance + 2)).ToList();
// Validate bounce actions
// Assert that character will bounce, then stop
Assert.IsTrue(bounces.Count >= 2, $"Expected to find at least {2} bounces, but instead found {bounces.Count}");
KCCBounce lastBounce = bounces[bounces.Count - 1];
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Bounce);
KCCValidation.ValidateKCCBounce(lastBounce, KCCUtils.MovementAction.Stop);
// Assert that the box has some force added to it if it's dynamic
bool canPush = !isKinematic && rigidbody != null && pushable.GetComponent() != null;
if (canPush && rigidbody != null)
{
Assert.IsTrue(rigidbody.velocity.magnitude >= 0, $"Expected box to have some added force");
}
else if (rigidbody != null)
{
Assert.IsTrue(rigidbody.velocity.magnitude == 0, $"Expected box to not have any added force");
}
}
///
/// Basic test of player walking into an object.
///
[UnityTest]
public IEnumerator TestWalkIntoWall([NUnit.Framework.Range(5, 20, 5)] float distance)
{
// Setup object to walk into
var wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
wall.transform.position = Vector3.forward * (distance - 2) + Vector3.up * 0.5f;
RegisterGameObject(wall);
yield return null;
yield return new WaitForFixedUpdate();
// Have character walk into wall
var bounces = GetBounces(Vector3.forward * distance).ToList();
// Validate bounce actions
Assert.IsTrue(bounces.Count >= 2, $"Expected to find at least {2} bounces, but instead found {bounces.Count}");
KCCBounce lastBounce = bounces[bounces.Count - 1];
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Bounce);
KCCValidation.ValidateKCCBounce(lastBounce, KCCUtils.MovementAction.Stop);
Debug.Log($"initialPosition: {bounces[0].initialPosition}, finalPosition: {bounces[0].finalPosition}, movement: {bounces[0].Movement}");
TestUtils.AssertInBounds(bounces[0].Movement, Vector3.forward * (distance - 3), 0.1f);
TestUtils.AssertInBounds(lastBounce.finalPosition, bounces[0].finalPosition, 0.1f);
}
///
/// Evaluate and test for player jitter when moving against an angled wall.
/// When the player hits the wall, their consecutive movements should NOT
/// make them jitter back and forward due to sliding between the angle.
///
/// Angle of wall to test player jitter against.
/// Enumerator of unity events for the test.
[UnityTest]
public IEnumerator TestKCCMovementJitterOnAngle([NUnit.Framework.Range(15, 179, 15)] float angle, [Values(10, 20)] float wallLength)
{
// Setup a wall at the given angle
ProBuilderMesh angledWall1 = ShapeGenerator.GenerateCube(PivotLocation.FirstCorner, new Vector3(0.1f, 2, wallLength));
ProBuilderMesh angledWall2 = ShapeGenerator.GenerateCube(PivotLocation.FirstCorner, new Vector3(0.1f, 2, wallLength));
angledWall1.GetComponent().material = new Material(Shader.Find("Standard"));
angledWall2.GetComponent().material = new Material(Shader.Find("Standard"));
angledWall1.gameObject.AddComponent();
angledWall2.gameObject.AddComponent();
angledWall1.transform.position = new Vector3(-0.1f, 0, wallLength);
angledWall2.transform.position = new Vector3(-0.1f, 0, wallLength);
angledWall1.transform.rotation = Quaternion.Euler(0, 180 - angle / 2, 0);
angledWall2.transform.rotation = Quaternion.Euler(0, 180 + angle / 2, 0);
RegisterGameObject(angledWall1.gameObject);
RegisterGameObject(angledWall2.gameObject);
yield return null;
yield return new WaitForFixedUpdate();
// First time player moves, they should move forward and bounce off the first angled wall
// Then they will slide into the second wall, and stop.
var bounces = GetBounces(Vector3.forward * wallLength).ToList();
Assert.IsTrue(bounces.Count >= 2, $"Was expecting to find at least {2} bounces but instead found {bounces.Count}");
foreach (KCCBounce bounce in bounces.AsEnumerable().Reverse().Skip(1))
{
Assert.IsTrue(
bounce.action == KCCUtils.MovementAction.Bounce ||
bounce.action == KCCUtils.MovementAction.Move,
$"Expected to find action {KCCUtils.MovementAction.Bounce} or {KCCUtils.MovementAction.Move} but instead found {bounce.action}");
}
KCCValidation.ValidateKCCBounce(bounces[bounces.Count - 1], KCCUtils.MovementAction.Stop);
Vector3 deltaMove = bounces[bounces.Count - 1].finalPosition - playerPosition.position;
playerPosition.position = bounces[bounces.Count - 1].finalPosition;
// Consider player complete when they have less than 0.01 forward movement
// And they are 90% of the way forward
int moves = 0;
Func complete = () => moves > 1000 ||
(deltaMove.z <= 0.01f &&
bounces[bounces.Count - 1].finalPosition.z - wallLength <= wallLength / 10);
// The player should continue to move forward and either bounce
// or stop but never move backwards and start "jittering"
while (bounces.Count > 1 && !complete())
{
bounces = GetBounces(Vector3.forward * wallLength).ToList();
deltaMove = bounces[bounces.Count - 1].finalPosition - playerPosition.position;
playerPosition.position = bounces[bounces.Count - 1].finalPosition;
// Assert all actions are bounces and that those bounces are forward
// Except the last one.
foreach (KCCBounce bounce in bounces.AsEnumerable().Reverse().Skip(1))
{
Assert.IsTrue(
Vector3.Dot(bounce.Movement, Vector3.forward) >= 0,
$"Player must mover in forward direction but instead moved in direction {bounce.Movement.ToString("F3")}");
Assert.IsTrue(
bounce.action == KCCUtils.MovementAction.Bounce ||
bounce.action == KCCUtils.MovementAction.Move,
$"Expected to find action {KCCUtils.MovementAction.Bounce} or {KCCUtils.MovementAction.Move} but instead found {bounce.action}");
}
moves++;
}
// Assert that the last action was just a stop.
Assert.IsTrue(moves <= 1000, "Expected player to reach end of angle before 1000 bounces");
KCCValidation.ValidateKCCBounce(bounces[bounces.Count - 1], KCCUtils.MovementAction.Stop);
}
///
/// Test that the player will not move when overlapping with objects.
///
/// Enumerator of unity events for the test.
[UnityTest]
public IEnumerator PushOutOverlapping()
{
// Setup object to overlap with
var wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
RegisterGameObject(wall);
// Setup random number generator
UnityEngine.Random.InitState(42);
for (int i = 0; i < 30; i++)
{
Vector3 offset = UnityEngine.Random.insideUnitCircle * 10;
// Randomize the cube position and rotation then update state
wall.transform.position = UnityEngine.Random.insideUnitSphere * 0.75f + Vector3.up + offset;
wall.transform.rotation = UnityEngine.Random.rotation;
playerPosition.transform.position = offset;
yield return null;
yield return new WaitForFixedUpdate();
// have player attempt to move, they should overlap with object and exit early
var bounces = GetBounces(Vector3.forward).ToList();
Assert.IsTrue(bounces.Count == 2, $"Expected to find 2 bounces, but instead found {bounces.Count}");
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Bounce, initialPosition: playerPosition.position, finalPosition: playerPosition.position, remainingMomentum: Vector3.zero, initialMomentum: Vector3.forward, log: false);
KCCValidation.ValidateKCCBounce(bounces[1], KCCUtils.MovementAction.Stop, initialPosition: playerPosition.position, finalPosition: playerPosition.position, remainingMomentum: Vector3.zero, log: false);
}
}
[UnityTest]
public IEnumerator TestWalkUpStairs(
[NUnit.Framework.Range(0.1f, 0.65f, 0.25f)] float stepHeight,
[Values(2)] float width,
[NUnit.Framework.Range(5, 10, 5)] int numSteps,
[NUnit.Framework.Range(0.15f, 0.55f, 0.2f)] float stepDepth,
[Values(new float[] { 0.125f, 0.325f, 0.1f })] float[] snapHeightRange,
[Values(new float[] { 0.25f, 0.75f, 0.25f })] float[] snapDepthRange)
{
// Create stair object for player to walk into
// Position stairs 1 units ahead of the player and centered
var size = new Vector3(width, stepHeight * numSteps, stepDepth * numSteps);
ProBuilderMesh stairBuilder = ShapeGenerator.GenerateStair(PivotLocation.FirstCorner, size, numSteps, false);
stairBuilder.GetComponent().material = new Material(Shader.Find("Standard"));
stairBuilder.transform.position = Vector3.forward + Vector3.left * width / 2;
stairBuilder.gameObject.AddComponent();
RegisterGameObject(stairBuilder.gameObject);
// Put a small platform after the stairs
ProBuilderMesh platform = ShapeGenerator.GeneratePlane(PivotLocation.FirstCorner, 5, width, 1, 1, Axis.Up);
platform.transform.position = stairBuilder.transform.position + new Vector3(0, size.y, size.z);
platform.GetComponent().material = new Material(Shader.Find("Standard"));
platform.gameObject.AddComponent();
RegisterGameObject(platform.gameObject);
yield return null;
yield return new WaitForFixedUpdate();
// Run a test for each permutation of snap height and snap depth
var permutations = new List<(float, float)>();
for (float snapHeight = snapHeightRange[0]; snapHeight <= snapHeightRange[1]; snapHeight += snapHeightRange[2])
{
for (float snapDepth = snapDepthRange[0]; snapDepth <= snapDepthRange[1]; snapDepth += snapDepthRange[2])
{
permutations.Add((snapHeight, snapDepth));
}
}
foreach ((float snapHeight, float snapDepth) in permutations)
{
// Player should bounce forward and have at least first step be a bounce up
KCCConfig config = CreateKCCConfig();
config.MaxBounces = Mathf.Min(5);
config.StepUpDepth = snapDepth;
config.VerticalSnapUp = snapHeight;
config.CanSnapUp = true;
// Reset player position
playerPosition.position = Vector3.zero;
// Check if the player can climb steps, step height should be <= snap up stair step depth should be >= snap depth
// Edge case, player can walk up steps if player can take multiple steps in one snap
int maxStepsAtOnce = (int)Mathf.Floor(snapHeight / stepHeight);
float maxStepsDepth = maxStepsAtOnce * stepDepth;
bool canClimb = stepHeight <= config.VerticalSnapUp && (stepDepth >= config.StepUpDepth || maxStepsDepth >= config.StepUpDepth);
Debug.Log($"Computed canClimb:{canClimb} = stepHeight:{stepHeight} <= config.verticalSnapUp:{config.VerticalSnapUp} && stepDepth:{stepDepth} >= config.stepUpDepth:{config.StepUpDepth}");
// Have player move forward until they hit the top of the steps
Func climbedSteps = () => canClimb &&
playerPosition.transform.position.z >= stairBuilder.transform.position.z + size.z - config.SkinWidth &&
playerPosition.transform.position.y >= stairBuilder.transform.position.y + size.y - config.SkinWidth;
// Move forward 3 units
Vector3 movement = Vector3.forward * 3;
// If the player cannot climb up, check to make sure they bounce then stop
if (!canClimb)
{
var bounces = GetBounces(movement, config).ToList();
playerPosition.transform.position = bounces[bounces.Count - 1].finalPosition;
// Validate bounce actions
Assert.IsTrue(bounces.Count >= 2, $"Expected to find at least {2} bounces, but instead found {bounces.Count}");
KCCBounce lastBounce = bounces[bounces.Count - 1];
// Assert that no bounces were snap up
bounces.ForEach(bounce => Assert.IsTrue(bounce.action != KCCUtils.MovementAction.SnapUp, $"Found unexpected {KCCUtils.MovementAction.SnapUp} while player should not able to walk up stairs"));
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Bounce);
KCCValidation.ValidateKCCBounce(lastBounce, KCCUtils.MovementAction.Stop);
}
// If the player can climb the steps, have them move until they climb up all the steps
int snapUpCount = 0;
int count = 0;
while (canClimb && !climbedSteps())
{
var bounces = GetBounces(movement, config).ToList();
// Move player to final position
playerPosition.transform.position = bounces[bounces.Count - 1].finalPosition;
// Expected to find at least three bounces
Assert.IsTrue(bounces.Count >= 2, $"Expected to find at least three bounces, but instead found {bounces.Count}");
// First bounces should all be a "StepUp" action
// Last bounce should be a "Stop" action
// Some steps may take more than one bounce to scale so will let that happen
IEnumerable stepUpBounces = Enumerable.Range(0, bounces.Count - 2).Select(index => bounces[index]);
// Validate the sets of bounces
foreach (KCCBounce stepUp in stepUpBounces)
{
// Movement should also be forward and step up action
Assert.IsTrue(
stepUp.action == KCCUtils.MovementAction.SnapUp || stepUp.action == KCCUtils.MovementAction.Bounce,
$"Expected to find action {KCCUtils.MovementAction.SnapUp} or {KCCUtils.MovementAction.Bounce} but instead found {stepUp.action}");
Assert.IsTrue(Vector3.Dot(
stepUp.Movement, Vector3.forward) >= 0.0f,
$"Expected player movement to be forward but instead found {stepUp.Movement.ToString("F3")}");
if (stepUp.action == KCCUtils.MovementAction.SnapUp)
{
snapUpCount++;
}
}
// If we have finished climbing the steps, validate move and stop bounces
if (climbedSteps())
{
KCCBounce stopBounce = bounces[bounces.Count - 1];
KCCValidation.ValidateKCCBounce(stopBounce, KCCUtils.MovementAction.Stop);
TestUtils.AssertInBounds(stopBounce.finalPosition.y, size.y, stepHeight);
TestUtils.AssertInBounds(stopBounce.finalPosition.z, size.z, 6, bound: TestUtils.BoundRange.GraterThan);
Assert.IsTrue(climbedSteps());
Assert.IsTrue(snapUpCount > 0, $"Expected player to snap up but did not find any snap up events.");
}
yield return null;
count++;
if (count > 1000)
{
Assert.Fail();
}
}
}
}
///
/// Basic test of player sliding off wall.
///
[UnityTest]
public IEnumerator TestSlideOffWall([NUnit.Framework.Range(15, 60, 15)] float yaw, [NUnit.Framework.Range(15, 60, 15)] float pitch)
{
// Setup object to walk into
var wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
wall.transform.position = Vector3.forward * 3 + Vector3.up * 0.5f;
wall.transform.rotation = Quaternion.Euler(pitch, yaw, 0);
RegisterGameObject(wall);
yield return null;
yield return new WaitForFixedUpdate();
// Have character walk into wall
Vector3 movement = Vector3.forward * 5;
var bounces = GetBounces(movement).ToList();
// Validate bounce actions
Assert.IsTrue(bounces.Count == 3, $"Expected to find {3} bounces, but instead found {bounces.Count}");
KCCValidation.ValidateKCCBounce(bounces[0], KCCUtils.MovementAction.Bounce);
KCCValidation.ValidateKCCBounce(bounces[1], KCCUtils.MovementAction.Move);
KCCValidation.ValidateKCCBounce(bounces[2], KCCUtils.MovementAction.Stop);
TestUtils.AssertInBounds(bounces[0].Movement, Vector3.forward * 2, 1.0f);
}
}
}