Skill system with cooldown
Assets/
Scenes/
Arena.unity
Scripts/
Data/
Project settings
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.
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.
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
Now exists in the menu — that came from the CreateAssetMenu attribute.
- 2
Create three: Skill_Slash (cd 2), Skill_Dash (cd 5), Skill_Ultimate (cd 12).
- 3
Fill in the numbers in the Inspector. No recompiling, no code changes.
Step 2 — the caster
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
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);
}
}[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
Give the blocks a layer of their own: , add a User Layer called Obstacle.
- 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
Replace ApplyDamage in SkillCaster with the version below — it adds one line inside the loop.
- 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.
[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);
}
}