07-03 Directional Character 3D

Location: Samples/07 Sprites/03 Directional Character 3D

Recommended After: Directional Character

Learning Outcomes: in this sample you will learn:

How to use Sprite based animations in 3D space.

Summary

This sample reworks the 2D characters from the Directional Character sample to work in a 3D environment.

Overview

The code has the same general structure as the Directional Character sample with two differences:

  • It uses 3D physics components instead of 2D ones.

Other than that, DirectionalCharacter3D looks very similar to DirectionalCharacter.

using Animancer;
using UnityEngine;

public class DirectionalCharacter3D : MonoBehaviour
{
    [Header("Physics")]
    [SerializeField] private CapsuleCollider _Collider;
    [SerializeField] private Rigidbody _Rigidbody;
    [SerializeField, MetersPerSecond] private float _WalkSpeed = 1;
    [SerializeField, MetersPerSecond] private float _RunSpeed = 2;

    [Header("Animations")]
    [SerializeField] private DirectionalAnimations3D _Animancer;
    [SerializeField] private DirectionalAnimationSet _Idle;
    [SerializeField] private DirectionalAnimationSet _Walk;
    [SerializeField] private DirectionalAnimationSet _Run;
    [SerializeField] private DirectionalAnimationSet _Push;

    private Vector3 _Movement;
    private bool _IsPushing;

    public enum AnimationGroup
    {
        Other,
        Movement,
    }

    protected virtual void Update()
    {
        Vector2 input = SampleInput.WASD;
        if (input != Vector2.zero)
        {
            input = _Animancer.Animations.Snap(input);

            _Movement = new Vector3(input.x, 0, input.y);

            Transform camera = _Animancer.Camera;
            _Movement = camera.TransformDirection(_Movement);
            _Movement.y = 0;
            _Movement.Normalize();
            _Animancer.Forward = _Movement;

            Play(GetMovementAnimations(), AnimationGroup.Movement);
        }
        else
        {
            _Movement = Vector3.zero;

            Play(_Idle, AnimationGroup.Other);
        }
    }

    private void Play(DirectionalAnimationSet animations, AnimationGroup group)
        => _Animancer.SetAnimations(animations, (int)group);

    private DirectionalAnimationSet GetMovementAnimations()
    {
        if (_IsPushing)
            return _Push;
        else if (SampleInput.LeftShiftHold)
            return _Run;
        else
            return _Walk;
    }

    protected virtual void OnCollisionEnter(Collision collision)
        => OnCollision(collision);

    protected virtual void OnCollisionStay(Collision collision)
        => OnCollision(collision);

    private void OnCollision(Collision collision)
    {
        if (_IsPushing)
            return;

        int contactCount = collision.contactCount;
        for (int i = 0; i < contactCount; i++)
        {
            ContactPoint contact = collision.GetContact(i);

            if (Vector3.Angle(contact.normal, _Movement) > 180 - 30)
            {
                _IsPushing = true;
                return;
            }
        }
    }

    protected virtual void FixedUpdate()
    {
        _IsPushing = false;

        float speed = _Animancer.Animations == _Run ? _RunSpeed : _WalkSpeed;
        _Rigidbody.velocity = _Movement * speed;
    }
#if UNITY_EDITOR
    protected virtual void OnValidate()
    {
        if (_Animancer != null)
            _Animancer.Animations = _Idle;
    }
#endif}

Fields

The Serialized Fields are very similar to the fields in the Directional Character sample:

[Header("Physics")]
[SerializeField] private CapsuleCollider _Collider;// Changed from CapsuleCollider2D.
[SerializeField] private Rigidbody _Rigidbody;// Changed from Rigidbody2D.
[SerializeField, MetersPerSecond] private float _WalkSpeed = 1;
[SerializeField, MetersPerSecond] private float _RunSpeed = 2;

[Header("Animations")]
[SerializeField] private DirectionalAnimations3D _Animancer;// Changed from AnimancerComponent.
[SerializeField] private DirectionalAnimationSet _Idle;
[SerializeField] private DirectionalAnimationSet _Walk;
[SerializeField] private DirectionalAnimationSet _Run;
[SerializeField] private DirectionalAnimationSet _Push;

private Vector3 _Movement;// Changed from Vector2.
private bool _IsPushing;// New.

public enum AnimationGroup
{
    Other,
    Movement,
}

The _Facing, _CurrentAnimationSet, and TimeSynchronizer are no longer needed because they're all managed by the DirectionalAnimations3D.

  • To set the facing direction we now use _Animancer.Forward = _Movement;.
  • And to play something we now use:
private void Play(DirectionalAnimationSet animations, AnimationGroup group)
    => _Animancer.SetAnimations(animations, (int)group);

That automatically takes care of the Synchronization and getting the appropriate animation for the current direction.

Movement

The Update method is a bit more complicated than Movement in the Directional Character sample because it has to transform the player's 2D screen space inputs into a 3D world space direction:

protected virtual void Update()
{
    Vector2 input = SampleInput.WASD;
    if (input != Vector2.zero)
    {
        input = _Animancer.Animations.Snap(input);

Convert the input to 3D in the XZ plane:

        _Movement = new Vector3(input.x, 0, input.y);

Apply the camera's rotation, flatten it on the Y axis, and set it as the forward direction:

        Transform camera = _Animancer.Camera;
        _Movement = camera.TransformDirection(_Movement);
        _Movement.y = 0;
        _Movement.Normalize();
        _Animancer.Forward = _Movement;

And play the appropriate animations:

        Play(GetMovementAnimations(), AnimationGroup.Movement);
    }
    else
    {
        _Movement = Vector3.zero;

        Play(_Idle, AnimationGroup.Other);
    }
}

Pushing

Unfortunately, there is no 3D equivalent of the _Collider.GetContacts method used in the Directional Character sample to detect if the character is pushing against something so we need to use the OnCollisionEnter and OnCollisionStay MonoBehaviour Messages:

private bool _IsPushing;

protected virtual void OnCollisionEnter(Collision collision)
    => OnCollision(collision);

protected virtual void OnCollisionStay(Collision collision)
    => OnCollision(collision);

private void OnCollision(Collision collision)
{
    if (_IsPushing)
        return;

Once we have a collision, we can process its contacts using the same angle calculation as the Directional Character sample (using Vector3.Angle instead of Vector2.Angle):

    int contactCount = collision.contactCount;
    for (int i = 0; i < contactCount; i++)
    {
        ContactPoint contact = collision.GetContact(i);

        if (Vector3.Angle(contact.normal, _Movement) > 180 - 30)
        {

Physics collisions aren't detected every Update though, so instead of immediately playing animations here we need to remember if the character is pushing something:

            _IsPushing = true;
            return;
        }
    }
}

Unity calls FixedUpdate before the OnCollision events so in each physics update we can simply reset _IsPushing = false in FixedUpdate so that we don't have to worry about collision exit events or try to handle the possibility of a contact normal changing if an object rotates:

protected virtual void FixedUpdate()
{
    _IsPushing = false;

The rest of FixedUpdate is the same as the Directional Character sample

    float speed = _Animancer.Animations == _Run ? _RunSpeed : _WalkSpeed;
    _Rigidbody.velocity = _Movement * speed;
}

And now back in Update we can use that field to determine if we need to use the _Push animations:

protected virtual void Update()
{
    ...

    Play(GetMovementAnimations(), AnimationGroup.Movement);

    ...
}

private DirectionalAnimationSet GetMovementAnimations()
{
    if (_IsPushing)
        return _Push;
    else if (SampleInput.LeftShiftHold)
        return _Run;
    else
        return _Walk;
}

Edit Mode

DirectionalAnimations3D already takes care of showing the appropriate sprite for its assigned animations in Edit Move just like in the Directional Character sample, so all we need to do this time is make sure it has the right _Idle animations:

#if UNITY_EDITOR
protected virtual void OnValidate()
{
    if (_Animancer != null)
        _Animancer.Animations = _Idle;
}
#endif