Skip to main content

On Sale: GamesAssetsToolsTabletopComics
Indie game storeFree gamesFun gamesHorror games
Game developmentAssetsComics
SalesBundles
Jobs
TagsGame Engines

Making mechanics of Cult of the Lamb: Attacking Part 2

This is the fifth tutorial of the making of the Cult of the Lamb mechanics in Unity. If you want to follow along the series you need to first read Ground Zero, Part 1, Part 2, Part 3 and Part 4.

Adding Health

Before adding some health behaviour we need a quick change on how we handle the attack button press on the PlayerInput script. This change allow as to hold the button and perform the attack as well:

namespace Player.Input
{
    public class PlayerInput : MonoBehaviour, GameControls.IPlayerActions
    {
        ...
        public void OnAttack(InputAction.CallbackContext context)
        {
            if (context.performed)
            {
                AttackEvent?.Invoke(true);
            }
            else if (context.canceled)
            {
                AttackEvent?.Invoke(false);
            }
        }
    }
}

In the last tutorial we could perform an attack but we had commented the health system to actually calculate and perform some damage. We are going to address that now. Create a new script and call it IDamageable. This is not going to be a class but an interface. This will help us later if we would like to damage enemies, crates with loot or trees for example.

namespace BerserkPixel.Health
{
    public interface IDamageable
    {
        /// <summary>
        /// Damages something. Returns true if successful, false otherwise
        /// </summary>
        bool Damage(float amount);
    }
}

After that, we need to actually implement this new interface somewhere. Let’s create another script and name it Health. In this one we are going to set a maximum health, a current health and create some events to tell other scripts about what has happened:

using System;
using System.Collections;
using UnityEngine;

namespace BerserkPixel.Health
{
    public class Health : MonoBehaviour, IDamageable
    {
        [SerializeField] private float _maxHealth = 100;
        [SerializeField] private float _inmuneTime = 1f;

        public event Action<float, float> OnTakeDamage;
        public event Action OnDie;
        public event Action<float> OnSetupHealth;

        private bool _isDead => _health == 0;
        private float _health;
        private WaitForSeconds _waitingTime;
        private bool _isInmune;

        private void Awake()
        {
            _health = _maxHealth;
            _waitingTime = new WaitForSeconds(_inmuneTime);
        }

        private void Start()
        {
            OnSetupHealth?.Invoke(_health);
        }

        public void Setup(int maxHealth)
        {
            _maxHealth = maxHealth;
            _health = maxHealth;

            OnSetupHealth?.Invoke(_health);
        }

        public void Heal(float amount)
        {
            _health = Mathf.Min(_health + amount, _maxHealth);
            OnTakeDamage?.Invoke(0, _health);
        }

        public bool Damage(float amount)
        {
            if (_isDead || _isInmune) { return false; }

            _health = Mathf.Max(_health - amount, 0);

            OnTakeDamage?.Invoke(amount, _health);

            StartCoroutine(Immune());

            if (_isDead)
            {
                Die();
            }

            return true;
        }

        private void Die()
        {
            _isInmune = true;
            OnDie?.Invoke();
            Destroy(transform.root.gameObject);
        }

        private IEnumerator Immune()
        {
            _isInmune = true;
            yield return _waitingTime;
            _isInmune = false;
        }
    }
}

With this, we can try and get a IDamageable component on any script and call the Damage method on it. Now we can display the health with a simple health bar:

using UnityEngine;
using UnityEngine.UI;

namespace BerserkPixel.Health
{
    [RequireComponent(typeof(Health))]
    public class HealthBar : MonoBehaviour
    {
        [SerializeField] private Slider _slider;
        [SerializeField] private float _animationSpeed = 10f;

        private Health _health;
        private float _currentHealth;
        private float _maxHealth;

        private void Awake()
        {
            _health = GetComponent<Health>();
        }

        private void OnEnable()
        {
            _health.OnSetupHealth += HandleHealthSetup;
            _health.OnTakeDamage += HandleTakeDamage;
        }

        private void OnDisable()
        {
            _health.OnSetupHealth -= HandleHealthSetup;
            _health.OnTakeDamage -= HandleTakeDamage;
        }

        private void HandleHealthSetup(float health)
        {
            _maxHealth = health;
            _currentHealth = _maxHealth;
            _slider.value = _currentHealth;
        }

        private void HandleTakeDamage(float amount, float health)
        {
            _currentHealth = health / _maxHealth;
        }

        private void Update()
        {
            // we just lerp the values. We could use SmoothStep, Lerp or Slerp here
            _slider.value = Mathf.SmoothStep(_slider.value, _currentHealth, _animationSpeed * Time.deltaTime);
        }
    }
}

Here is the health bar gameobject configuration. Note that the canvas Render Mode is set to World Space and it’s scale is set to a small value such as 0.01.

Health bar component setup

On the Enemy gameobject, add the HealthBar component (this will automatically add the Health script to it). Don’t forget to drag the slider object from the Hierarchy to the Slider field on the HealthBar script. Your enemy object should look something like this:

Health bar on the Enemy

The only thing missing now is to actually use this scripts to perform some damage. Open the PlayerAttackState and update the PerformDamage method:

private void PerformDamage(IReadOnlyCollection<Collider> colliders)
{
    if (colliders.Count <= 0) return;

    foreach (var col in colliders)
    {
	if (col.TryGetComponent(out IDamageable damageable))
	{
	    damageable.Damage(_attackData.Damage);
	}
    }
}

Now if you press play you should be able to hit the enemy and look how it’s healthbar is reduced with each hit!

Attack Combos

Combo attacks are a great way of showcasing the power of the state machine and scriptable objects. First we need to create the animations. We will do something simple. Create 2 more animations for the player. I named them Player_AttackSwing, Player_AttackSwing2 and Player_AttackSwing3.

Showing player animations

Each animation has a Trigger event for sending the Hitbox and the FinishAttack events. Drag this animations to the Player’s Animator window. Then we need to change their names and also add the “Attack” tag to this animations. Also make sure this animations are not set to loop:

Setup animations on the Animator window

Alright! we are half way through. Now we need to update some scripts. Let’s start with the AttackSO script:

namespace Player.States.Attacks
{
    public class AttackSO : ScriptableObject
    {
        [Tooltip("The name of the animation to use")]
        public string AttackName; // rename this one

        [Tooltip("Which attack index should we transition to")]
        [HideInInspector]
        public int NextComboIndex;

        [Tooltip("The time to start checking for next combo time")]
        public float ComboAttackTime;
        
        ...
    }
}

Now create a new file and name it ScriptableObjectExt. This will be an extension file for Scriptable Objects to help us clone one:

using UnityEngine;

namespace Extensions
{
    public static class ScriptableObjectExt
    {
        /// <summary>
        /// Creates and returns a clone of any given scriptable object.
        /// </summary>
        public static T Clone<T>(this T scriptableObject) where T : ScriptableObject
        {
            if (scriptableObject == null)
            {
                Debug.LogWarning($"ScriptableObject was null. Returning default {typeof(T)} object.");
                return (T) ScriptableObject.CreateInstance(typeof(T));
            }

            T instance = Object.Instantiate(scriptableObject);
            instance.name = scriptableObject.name; // remove (Clone) from name
            return instance;
        }
    }
}

And now we need to modify the PlayerAttackState. We need to accept a list/array of attacks to perform. Then we select the current one using an internal index. Afterwards we need to check if the animation has finished or is about to (meaning it’s currently in a transition). We do this on the Tick method. If this happens and the user is still performing an attack button, we try and get the next combo, meaning that we need to clone the current state and change the index to the next one. If the user stopped pressing the attack button we just go to the PlayerIdleState:

using System.Collections.Generic;
using BerserkPixel.Health;
using BerserkPixel.StateMachine;
using Extensions;
using UnityEngine;

namespace Player.States.Attacks
{
    [CreateAssetMenu(menuName = "States/Player/Attack")]
    public class PlayerAttackState : State<PlayerStateMachine>
    {
        [SerializeField] private AttackSO[] _attackDatas;

        private int _currentAttackIndex;
        private AttackSO _attackData;
        private float _previousFrameTime;

        private void OnValidate()
        {
            // this will automatically change the next combo index according to the 
            // ordering of the attack scriptable objects on the editor
            for (var i = 0; i < _attackDatas.Length; i++)
            {
                // if it's the last one, the next one is the first one so we loop the animations
                if (i == _attackDatas.Length - 1)
                {
                    _attackDatas[i].NextComboIndex = 0;
                }
                else
                {
                    _attackDatas[i].NextComboIndex = i + 1;
                }
            }
        }

        public override void Enter(PlayerStateMachine parent)
        {
            base.Enter(parent);

            // we need to gather which is the current attack we want
            _attackData = _attackDatas[_currentAttackIndex];

            parent.Animations.PlayAttack(_attackData.AttackName);
        }

        public override void Tick(float deltaTime)
        {
            var normalizedTime = _runner.Animations.GetNormalizedTime();

            if (normalizedTime >= _previousFrameTime && normalizedTime < 1f)
            {
                if (_runner.AttackPressed)
                {
                    TryComboAttack(normalizedTime);
                }
            }
            else
            {
                // go back to idle
                _runner.SetState(typeof(PlayerIdleState));
            }

            _previousFrameTime = normalizedTime;
        }

        public override void AnimationTriggerEvent(AnimationTriggerType triggerType)
        {
            base.AnimationTriggerEvent(triggerType);

            if (triggerType != AnimationTriggerType.HitBox) return;

            var colliders = _attackData.Hit(_runner.transform, _runner.IsFacingRight);

            PerformDamage(colliders);
        }

        private void TryComboAttack(float normalizedTime)
        {
            if (normalizedTime < _attackData.ComboAttackTime)
            {
                return;
            }

            // we clone the current state using the extension we created
            var newCombo = this.Clone();

            // we set the current index to the next one in the list
            newCombo._currentAttackIndex = _attackData.NextComboIndex;

            _runner.SetState(newCombo);
        }

        ...
    }
}

Then we need to update the PlayerAnimations script we previously made. We need a way of calculating the normalized time of the current animation. This value goes in between 0 and 1, meaning that 1 is completely finished and 0 otherwise. Replace the “HasAnimationEnded” method for this new “GetNormalizedTime”. Here we use the power of the “Attack” tags we added previously:

namespace Player
{
    public class PlayerAnimations
    {
        ...

        public float GetNormalizedTime()
        {
            var currentInfo = _animator.GetCurrentAnimatorStateInfo(0);
            var nextInfo = _animator.GetNextAnimatorStateInfo(0);

            if (_animator.IsInTransition(0) && nextInfo.IsTag("Attack"))
            {
                return nextInfo.normalizedTime;
            }

            if (!_animator.IsInTransition(0) && currentInfo.IsTag("Attack"))
            {
                return currentInfo.normalizedTime;
            }

            return 0f;
        }

    }
}

Also I changed the CrossFade to CrossFadeInFixedTime on each call but that is just a matter of preference:

using UnityEngine;

namespace Player
{
    public class PlayerAnimations
    {
	...
        public void PlayIdle()
        {
            _animator.CrossFadeInFixedTime(PLAYER_IDLE, _transitionDuration);
        }

        public void PlayMove()
        {
            _animator.CrossFadeInFixedTime(PLAYER_MOVE, _transitionDuration);
        }

        public void PlayRoll()
        {
            _animator.CrossFadeInFixedTime(PLAYER_ROLL, _transitionDuration);
        }
        
        public void PlayAttack(string attackName)
        {
            _animator.CrossFadeInFixedTime(attackName, _transitionDuration);
        }
	...
    }
}

Creating the Scriptable Object Assets

Go back to the editor and inside your Attacks state directory create a new SO directory (or call it however it makes sense to you). In here we are going to store all the data for each attack. Right click Create → Player → Attack

Creating the attack assets

Create 3 of them and named them accordingly. Change the Attack Name field to match the animations in the Animator view. Here is my first attack. All the values are the same for my 3 attacks except for the Attack Name and the damage variables:

Specific attack asset values

Now, select your PlayerAttackState scriptable object asset and drag this new 3 attacks in the Attack Datas field. You can play around with the order if you want.

Adding attacks to the state

Finally, make sure you have this PlayerAttackState asset in the Player’s possible states. Press play and press the attack button. The states should be changing automatically in between each attack combo:

Final result

Play around with the Immune time on the health scrip and other configurations to make it look and feel awesome!

On the next tutorial we will add some weapons to the scene so the player can grab them and change in between them.

Support this post

Did you like this post? Tell us

Leave a comment

Log in with your itch.io account to leave a comment.