U
章 02角色与战斗9 课 — 点击打开
04 / 09 · 12 分钟
角色与战斗

带冷却的技能系统

到目前为止的项目

Assets/

Scenes/

Arena.unity

Knight

Scripts/

PlayerAnimator.csCharacterStats.csSkillData.csSkillCaster.csSkillSlotUI.cs

Data/

Skill_Slash / Dash / Ultimate

项目设置

Obstacle layer

你已经有的这一课新增

Knight 已经会走、会跑、会挥武器了。接下来它需要三个值得挥武器的技能。这一课的东西全部挂在 Knight 上,放在 PlayerMovement 和 PlayerAnimator 旁边。

一个技能有四部分:消耗什么、锁多久、造成什么效果、长什么样。前三样留作数据,第四样做成 prefab。

动态图解

施放 → 圆环空掉 → 重新填满 → 再次可用。那个圆环其实就是一个 float。

第 1 步 —— 技能是数据,不是脚本

ScriptableObject 是一个装着数值的资源文件。把每个技能做成一个资源,设计师就能在不碰代码的情况下加新技能。

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

    现在菜单里有了 Assets▸Create▸Game▸Skill —— 这就是 CreateAssetMenu 属性带来的。

  2. 2

    创建三个:Skill_Slash(cd 2)、Skill_Dash(cd 5)、Skill_Ultimate(cd 12)。

  3. 3

    在 Inspector 里把数字填好。不需要重新编译,也不用改代码。

第 2 步 —— 负责施放的那部分

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

为什么用 Time.time 而不用倒计时变量

  • 倒计时得在 Update 里每帧减一次。而截止时间点每帧什么都不用做 —— 只要比较一下。

  • 30 个敌人各带 40 个技能,这个差别就是每帧省掉 1,200 次减法。

  • CooldownFill 返回的是从 1 到 0,正好就是圆形 UI Image 的 fillAmount 想要的值。

第 3 步 —— 接到界面上

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# 脚本
[SerializeField] private LayerMask enemyLayers;
// ...
Physics.OverlapSphere(center, s.range, enemyLayers);

第 4 步 —— 能被方块挡住的横扫

一次施放已经能同时打中三个敌人:球体会找出包在里面的每个 collider,循环再给它们全部扣血。正因如此它才像个技能,而不是普通的一拳。

但球体只会量距离。站在方块后面的敌人同样在球体里,于是它就隔着实实在在的几何体吃了这一击 —— 而后面的敌人那一课会告诉敌人:方块能挡住他们的攻击。这里不改,规则就只单向生效,玩家一眼就能看出来。

  1. 1

    给方块一个自己的 layer:Edit▸Project Settings▸Tags and Layers, 添加一个叫 Obstacle 的 User Layer。

  2. 2

    打开 tab 01 的 P_Cube prefab,把它的 Layer 设成 Obstacle。网格里的每个方块都会跟着 prefab 走 —— 你做它的目的就是如此。

  3. 3

    把 SkillCaster 里的 ApplyDamage 换成下面这版 —— 只是在循环里多加一行。

  4. 4

    在 Inspector 里,Enemy Layers 勾选 Enemy,Obstacles 勾选 Obstacle。两个 mask 各管一件事 —— 把它们混在一起就是技能什么都打不中的原因。

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