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 —— 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# スクリプト
[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 に追随します——そもそも 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);
    }
}