U
tab 02Nhân vật & Chiến đấu9 bài — chạm để mở
04 / 09 · 12 phút
Nhân vật & Chiến đấu

Hệ thống skill có hồi chiêu

dự án của bạn tới lúc này

Assets/

Scenes/

Arena.unity

Knight

Scripts/

PlayerAnimator.csCharacterStats.csSkillData.csSkillCaster.csSkillSlotUI.cs

Data/

Skill_Slash / Dash / Ultimate

thiết lập dự án

Obstacle layer

đã có sẵnbài này thêm

Knight đã đi, chạy và vung tay được. Giờ nó cần ba chiêu đáng để vung tay. Mọi thứ trong bài này gắn lên Knight, nằm cạnh PlayerMovement và PlayerAnimator.

Một skill có bốn phần: tốn gì, khoá bao lâu, làm gì, và trông ra sao. Ba phần đầu để dạng dữ liệu, phần thứ tư để dạng prefab.

sơ đồ động

Dùng chiêu → vòng tròn vơi đi → đầy lại → sẵn sàng. Vòng tròn đó chỉ là một số float.

Bước 1 — skill là dữ liệu, không phải script

ScriptableObject là một file asset chứa giá trị. Biến mỗi skill thành một asset nghĩa là người thiết kế có thể thêm skill mới mà không cần đụng vào code.

SkillData.cs
using UnityEngine;

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

    [Header("số liệu")]
    public float cooldown = 2f;
    public float manaCost = 10f;
    public float damage = 25f;
    public float range = 3f;

    [Header("trình diễn")]
    public string animationTrigger = "Attack";
    public GameObject vfxPrefab;
    public AudioClip sfx;
}
  1. 1

    Bây giờ menu Assets▸Create▸Game▸Skill đã có — nhờ thuộc tính CreateAssetMenu.

  2. 2

    Tạo ba cái: Skill_Slash (hồi 2), Skill_Dash (hồi 5), Skill_Ultimate (hồi 12).

  3. 3

    Điền số trong Inspector. Không cần biên dịch lại, không sửa code.

Bước 2 — bộ thi triển skill

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;

    // nhớ mốc Time.time mà mỗi skill sẵn sàng trở lại
    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 là sẵn sàng, 1 là vừa mới dùng
    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 + "đang hồi"); return; }
        if (mana < s.manaCost) { Debug.Log("thiếu mana"); return; }

        mana -= s.manaCost;
        readyAt[i] = Time.time + s.cooldown;      // cả cơ chế hồi chiêu nằm ở dòng này

        if (anim != null && !string.IsNullOrEmpty(s.animationTrigger))
            anim.SetTrigger(s.animationTrigger);   // trigger Attack từ bài trước

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

        ApplyDamage(s);
    }

    void ApplyDamage(SkillData s)
    {
        // mọi thứ trong một quả cầu phía trước nhân vật
        Vector3 center = transform.position + transform.forward * s.range * 0.5f;

        foreach (Collider hit in Physics.OverlapSphere(center, s.range))
        {
            if (hit.transform == transform) continue;                 // bỏ qua bản thân
            if (hit.TryGetComponent(out CharacterStats target))
                target.TakeDamage(s.damage);
        }
    }
}

Vì sao dùng Time.time thay vì biến đếm ngược?

  • Biến đếm ngược phải trừ dần mỗi khung hình trong Update. Mốc thời gian thì không tốn gì cả — chỉ so sánh.

  • Với 40 skill trên 30 con địch, khác biệt đó là 1.200 phép trừ mỗi khung hình mà bạn không phải làm.

  • CooldownFill trả về từ 1 về 0, đúng thứ mà fillAmount của Image dạng vòng tròn cần.

Bước 3 — nối vào giao diện

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);

Bước 4 — cú quét mà khối chặn được

Một cú tung đã trúng ba kẻ địch cùng lúc: hình cầu tìm ra mọi collider nằm trong nó và vòng lặp trừ máu tất cả. Chính điều đó làm skill ra dáng skill, chứ không phải một cú đấm.

Nhưng hình cầu chỉ đo khoảng cách. Kẻ địch đứng sau khối vẫn nằm trong hình cầu, nên nó ăn đòn xuyên qua khối đặc — trong khi bài kẻ địch phía sau sẽ dạy cho chúng rằng khối chặn được đòn đánh. Để nguyên thế này thì luật chỉ chạy một chiều, và người chơi nào cũng nhận ra.

  1. 1

    Cho các khối một layer riêng: Edit▸Project Settings▸Tags and Layers, thêm một User Layer tên Obstacle.

  2. 2

    Mở prefab P_Cube từ tab 01 và đặt Layer của nó thành Obstacle. Mọi khối trong lưới đi theo prefab — bạn tạo nó ra chính là để vậy.

  3. 3

    Thay ApplyDamage trong SkillCaster bằng bản dưới đây — nó chỉ thêm một dòng vào trong vòng lặp.

  4. 4

    Trong Inspector, tick Enemy ở ô Enemy Layers và Obstacle ở ô Obstacles. Hai mask, mỗi ô một thứ — trộn lẫn chúng là lỗi làm skill không trúng gì cả.

thêm vào SkillCaster.cs — cú quét mà khối chặn được
[Header("Cover")]
public LayerMask enemyLayers;     // chỉ Enemy
public LayerMask obstacles;       // chỉ Obstacle

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))
    {
        // Hình cầu nói "đủ gần". Dòng này nói "và tôi thật sự nhìn thấy
        // nó" — không có đường xuyên khối thì không có sát thương xuyên khối.
        Vector3 chest = hit.bounds.center;
        if (Physics.Linecast(eye, chest, obstacles)) continue;

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