U
タブ 02キャラクターと戦闘9 レッスン — タップで開く
07 / 09 · 11 分
キャラクターと戦闘

体力、ダメージ、当たった手応え

ここまでのプロジェクト

Assets/

Scenes/

Arena.unity

KnightDummy ×3

Scripts/

CharacterStats.csSkillCaster.csEquipment.csCharacterStats.OnDied eventHealthBar.csDamagePopup.csHitFlash.cs

プロジェクトの設定

Enemy layer

すでにあるものこのレッスンで追加

ダメージを与えるスキルはありますが、打つ対象も、当たったと分かる表示もありません。プレイヤーが感じ取れない攻撃は、存在しないのと同じです。

アニメーション図

当たりを伝える三つの信号:飛び出す数字、白く光る体、減るバー。

ステップ 1 —— 打つ対象を作る

  1. 1

    Capsule を作成して Dummy と名付け、赤い material を付け、Knight の前方 6 m に置きます。そうです——この tab の最初で Knight 自身が使っていたのと同じカプセルです。

  2. 2

    Enemy という layer を作成し、Inspector▸Layer▸Add Layer から Dummy に割り当てます。

  3. 3

    CharacterStats をアタッチし、maxHealth を 120 に設定します。

  4. 4

    Knight の SkillCaster で Enemy Layers mask を Enemy のだけに設定します——これで Q、E、R は人形に当たり、Mage や Archer には当たりません。

  5. 5

    人形を二回複製し、三体をプレイヤーの周りに散らします。

ステップ 2 —— ポーリングではなくイベント

HP バーが毎フレーム残りの HP を数えに行ってはいけません。代わりに、HP が変わったことを CharacterStats が発表し、関心のある側がそれを聞き受けます。

add to CharacterStats.cs
using System;
using UnityEngine;

// ... inside CharacterStats

/// current, max
public event Action<float, float> OnHealthChanged;
public event Action OnDied;

public void TakeDamage(float rawDamage)
{
    if (!IsAlive) return;

    float reduced = Mathf.Max(1f, rawDamage - finalDefense);
    currentHealth = Mathf.Max(0f, currentHealth - reduced);

    OnHealthChanged?.Invoke(currentHealth, finalMaxHp);   // tell the listeners
    DamagePopup.Spawn(transform.position + Vector3.up * 2f, reduced);

    if (!IsAlive)
    {
        OnDied?.Invoke();
        Die();
    }
}

public void Heal(float amount)
{
    if (!IsAlive) return;
    currentHealth = Mathf.Min(finalMaxHp, currentHealth + amount);
    OnHealthChanged?.Invoke(currentHealth, finalMaxHp);
}

✓ Event

実際にダメージが出たフレームだけ動きます。それ以外の時間はコストゼロです。

✖ Update() polling

意味のある二フレームのために、敵 30 体分を毎秒 60 回も読み続けます。

ステップ 3 —— 頭上の HP バー

  1. 1

    Dummy に子として UI▸Canvas を追加し、Render Mode = World Space に設定します。

  2. 2

    Canvas の RectTransform を width 120、height 20、scale 0.01、position Y = 2.4 に設定します。

  3. 3

    scale 0.01 で 120 の UI ピクセルが 1.2 メートルになります——これがないとバーがビル級の大きさです。

  4. 4

    中に Image を二つ置きます:暗い背景と、Image Type = Filled、Horizontal の赤い Fill です。

HealthBar.cs — put it on the world-space Canvas
using UnityEngine;
using UnityEngine.UI;

public class HealthBar : MonoBehaviour
{
    [SerializeField] private CharacterStats stats;
    [SerializeField] private Image fill;
    [SerializeField] private Image trailing;      // the slower amber bar
    [SerializeField] private float trailSpeed = 1.2f;

    private Camera cam;
    private float target = 1f;

    void Awake() => cam = Camera.main;

    void OnEnable()
    {
        stats.OnHealthChanged += Redraw;
        stats.OnDied += () => gameObject.SetActive(false);
    }

    void OnDisable() => stats.OnHealthChanged -= Redraw;   // always unsubscribe

    void Redraw(float current, float max)
    {
        target = max <= 0f ? 0f : current / max;
        fill.fillAmount = target;                          // snaps
    }

    void LateUpdate()
    {
        // the trailing bar eases down — this is what makes damage readable
        trailing.fillAmount = Mathf.MoveTowards(trailing.fillAmount, target, trailSpeed * Time.deltaTime);

        // always face the camera
        transform.rotation = cam.transform.rotation;
    }
}

ステップ 4 —— 浮遊するダメージ数字

DamagePopup.cs — on a small world-space Canvas prefab
using UnityEngine;
using TMPro;

public class DamagePopup : MonoBehaviour
{
    private static DamagePopup prefab;

    [SerializeField] private TextMeshProUGUI label;
    [SerializeField] private float lifeTime = 0.8f;
    [SerializeField] private float riseSpeed = 1.6f;

    private float age;

    public static void Spawn(Vector3 worldPos, float amount)
    {
        if (prefab == null)
            prefab = Resources.Load<DamagePopup>("DamagePopup");   // Assets/Resources/DamagePopup.prefab
        if (prefab == null) return;

        DamagePopup p = Instantiate(prefab, worldPos, Quaternion.identity);
        p.label.text = Mathf.RoundToInt(amount).ToString();

        // a little scatter so two hits never overlap
        p.transform.position += new Vector3(Random.Range(-0.3f, 0.3f), 0f, 0f);
    }

    void Update()
    {
        age += Time.deltaTime;

        transform.position += Vector3.up * riseSpeed * Time.deltaTime;
        transform.rotation = Camera.main.transform.rotation;

        float t = age / lifeTime;
        label.alpha = 1f - t;                                     // fade out
        transform.localScale = Vector3.one * (1f + 0.3f * (1f - t));

        if (age >= lifeTime) Destroy(gameObject);
    }
}

白いフラッシュ

HitFlash.cs — on the dummy
using System.Collections;
using UnityEngine;

public class HitFlash : MonoBehaviour
{
    [SerializeField] private CharacterStats stats;
    [SerializeField] private Renderer[] renderers;
    [SerializeField] private float duration = 0.09f;

    private Color[] original;

    void Awake()
    {
        original = new Color[renderers.Length];
        for (int i = 0; i < renderers.Length; i++)
            original[i] = renderers[i].material.color;
    }

    void OnEnable()  => stats.OnHealthChanged += (_, __) => { StopAllCoroutines(); StartCoroutine(Flash()); };

    IEnumerator Flash()
    {
        foreach (Renderer r in renderers) r.material.color = Color.white;
        yield return new WaitForSeconds(duration);
        for (int i = 0; i < renderers.Length; i++) renderers[i].material.color = original[i];
    }
}
  • 0.09 秒がちょうど良いところです。短すぎると誰も気づかず、長すぎると敵がストロボのようになります。

  • .material を読み取ると object ごとの material コピーが作られます。だから一体だけを光らせて全体は光らせないでいられます。

  • このフラッシュを VFX レッスンの 0.05 秒のヒットストップと組み合わせれば、ただのカプセルを殴っても気持ちよく感じられます。