using Sandbox.Movement;
using System;
namespace SceneTests.Components;
///
/// Pins the PlayerController contract that's reachable without live input: the
/// rigidbody/collider rig it builds on awake, body dimensions, grounding against a
/// static floor, WishVelocity driven movement, jumping, ducking, the move-mode
/// state machine and serialization. Anything needing real Input is out of scope.
///
[TestClass]
public class PlayerControllerTest
{
///
/// Creates a large static floor whose top surface sits at z = 0.
///
static GameObject CreateFloor( Scene scene )
{
var go = scene.CreateObject();
go.Name = "Floor";
go.WorldPosition = new Vector3( 0, 0, -50 );
var box = go.Components.Create();
box.Scale = new Vector3( 4000, 4000, 100 );
box.Static = true;
return go;
}
///
/// Creates a PlayerController at the given position, configured for headless
/// ticking: input controls off (there's no input to read) and footstep sounds
/// off (no audio assets mounted).
///
static PlayerController CreatePlayer( Scene scene, Vector3 position )
{
var go = scene.CreateObject();
go.Name = "Player";
go.WorldPosition = position;
var pc = go.Components.Create();
pc.UseInputControls = false;
pc.EnableFootstepSounds = false;
return pc;
}
///
/// Ticks the scene until the controller reports ground, bounded so a regression
/// fails fast instead of hanging.
///
static void TickUntilGrounded( Scene scene, PlayerController pc, int maxTicks = 60 )
{
for ( int i = 0; i < maxTicks && !pc.IsOnGround; i++ )
{
scene.GameTick();
}
}
///
/// Creating the component builds the whole rig: a hidden Rigidbody on the same
/// object, a hidden "Colliders" child holding the capsule and feet colliders,
/// a walk move mode, and a body locked against rotation with the mass override.
///
[TestMethod]
public void CreationBuildsBodyAndColliders()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var pc = go.Components.Create();
// rigidbody auto-created on the same object and hidden by default
Assert.IsTrue( pc.Body.IsValid() );
Assert.AreEqual( go, pc.Body.GameObject );
Assert.IsTrue( pc.Body.Flags.HasFlag( ComponentFlags.Hidden ) );
// colliders live on a hidden "Colliders" child object
Assert.IsTrue( pc.ColliderObject.IsValid() );
Assert.AreEqual( "Colliders", pc.ColliderObject.Name );
Assert.AreEqual( go, pc.ColliderObject.Parent );
Assert.IsTrue( pc.ColliderObject.Flags.HasFlag( GameObjectFlags.Hidden ) );
Assert.IsTrue( pc.BodyCollider.IsValid() );
Assert.AreEqual( pc.ColliderObject, pc.BodyCollider.GameObject );
Assert.IsTrue( pc.FeetCollider.IsValid() );
Assert.AreEqual( pc.ColliderObject, pc.FeetCollider.GameObject );
// the walk move mode is installed and selected on awake
Assert.IsTrue( pc.Mode is MoveModeWalk );
// the body is set up for character use
Assert.IsTrue( pc.Body.Locking.Pitch );
Assert.IsTrue( pc.Body.Locking.Yaw );
Assert.IsTrue( pc.Body.Locking.Roll );
Assert.AreEqual( 500f, pc.Body.MassOverride );
Assert.IsTrue( pc.Body.OverrideMassCenter );
Assert.AreEqual( 36f, pc.Body.MassCenterOverride.z, 0.01f );
Assert.AreEqual( RigidbodyFlags.DisableCollisionSounds, pc.Body.RigidbodyFlags );
Assert.IsTrue( pc.Body.CollisionEventsEnabled );
Assert.IsTrue( pc.Body.CollisionUpdateEventsEnabled );
}
///
/// Pins the documented property defaults on a freshly created controller.
///
[TestMethod]
public void DefaultPropertySurface()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var pc = go.Components.Create();
Assert.AreEqual( 16f, pc.BodyRadius );
Assert.AreEqual( 72f, pc.BodyHeight );
Assert.AreEqual( 500f, pc.BodyMass );
Assert.AreEqual( 1f, pc.BrakePower );
Assert.AreEqual( 0.1f, pc.AirFriction );
Assert.IsTrue( pc.UseInputControls );
Assert.AreEqual( 110f, pc.WalkSpeed );
Assert.AreEqual( 320f, pc.RunSpeed );
Assert.AreEqual( 70f, pc.DuckedSpeed );
Assert.AreEqual( 300f, pc.JumpSpeed );
Assert.AreEqual( 36f, pc.DuckedHeight );
Assert.AreEqual( "run", pc.AltMoveButton );
Assert.IsFalse( pc.RunByDefault );
Assert.IsTrue( pc.EnablePressing );
Assert.AreEqual( "use", pc.UseButton );
Assert.AreEqual( 130f, pc.ReachLength );
Assert.IsTrue( pc.UseLookControls );
Assert.IsTrue( pc.RotateWithGround );
Assert.AreEqual( 90f, pc.PitchClamp );
Assert.IsTrue( pc.UseCameraControls );
Assert.AreEqual( 8f, pc.EyeDistanceFromTop );
Assert.IsTrue( pc.ThirdPerson );
Assert.IsTrue( pc.HideBodyInFirstPerson );
Assert.AreEqual( "view", pc.ToggleCameraModeButton );
Assert.IsTrue( pc.UseAnimatorControls );
Assert.IsTrue( pc.EnableFootstepSounds );
Assert.IsFalse( pc.IsDucking );
Assert.IsFalse( pc.IsClimbing );
Assert.IsFalse( pc.IsSwimming );
Assert.IsFalse( pc.IsOnGround );
Assert.IsTrue( pc.IsAirborne );
Assert.AreEqual( Vector3.Zero, pc.WishVelocity );
Assert.AreEqual( 72f, pc.CurrentHeight, "standing height comes from BodyHeight" );
}
///
/// The collider rig is sized from BodyRadius/BodyHeight: the capsule covers the
/// upper half, the feet box the lower half - and resizing reapplies on tick.
///
[TestMethod]
public void BodyDimensionsFollowProperties()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var pc = go.Components.Create();
var capsuleRadius = (16f * MathF.Sqrt( 2f )) / 2f;
Assert.AreEqual( capsuleRadius, pc.BodyCollider.Radius, 0.001f );
Assert.AreEqual( 72f - capsuleRadius, pc.BodyCollider.Start.z, 0.001f );
Assert.AreEqual( 36f, pc.BodyCollider.End.z, 0.001f );
Assert.IsTrue( pc.BodyCollider.Enabled );
Assert.AreEqual( new Vector3( 16, 16, 36 ), pc.FeetCollider.Scale );
Assert.AreEqual( new Vector3( 0, 0, 18 ), pc.FeetCollider.Center );
Assert.IsTrue( pc.FeetCollider.Enabled );
// BodyBox is the trace hull: half-radius footprint, full height
var hull = pc.BodyBox();
Assert.AreEqual( new Vector3( -8, -8, 0 ), hull.Mins );
Assert.AreEqual( new Vector3( 8, 8, 72 ), hull.Maxs );
// resizing takes effect when UpdateBody runs on the next tick
pc.BodyHeight = 100;
scene.GameTick();
Assert.AreEqual( 100f - capsuleRadius, pc.BodyCollider.Start.z, 0.001f );
Assert.AreEqual( 50f, pc.BodyCollider.End.z, 0.001f );
Assert.AreEqual( 50f, pc.FeetCollider.Scale.z, 0.001f );
}
///
/// A controller spawned above a static floor falls under its rigidbody's gravity
/// and lands: IsOnGround flips on, the ground object is reported as static, and
/// the headroom probe reports the full 100 units of open sky.
///
[TestMethod]
public void FallsAndLandsOnFloor()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var floor = CreateFloor( scene );
var floorCollider = floor.Components.Get();
var pc = CreatePlayer( scene, new Vector3( 0, 0, 10 ) );
var go = pc.GameObject;
Assert.IsFalse( pc.IsOnGround );
Assert.IsTrue( pc.IsAirborne );
TickUntilGrounded( scene, pc );
Assert.IsTrue( pc.IsOnGround, $"should have landed: {go.WorldPosition}" );
Assert.IsFalse( pc.IsAirborne );
Assert.AreEqual( floor, pc.GroundObject );
Assert.AreEqual( floorCollider, pc.GroundComponent );
Assert.IsFalse( pc.GroundIsDynamic, "a static collider is not dynamic ground" );
Assert.IsTrue( go.WorldPosition.z < 2f, $"{go.WorldPosition}" );
Assert.IsTrue( go.WorldPosition.z > -1f, $"{go.WorldPosition}" );
Assert.AreEqual( 100f, pc.Headroom, 1f, "open sky above - the probe reports its full length" );
}
///
/// WishVelocity drives the body across the floor through the walk mode, and
/// clearing it engages the brakes - the velocity bleeds off quickly.
///
[TestMethod]
public void WishVelocityWalksAcrossFloor()
{
var scene = new Scene();
using var sceneScope = scene.Push();
CreateFloor( scene );
var pc = CreatePlayer( scene, new Vector3( 0, 0, 1 ) );
var go = pc.GameObject;
TickUntilGrounded( scene, pc );
Assert.IsTrue( pc.IsOnGround );
pc.WishVelocity = new Vector3( 100, 0, 0 );
for ( int i = 0; i < 20; i++ )
{
scene.GameTick();
}
Assert.IsTrue( go.WorldPosition.x > 50f, $"should have walked: {go.WorldPosition}" );
Assert.IsTrue( MathF.Abs( go.WorldPosition.y ) < 5f, $"no sideways drift: {go.WorldPosition}" );
Assert.IsTrue( pc.Velocity.x > 10f, $"{pc.Velocity}" );
Assert.IsTrue( pc.IsOnGround, "walking on a flat floor stays grounded" );
// no wish velocity = brakes on, the body stops quickly
pc.WishVelocity = Vector3.Zero;
for ( int i = 0; i < 20; i++ )
{
scene.GameTick();
}
Assert.IsTrue( pc.Velocity.Length < 10f, $"the brakes should have stopped it: {pc.Velocity}" );
}
///
/// Jump adds an upwards velocity to the body and ungrounds immediately via
/// PreventGrounding - the controller rises, falls back and lands again.
///
[TestMethod]
public void JumpLaunchesAndRelands()
{
var scene = new Scene();
using var sceneScope = scene.Push();
CreateFloor( scene );
var pc = CreatePlayer( scene, new Vector3( 0, 0, 1 ) );
var go = pc.GameObject;
TickUntilGrounded( scene, pc );
Assert.IsTrue( pc.IsOnGround );
pc.Jump( Vector3.Up * 200f );
Assert.IsFalse( pc.IsOnGround, "PreventGrounding ungrounds the moment we jump" );
Assert.IsTrue( pc.Body.Velocity.z > 150f, $"{pc.Body.Velocity}" );
// rises off the floor...
for ( int i = 0; i < 3; i++ )
{
scene.GameTick();
}
Assert.IsTrue( go.WorldPosition.z > 5f, $"should be in the air: {go.WorldPosition}" );
// ...and comes back down
TickUntilGrounded( scene, pc );
Assert.IsTrue( pc.IsOnGround, $"should have landed again: {go.WorldPosition}" );
Assert.IsTrue( go.WorldPosition.z < 2f, $"{go.WorldPosition}" );
}
///
/// UpdateDucking swaps CurrentHeight between BodyHeight and DuckedHeight, and
/// standing back up is refused while there isn't enough headroom.
///
[TestMethod]
public void DuckingNeedsHeadroomToStand()
{
var scene = new Scene();
using var sceneScope = scene.Push();
CreateFloor( scene );
var pc = CreatePlayer( scene, new Vector3( 0, 0, 1 ) );
TickUntilGrounded( scene, pc );
scene.GameTick(); // refresh Headroom with open sky above
pc.UpdateDucking( true );
Assert.IsTrue( pc.IsDucking );
Assert.AreEqual( 36f, pc.CurrentHeight, "ducked height comes from DuckedHeight" );
// plenty of headroom - standing up works
pc.UpdateDucking( false );
Assert.IsFalse( pc.IsDucking );
Assert.AreEqual( 72f, pc.CurrentHeight );
// duck again and slide a ceiling in at z=40 - too low to stand under
pc.UpdateDucking( true );
var ceiling = scene.CreateObject();
ceiling.Name = "Ceiling";
ceiling.WorldPosition = new Vector3( 0, 0, 90 );
var ceilingBox = ceiling.Components.Create();
ceilingBox.Scale = new Vector3( 400, 400, 100 );
ceilingBox.Static = true;
scene.GameTick(); // refresh Headroom against the new ceiling
pc.UpdateDucking( false );
Assert.IsTrue( pc.IsDucking, "must stay ducked without the headroom to stand" );
}
///
/// The move mode state machine picks the enabled mode with the highest score
/// each tick, firing OnModeBegin/OnModeEnd on the way - and a mode that forbids
/// grounding ungrounds the controller.
///
[TestMethod]
public void ModeSelectionPicksHighestScore()
{
var scene = new Scene();
using var sceneScope = scene.Push();
CreateFloor( scene );
var pc = CreatePlayer( scene, new Vector3( 0, 0, 1 ) );
var go = pc.GameObject;
TickUntilGrounded( scene, pc );
Assert.IsTrue( pc.Mode is MoveModeWalk );
var probe = go.Components.Create();
probe.Controller = pc; // codegen-free test type: wire the [RequireComponent] by hand
probe.ScoreValue = 10;
scene.GameTick();
Assert.AreEqual( probe, pc.Mode, "the higher scoring mode wins" );
Assert.AreEqual( 1, probe.BeginCount );
scene.GameTick();
Assert.IsFalse( pc.IsOnGround, "the probe mode doesn't allow grounding" );
// drop the score below the walk mode - control hands back
probe.ScoreValue = -5;
scene.GameTick();
Assert.IsTrue( pc.Mode is MoveModeWalk, "the walk mode should win again" );
Assert.AreEqual( pc.Mode, probe.EndedWith, "OnModeEnd is told which mode takes over" );
Assert.AreEqual( 1, probe.BeginCount, "the probe was only begun once" );
// grounding works again under the walk mode
TickUntilGrounded( scene, pc, 30 );
Assert.IsTrue( pc.IsOnGround );
}
///
/// A controller round-trips through JSON: tuned properties survive, and the
/// deserialized copy rebuilds its body and reuses the serialized colliders child
/// instead of creating a duplicate.
///
[TestMethod]
public void SerializationRoundTrip()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var pc = go.Components.Create();
pc.WalkSpeed = 123;
pc.RunSpeed = 456;
pc.BodyHeight = 80;
pc.RunByDefault = true;
pc.UseInputControls = false;
var json = go.Serialize().ToJsonString();
var jsonObject = Json.ParseToJsonObject( json );
Assert.IsNotNull( jsonObject );
SceneUtility.MakeIdGuidsUnique( jsonObject );
var copy = new GameObject( false );
copy.Deserialize( jsonObject );
copy.Enabled = true;
var pc2 = copy.GetComponent();
Assert.IsNotNull( pc2 );
Assert.AreEqual( 123f, pc2.WalkSpeed );
Assert.AreEqual( 456f, pc2.RunSpeed );
Assert.AreEqual( 80f, pc2.BodyHeight );
Assert.IsTrue( pc2.RunByDefault );
Assert.IsFalse( pc2.UseInputControls );
Assert.IsTrue( pc2.Body.IsValid() );
Assert.AreNotEqual( pc.Body, pc2.Body );
Assert.IsTrue( pc2.ColliderObject.IsValid() );
Assert.IsTrue( pc2.BodyCollider.IsValid() );
Assert.IsTrue( pc2.FeetCollider.IsValid() );
Assert.IsTrue( pc2.Mode is MoveModeWalk );
Assert.AreEqual( 1, copy.Children.Count( x => x.Name == "Colliders" ),
"awake must reuse the serialized colliders child, not duplicate it" );
}
}
///
/// A move mode with a test-controlled score that records the state machine
/// callbacks. The [RequireComponent] controller wiring is done by the tests since
/// test assemblies don't run codegen.
///
public class ProbeMoveMode : MoveMode
{
///
/// The score this mode reports to the mode selection.
///
public int ScoreValue { get; set; }
///
/// How many times OnModeBegin fired.
///
public int BeginCount { get; private set; }
///
/// The mode passed to OnModeEnd when this mode lost control.
///
public MoveMode EndedWith { get; private set; }
///
/// Reports the test-controlled score to the controller's mode selection.
///
public override int Score( PlayerController controller ) => ScoreValue;
///
/// Records that this mode took control.
///
public override void OnModeBegin() => BeginCount++;
///
/// Records which mode took over from this one.
///
public override void OnModeEnd( MoveMode next ) => EndedWith = next;
}