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