体力、ダメージ、当たった手応え
Assets/
Scenes/
Arena.unity
Scripts/
プロジェクトの設定
すでにあるものこのレッスンで追加
ダメージを与えるスキルはありますが、打つ対象も、当たったと分かる表示もありません。プレイヤーが感じ取れない攻撃は、存在しないのと同じです。
当たりを伝える三つの信号:飛び出す数字、白く光る体、減るバー。
ステップ 1 —— 打つ対象を作る
- 1
Capsule を作成して Dummy と名付け、赤い material を付け、Knight の前方 6 m に置きます。そうです——この tab の最初で Knight 自身が使っていたのと同じカプセルです。
- 2
Enemy という layer を作成し、 から Dummy に割り当てます。
- 3
CharacterStats をアタッチし、maxHealth を 120 に設定します。
- 4
Knight の SkillCaster で Enemy Layers mask を Enemy のだけに設定します——これで Q、E、R は人形に当たり、Mage や Archer には当たりません。
- 5
人形を二回複製し、三体をプレイヤーの周りに散らします。
ステップ 2 —— ポーリングではなくイベント
HP バーが毎フレーム残りの HP を数えに行ってはいけません。代わりに、HP が変わったことを CharacterStats が発表し、関心のある側がそれを聞き受けます。
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
ステップ 3 —— 頭上の HP バー
- 1
Dummy に子として を追加し、Render Mode = World Space に設定します。
- 2
Canvas の RectTransform を width 120、height 20、scale 0.01、position Y = 2.4 に設定します。
- 3
scale 0.01 で 120 の UI ピクセルが 1.2 メートルになります——これがないとバーがビル級の大きさです。
- 4
中に Image を二つ置きます:暗い背景と、Image Type = Filled、Horizontal の赤い Fill です。
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 —— 浮遊するダメージ数字
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);
}
}白いフラッシュ
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 秒のヒットストップと組み合わせれば、ただのカプセルを殴っても気持ちよく感じられます。