using System;
using UnityEngine;
using Framework.Layers;
using Framework.Services;
namespace Framework.Actors {
/// Manages the physical behavior of an actor
public class ActorPhysics : ScriptComponent, IVelocityProvider {
/// Smallest velocity change the recorded velocity can be updated by
private const float VelocityEpsilon = 1e-4f;
/// How far off the ground the character still counts as grounded
private const float GroundCheckExtraDistance = 0.03f;
/// Whether a custom layer mask should be used for collisions by this actor
///
/// If this is set to 'false', the layer mask will be determined from Unity's
/// physics settings for the layer the player character is on.
///
public bool UseCustomLayerMask;
/// Things that will block movement of the actor
///
/// This is ignored (or rather, overwritten) unless
/// is set at the same time.
///
public LayerMask SolidLayerMask = Physics.DefaultRaycastLayers;
/// Whether the actor is currently on solid ground
///
/// This can be used to determine when to reset the jump counter or whether
/// the actor is able to jump off from the ground at all. It is not very reliable,
/// though, and the actor often needs to be driven into the ground at a significant
/// velocity to even register as grounded. Consider using
/// the for a more reliable alternative.
///
public bool UnreliableIsGrounded;
/// Whether the actor is being affected by gravity
///
/// If set, the gravity from Unity's physics settings is applied as a force
/// automatically before each update. Only set this to false if you do fancy
/// things with gravity.
///
public bool IsAffectedByGravity = true;
/// How much the actor is affected by gravity
///
/// You may want to increase this for fast platformers since a realistic amount
/// of gravity makes for very boring movements when combined with unrealistic
/// jump heights (of multiple times the actor's own body height).
///
public float GravityScale = 1.0f;
/// Mass of the actor
///
/// This should include the equipment carried by the actor.
///
/// -
/// Human
/// 75 kilograms
///
/// -
/// Dog
/// 35 kilograms
///
/// -
/// Horse
/// 450 kilograms
///
/// -
/// Car
/// 1500 kilograms
///
///
///
public float Mass = 85.0f; // Default weight = athletic "hero" human + stuff
/// Maximum step height the character can traverse without jumping
///
/// The character controller used by the actor physics component moves the actor
/// the full horizontal distance desired and adjusts the height as needed
/// (up to this height). The actor will move on top of the step without gaining
/// any upward velocity.
///
public float MaximumStepHeight = 0.25f;
/// Current velocity of the actor
public Vector3 Velocity;
/// Called when the component is loaded into a game object
protected override void Awake() {
base.Awake();
// Unless the actor wants to use a custom layer mask, look up what layers
// the actor (or rather the layer the actor is on) should collide against
if(!this.UseCustomLayerMask) {
this.SolidLayerMask = LayerMaskHelper.GetLayerCollisionMaskFor(gameObject.layer);
}
#if UNITY_EDITOR
// Make sure the actor can actually detect ground contact and won't collide
// with itself, since otherwise ground detection will not work at all.
int ownLayer = gameObject.layer;
if((LayerMaskHelper.GetLayerCollisionMaskFor(ownLayer) & (1 << ownLayer)) != 0) {
string ownLayerName = LayerMask.LayerToName(ownLayer);
Debug.LogWarning(
"Actor '" + gameObject.name + "' is on layer '" + ownLayerName + "' and " +
"would collide with itself. Ground detection will not work. Either change " +
"the 'SolidLayerMask' field of its ActorPhysics component or adjust " +
"the collision mask in Unity's physics settings."
);
}
#endif // UNITY_EDITOR
// We rely on the built-in CharacterController component to do the movement.
// An earlier attempt with hand-rolled movement logic was thwarted but Unity
// or PhysX returning bad intersection points and normals for standing contacts.
this.characterController = GetComponent();
if(this.characterController == null) {
this.characterController = gameObject.AddComponent();
this.characterController.minMoveDistance = VelocityEpsilon;
fitCharacterControllerToCollider();
}
this.characterController.stepOffset = this.MaximumStepHeight;
}
/// Character controller that is used so move the actor around
public CharacterController CharacterController {
get { return this.characterController; }
}
/// Collisions the actor encountered in the last physics frame
public CollisionFlags Collisions {
get { return this.collisions; }
}
/// Queues a direct movement for the actor
/// Movement that will be queued
///
/// This will bypass acceleration/deceleration and attempt to move the actor
/// directly by the specified amount during the next physics update. It is
/// useful if you want to combine physics with animation-driven root motion.
///
public void QueueMovement(Vector3 movement) {
this.queuedMovement += movement;
}
/// Queues a force to affect an actor's velocity
/// Force that will be queued for the actor's velocity
public void QueueForce(Vector3 force) {
this.queuedForces += force;
}
/// Queues an impulse to affect an actor's velocity
/// Impulse that will be queued for the actor's velocity
public void QueueImpulse(Vector3 impulse) {
this.queuedImpulses += impulse;
}
#if false // untested, may be fine or complete garbage
/// Applies drag to the object
/// Drag coefficient of the actor
public void ApplyDrag(float dragCoefficient = 1.0f) {
const float Density = 0.233f;
const float IncidentalArea = 0.1f;
float speed = this.Velocity.magnitude;
float drag = speed * speed * 0.5f * Density * dragCoefficient * IncidentalArea;
QueueForce(this.Velocity * -drag);
}
#endif
/// Applies the force of gravity to the actor
public void ApplyGravity(Vector3 gravity) {
// Times mass b/c w/o friction, a feather falls as fast as a lead weight!
QueueForce(gravity * this.Mass * this.GravityScale);
}
/// Called once per physics frame
protected virtual void FixedUpdate() {
float deltaTime = Time.fixedDeltaTime;
// Auto-Apply if enabled
if(this.IsAffectedByGravity) {
ApplyGravity(Gravity);
}
// Determine the translation the actor should attempt this physics frame
// according to its velocity, acceleration and forces.
Vector3 translation = integrateViaEulerMethod(deltaTime);
// Now do the movement. This requires special tricks because the CharacterController
// component has several issues.
Vector3 reportedVelocity;
float velocityAdjustment = 0.0f;
{
moveActor(translation, out reportedVelocity);
// As the actor travels horizontally, recharge the step climb budget
// by the amount the charactor controller's slope limit would allow
// the character to climb vertically.
rechargeStepClimbBudget(translation);
// Calculate the amount the character controller has adjusted the height
// of the actor over our intended trajectory to compensate for higher floors.
float deltaHeightAdjustment;
{
float heightAdjustment = (reportedVelocity.y - this.Velocity.y) * deltaTime;
deltaHeightAdjustment = heightAdjustment - this.previousHeightAdjustment;
this.previousHeightAdjustment = heightAdjustment;
}
// If the actor went upwards by less than the step climb budget, we assume
// that the character controller used its step offset logic.
bool hasTraversedStep =
(deltaHeightAdjustment >= VelocityEpsilon) &&
(deltaHeightAdjustment < this.characterController.stepOffset);
if(hasTraversedStep) {
reportedVelocity.y -= deltaHeightAdjustment / deltaTime;
this.characterController.stepOffset -= deltaHeightAdjustment;
}
}
// Update the velocity. This is filtered so that small errors will not accumulate,
// like when moving at a speed of 5.0 up a slope and the movement logic says that
// the character only moved 4.99 units, getting slower every cycle.
updateVelocity(reportedVelocity, updateHorizontalVelocity: true);
this.Velocity.y += velocityAdjustment;
}
/// Integrates acceleration and velocity using the Euler method
/// Time by which the simulation will be advanced
/// The translation by which the actor should be moved
private Vector3 integrateViaEulerMethod(float deltaTime) {
Vector3 translation;
{
// Apply the other half of the acceleration from the previous update cycle.
// This is delayed into now in order to integrate velocity at the midpoint.
this.Velocity += this.acceleration * deltaTime;
// Calculate the new acceleration
this.acceleration = this.queuedForces / this.Mass;
// Add impulses
Vector3 impulses = this.queuedImpulses / this.Mass;
//this.acceleration += impulses / delta; // turn impulse into force over 1 delta tick
this.Velocity += impulses; // add impulses directly
// Halve the acceleration so that one half can be applied now,
// the other half at the beginning of the next update cycle.
this.acceleration /= 2.0f;
// Apply half of the force scaled by time
this.Velocity += this.acceleration * deltaTime;
// Integrate into position
translation = this.Velocity * deltaTime;
// Finally, queued movements (root motion, etc.) go directly into translation
translation += this.queuedMovement;
}
// Reset the queues
this.queuedForces = Vector3.zero;
this.queuedImpulses = Vector3.zero;
this.queuedMovement = Vector3.zero;
return translation;
}
///
/// Recharge the step climb budget relative from the actor's horizontal movement
///
/// Amount the actor has moved
///
/// Realistically, this budget would also recover by time: an actor could walk
/// up very steep stairs by moving only upwards, but the character controller
/// performs horizontal movement in full and only then adjusts height based on
/// obstacles, so it's either this or unlimited stair steepness.
///
private void rechargeStepClimbBudget(Vector3 translation) {
// Some silly micro-optimization: we don't want to recalculate the slope units
// Y per X each physics frames, so we cache it as per the last slope limit value.
if(this.lastSlopeLimit != this.characterController.slopeLimit) {
this.lastSlopeLimit = this.characterController.slopeLimit;
this.lastSlopeYperX = Mathf.Sin(this.lastSlopeLimit * Mathf.Deg2Rad);
}
// Recharge the step climb budget by the amount of horizontal movement
// the actor has done and converting this to maximum Y via the slope limit,
// so the actor can climb up a staircase without bouncing on the steps
// if they are at a lesser incline than the slope limit.
float horizontalMovement = Mathf.Sqrt(
(translation.x * translation.x) + (translation.z * translation.z)
);
this.characterController.stepOffset = Mathf.Min(
this.characterController.stepOffset + Mathf.Abs(horizontalMovement * this.lastSlopeYperX),
this.MaximumStepHeight
);
}
/// Gravity the actor is affected by
///
/// For very special cases, like when the actor can go into space, float on water
/// or you do crazy things with gravity, you can override this in a derived class
/// and alter it as needed.
///
protected virtual Vector3 Gravity {
get { return Physics.gravity; }
}
/// Current velocity of the game object
///
/// Implementation of the interface in case
/// you want to stack one actor on top of another.
///
Vector3 IVelocityProvider.Velocity {
get { return this.Velocity; }
}
///
/// Moves the actor by the specified amount (unless blocked by colliders) and
/// provides the new grounded state and actual velocity
///
/// Amount by which the actor will be moved
/// Actual velocity the actor is moving with
///
/// If the actor hits a wall, the reported velocity will change.
///
private void moveActor(Vector3 translation, out Vector3 actualVelocity) {
// Here's the annoying part: in Unity 5, the CharacterController does not reliably
// report being grounded even if it is driven downward relative to the ground angle.
//
// For example, moving /into/ a slope (velocity = -9, 2.61) while then reporting
// being deflected by the slope (velocity = -8.78, 3.19) it still claims to not
// be grounded
// We first separate the horizontal and vertical movement
Vector3 horizontalTranslation = new Vector3(translation.x, 0.0f, translation.z);
Vector3 verticalTranslation = new Vector3(0.0f, translation.y, 0.0f);
// Then, for positive Y velocities, we first move up and then sideways so that
// the move can not be interpreted as "hovering slightly above the ground" and
// for negative Y velocities, we first move sideways and then down so the final
// bit of movement will be "into the ground" with a definite collision.
//
// If the ground is touched at any of the two movements, we consider the actor
// to be grounded. Still not 100% reliable, but a significant improvement.
if(translation.y >= 0.0f) {
this.characterController.Move(verticalTranslation);
actualVelocity = new Vector3(0.0f, this.characterController.velocity.y, 0.0f);
this.UnreliableIsGrounded = this.characterController.isGrounded;
this.collisions = this.characterController.collisionFlags;
this.characterController.Move(horizontalTranslation);
actualVelocity += this.characterController.velocity;
} else {
this.characterController.Move(horizontalTranslation);
actualVelocity = this.characterController.velocity;
this.UnreliableIsGrounded = this.characterController.isGrounded;
this.collisions = this.characterController.collisionFlags;
this.characterController.Move(verticalTranslation);
actualVelocity.y += this.characterController.velocity.y;
}
this.collisions |= this.characterController.collisionFlags;
this.UnreliableIsGrounded |= this.characterController.isGrounded;
}
/// Updates the recorded velocity of the actor
/// New velocity the actor will report
///
/// Whether the horizontal velocity should be update (if no collisions happen
/// it's often a good idea to keep the horizontal velocity untouched)
///
private void updateVelocity(Vector3 newVelocity, bool updateHorizontalVelocity) {
// Update the velocity only if it has changed more than the epsilon value.
// Since the velocity is scaled by delta time, then descaled and scaled again,
// blindly updating the recorded velocity would over time accumulate floating
// point inaccuracies.
if(Vector3.Distance(this.Velocity, newVelocity) > VelocityEpsilon) {
if(updateHorizontalVelocity) {
this.Velocity = newVelocity;
} else {
this.Velocity.y = newVelocity.y;
}
}
// If the velocity has been set to zero on any axis, apply this in any
// case (since otherwise, a tiny drift might never be cleared from our
// velocity vector, very slowly moving the actor around against an obstacle).
{
if(updateHorizontalVelocity) {
if(Math.Abs(newVelocity.x) < VelocityEpsilon) {
this.Velocity.x = 0.0f;
}
if(Math.Abs(newVelocity.z) < VelocityEpsilon) {
this.Velocity.z = 0.0f;
}
}
if(Math.Abs(newVelocity.y) < VelocityEpsilon) {
this.Velocity.y = 0.0f;
}
}
}
/// Fits the character controller to the collider component, if present
private void fitCharacterControllerToCollider() {
Collider collider = GetComponent();
var sphereCollider = collider as SphereCollider;
if(sphereCollider != null) {
this.characterController.center = sphereCollider.center;
this.characterController.radius = sphereCollider.radius;
this.characterController.height = sphereCollider.radius * 2.0f;
}
var capsuleCollider = collider as CapsuleCollider;
if(capsuleCollider != null) {
this.characterController.center = capsuleCollider.center;
this.characterController.radius = capsuleCollider.radius;
this.characterController.height = capsuleCollider.height;
}
var boxCollider = collider as BoxCollider;
if(boxCollider != null) {
float averageBoxExtents = boxCollider.size.magnitude * 0.6035533905f;
this.characterController.center = boxCollider.center;
this.characterController.radius = averageBoxExtents;
this.characterController.height = averageBoxExtents * 2.0f;
}
this.characterController.radius -= this.characterController.skinWidth;
this.characterController.height -= this.characterController.skinWidth * 2.0f;
}
/// Force that has been queued for the actor's velocity
private Vector3 queuedForces;
/// Impulses that have been queued for the actor's velocity
private Vector3 queuedImpulses;
/// Movements that have been queued for the actor
private Vector3 queuedMovement;
/// Stores half of the acceleration from the last physics update
///
/// This is used when doing Euler integration using the Midpoint Method,
/// where half of the acceleration is integrated into velocity before updating
/// the actor's position and half of the acceleration is integrated after.
///
private Vector3 acceleration;
/// Last slope limit set on the character controlled
private float lastSlopeLimit = 45.0f;
/// Calculated height the character may climb per horizontal distance
///
/// This is calculated from the slopeLimit value of the character controller
/// and cached here until the character controller's slope limit changes.
///
private float lastSlopeYperX = 0.70710678118f;
/// Height adjustment the character controller did in the last frame
///
/// Used to filter out blocking obstacles (such as the ground countering the force
/// of gravity) from having an effect on the step climbing budget.
///
private float previousHeightAdjustment;
/// Collisions the actor encountered in the last physics frame
private CollisionFlags collisions;
/// Character controller that is used so move the actor around
private CharacterController characterController;
}
} // namespace Framework.Actors