U
tab 02Character & Combat9 lessons — tap to open
04 / 09 · 12 min
Character & Combat

Skill system with cooldown

your project so far

Assets/

Scenes/

Arena.unity

Knight

Scripts/

PlayerAnimator.csCharacterStats.csSkillData.csSkillCaster.csSkillSlotUI.cs

Data/

Skill_Slash / Dash / Ultimate

Project settings

Obstacle layer

you already havethis lesson adds

The Knight can walk, run and swing. Now it needs three things worth swinging for. Everything here goes on the Knight, next to PlayerMovement and PlayerAnimator.

A skill has four parts: what it costs, how long it locks, what it does, and what it looks like. Keep the first three as data and the fourth as a prefab.

animated diagram

Cast → the ring empties → it refills → ready again. That ring is one float.

Step 1 — a skill is data, not a script

A ScriptableObject is an asset file that holds values. Making each skill an asset means designers can add a new skill without touching code.

SkillData.cs
using UnityEngine;

[CreateAssetMenu(fileName = "Skill_", menuName = "Game/Skill")]
public class SkillData : ScriptableObject
{
    [Header("Identity")]
    public string skillName = "Slash";
    public Sprite icon;

    [Header("Numbers")]
    public float cooldown = 2f;
    public float manaCost = 10f;
    public float damage = 25f;
    public float range = 3f;

    [Header("Presentation")]
    public string animationTrigger = "Attack";
    public GameObject vfxPrefab;
    public AudioClip sfx;
}
  1. 1

    Now Assets▸Create▸Game▸Skill exists in the menu — that came from the CreateAssetMenu attribute.

  2. 2

    Create three: Skill_Slash (cd 2), Skill_Dash (cd 5), Skill_Ultimate (cd 12).

  3. 3

    Fill in the numbers in the Inspector. No recompiling, no code changes.

Step 2 — the caster

SkillCaster.cs — on the Knight
using System.Collections.Generic;
using UnityEngine;

public class SkillCaster : MonoBehaviour
{
    [SerializeField] private List<SkillData> skills = new List<SkillData>();
    [SerializeField] private KeyCode[] keys = { KeyCode.Q, KeyCode.E, KeyCode.R };
    [SerializeField] private Transform castPoint;
    [SerializeField] private float mana = 100f;

    // remembers the Time.time when each skill becomes usable again
    private float[] readyAt;
    private Animator anim;

    void Awake()
    {
        anim = GetComponent<Animator>();
        readyAt = new float[skills.Count];
    }

    void Update()
    {
        for (int i = 0; i < skills.Count && i < keys.Length; i++)
            if (Input.GetKeyDown(keys[i]))
                TryCast(i);
    }

    public bool IsReady(int i) => Time.time >= readyAt[i];

    /// 0 = ready, 1 = just cast
    public float CooldownFill(int i)
    {
        float remaining = readyAt[i] - Time.time;
        return Mathf.Clamp01(remaining / skills[i].cooldown);
    }

    public void TryCast(int i)
    {
        SkillData s = skills[i];

        if (!IsReady(i)) { Debug.Log(s.skillName + " on cooldown"); return; }
        if (mana < s.manaCost) { Debug.Log("Not enough mana"); return; }

        mana -= s.manaCost;
        readyAt[i] = Time.time + s.cooldown;      // this one line is the whole cooldown

        if (anim != null && !string.IsNullOrEmpty(s.animationTrigger))
            anim.SetTrigger(s.animationTrigger);   // the Attack trigger from the last lesson

        if (s.vfxPrefab != null)
            Instantiate(s.vfxPrefab, castPoint.position, castPoint.rotation);

        ApplyDamage(s);
    }

    void ApplyDamage(SkillData s)
    {
        // everything inside a sphere in front of the character
        Vector3 center = transform.position + transform.forward * s.range * 0.5f;

        foreach (Collider hit in Physics.OverlapSphere(center, s.range))
        {
            if (hit.transform == transform) continue;                 // not myself
            if (hit.TryGetComponent(out CharacterStats target))
                target.TakeDamage(s.damage);
        }
    }
}

Why Time.time and not a countdown variable?

  • A countdown needs to be decremented every frame in Update. A deadline needs zero work per frame — you just compare.

  • With 40 skills on 30 enemies, that difference is 1,200 subtractions per frame you never do.

  • CooldownFill returns 1 → 0, which is exactly what a radial UI Image fillAmount wants.

Step 3 — hook it to the UI

SkillSlotUI.cs — put on each skill icon
using UnityEngine;
using UnityEngine.UI;

public class SkillSlotUI : MonoBehaviour
{
    [SerializeField] private SkillCaster caster;
    [SerializeField] private Image cooldownOverlay;   // Image Type = Filled, Radial 360
    [SerializeField] private int slotIndex;

    void Update()
    {
        cooldownOverlay.fillAmount = caster.CooldownFill(slotIndex);
    }
}
C# script
[SerializeField] private LayerMask enemyLayers;
// ...
Physics.OverlapSphere(center, s.range, enemyLayers);

Step 4 — a sweep that a block can stop

One cast already hits three enemies at once: the sphere finds every collider inside it and the loop damages all of them. That is what makes a skill feel like a skill instead of a punch.

But a sphere only measures distance. An enemy standing behind a block is inside the sphere, so it takes the hit through solid geometry — and the enemy lesson later teaches the enemies that a block stops their attack. Leave this and the rule only runs one way, which every player notices.

  1. 1

    Give the blocks a layer of their own: Edit▸Project Settings▸Tags and Layers, add a User Layer called Obstacle.

  2. 2

    Open the P_Cube prefab from tab 01 and set its Layer to Obstacle. Every block in the grid follows the prefab — that is what you made it for.

  3. 3

    Replace ApplyDamage in SkillCaster with the version below — it adds one line inside the loop.

  4. 4

    In the Inspector, tick Enemy in Enemy Layers and Obstacle in Obstacles. Two masks, one each — mixing them is the mistake that makes a skill hit nothing.

add to SkillCaster.cs — a sweep a block can stop
[Header("Cover")]
public LayerMask enemyLayers;     // Enemy only
public LayerMask obstacles;       // Obstacle only

void ApplyDamage(SkillData s)
{
    Vector3 center = transform.position + transform.forward * s.range * 0.5f;
    Vector3 eye = castPoint.position;

    foreach (Collider hit in Physics.OverlapSphere(center, s.range, enemyLayers))
    {
        // The sphere says "close enough". This says "and I can actually
        // see it" — no line through a block, no damage through a block.
        Vector3 chest = hit.bounds.center;
        if (Physics.Linecast(eye, chest, obstacles)) continue;

        if (hit.TryGetComponent(out CharacterStats target))
            target.TakeDamage(s.damage);
    }
}