Unityを触り始めた頃って、Update() の中に「ダメージ計算」「HP管理」「エフェクト再生」「UI更新」など、全部まとめて書いてしまいがちですよね。
ダメージを受けたときの「頭上に数字を出す演出」も、ついその巨大なスクリプトの中に書き足してしまいがちです。
でもそれを続けていると、
- ダメージ演出だけ仕様変更したいのに、プレイヤーのロジックまで巻き込んでしまう
- 敵ごとに少しずつ挙動を変えたくても、巨大クラスに
ifが増え続ける - テストやデバッグで「どこで数字が出ているのか」追いづらい
という状態になりがちです。
そこでこの記事では、「ダメージ数字の表示だけ」を担当する小さなコンポーネントとして
DamagePopup(ダメージ数字)コンポーネントを用意して、
プレイヤーや敵のロジックからは「ダメージが出る箱」を呼ぶだけ、という構成にしてみましょう。
【Unity】ふわっと跳ねるダメージ数字!「DamagePopup」コンポーネント
ここでは、3D/2Dどちらでも使いやすいように、World Space の Canvas + TextMeshPro を前提にした実装を紹介します。
Unity6 で標準的な UI システム(UGUI + TextMeshPro)だけで動くようにしています。
DamagePopup.cs フルコード
using UnityEngine;
using TMPro;
/// <summary>
/**
* ダメージ数字をふわっと表示して、少し上に跳ねながらフェードアウトするコンポーネント。
*
* 想定:
* - World Space の Canvas 配下に配置された TextMeshProUGUI を持つプレハブにアタッチして使う。
* - DamagePopupSpawner など別コンポーネントから Instantiate されて利用する。
*/
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class DamagePopup : MonoBehaviour
{
// ====== 基本設定 ======
[Header("表示テキスト")]
[SerializeField] private TextMeshProUGUI damageText;
[Header("寿命(秒)")]
[SerializeField] private float lifetime = 0.8f;
[Header("初速度(ワールド座標系)")]
[SerializeField] private Vector3 initialVelocity = new Vector3(0f, 2.0f, 0f);
[Header("重力(ワールド座標系)")]
[SerializeField] private Vector3 gravity = new Vector3(0f, -6.0f, 0f);
[Header("開始時スケール")]
[SerializeField] private float startScale = 1.0f;
[Header("終了時スケール")]
[SerializeField] private float endScale = 0.7f;
[Header("フェードアウト開始割合(0~1)")]
[SerializeField, Range(0f, 1f)] private float fadeStartRatio = 0.4f;
[Header("ランダムな横方向の揺らぎ量")]
[SerializeField] private float randomHorizontalOffset = 0.3f;
[Header("カメラ方向を向かせるか")]
[SerializeField] private bool faceToCamera = true;
// ====== 内部状態 ======
private CanvasGroup canvasGroup;
private float elapsed;
private Vector3 velocity;
private Transform cachedTransform;
private Camera mainCamera;
private void Awake()
{
cachedTransform = transform;
canvasGroup = GetComponent<CanvasGroup>();
if (damageText == null)
{
// 自動取得(プレハブ設定ミス対策)
damageText = GetComponentInChildren<TextMeshProUGUI>();
}
mainCamera = Camera.main;
}
private void OnEnable()
{
// 再利用を考慮してリセット
elapsed = 0f;
velocity = initialVelocity;
// 少しだけランダムな横方向の揺らぎを与える
if (randomHorizontalOffset > 0f)
{
float offsetX = Random.Range(-randomHorizontalOffset, randomHorizontalOffset);
float offsetZ = Random.Range(-randomHorizontalOffset, randomHorizontalOffset);
cachedTransform.position += new Vector3(offsetX, 0f, offsetZ);
}
cachedTransform.localScale = Vector3.one * startScale;
canvasGroup.alpha = 1f;
}
private void Update()
{
elapsed += Time.deltaTime;
// 寿命を超えたら破棄
if (elapsed >= lifetime)
{
Destroy(gameObject);
return;
}
float t = elapsed / lifetime;
// ====== 位置の更新(簡易パーティクル的な挙動) ======
// v = v0 + g * dt
velocity += gravity * Time.deltaTime;
// p = p + v * dt
cachedTransform.position += velocity * Time.deltaTime;
// ====== スケールの補間 ======
float scale = Mathf.Lerp(startScale, endScale, t);
cachedTransform.localScale = Vector3.one * scale;
// ====== フェードアウト ======
float fadeStartTime = lifetime * fadeStartRatio;
if (elapsed >= fadeStartTime)
{
float fadeT = Mathf.InverseLerp(fadeStartTime, lifetime, elapsed);
canvasGroup.alpha = 1f - fadeT;
}
// ====== カメラ方向を向かせる(ビルボード) ======
if (faceToCamera && mainCamera != null)
{
// Canvas が World Space の場合、単純にカメラを向かせる
Vector3 camForward = mainCamera.transform.rotation * Vector3.forward;
Vector3 camUp = mainCamera.transform.rotation * Vector3.up;
cachedTransform.LookAt(cachedTransform.position + camForward, camUp);
}
}
/// <summary>
/// 表示するダメージ値と色を設定する。
/// 生成直後に呼び出す想定。
/// </summary>
/// <param name="value">ダメージ数値</param>
/// <param name="color">テキストカラー(null の場合は変更しない)</param>
public void Setup(int value, Color? color = null)
{
if (damageText == null) return;
damageText.text = value.ToString();
if (color.HasValue)
{
damageText.color = color.Value;
}
}
/// <summary>
/// クリティカルヒットなど、強調したい時にスケールを一時的に大きくする。
/// </summary>
public void PunchScale(float multiplier = 1.4f)
{
cachedTransform.localScale = Vector3.one * startScale * multiplier;
}
}
使い方の手順
ここからは、実際に「プレイヤーが敵を攻撃したときにダメージ数字を出す」例で手順を説明します。
手順①:DamagePopup プレハブを作る
- Hierarchy で
Canvasを作成し、Render Mode を「World Space」 に変更します。 - Canvas の子として
TextMeshPro - Text (UI)を作成します。 - Canvas のスケール・サイズを調整して、シーン内で適切な大きさに見えるようにします。
(例:Canvas の RectTransform の Width/Height を 200×100、スケールを 0.01 など) - Canvas の GameObject に
DamagePopupコンポーネントをアタッチします。 DamagePopupのDamage Textフィールドに、子の TextMeshProUGUI をドラッグ&ドロップします。- この Canvas を Project ビューへドラッグして Prefab 化します(例:
DamagePopup.prefab)。
手順②:ダメージ数字を生成するスクリプトを用意する
プレイヤーや敵のロジックに直接 Instantiate を書くと肥大化しやすいので、
「ダメージポップアップを出すだけのコンポーネント」を用意しておくと扱いやすくなります。
using UnityEngine;
/// <summary>
/// 任意のワールド座標に DamagePopup プレハブを生成してくれる小さなヘルパー。
/// プレイヤーや敵のオブジェクトにアタッチして使う想定。
/// </summary>
public class DamagePopupSpawner : MonoBehaviour
{
[Header("ダメージポップアップのプレハブ")]
[SerializeField] private DamagePopup damagePopupPrefab;
[Header("生成位置のオフセット(ローカル座標)")]
[SerializeField] private Vector3 spawnOffset = new Vector3(0f, 2.0f, 0f);
/// <summary>
/// ダメージポップアップを生成してセットアップする。
/// </summary>
public void SpawnDamagePopup(int damage, bool isCritical = false)
{
if (damagePopupPrefab == null)
{
Debug.LogWarning("DamagePopupSpawner: damagePopupPrefab が設定されていません。");
return;
}
// ワールド座標での生成位置
Vector3 spawnPosition = transform.position + spawnOffset;
// プレハブを生成(親なし / ワールドのルートに出す)
DamagePopup popup = Instantiate(damagePopupPrefab, spawnPosition, Quaternion.identity);
// 通常ダメージとクリティカルで色を変える例
Color color = isCritical ? Color.yellow : Color.white;
popup.Setup(damage, color);
if (isCritical)
{
popup.PunchScale(1.6f);
}
}
}
手順③:プレイヤーや敵から呼び出す
例として、敵がダメージを受けるコンポーネントを作り、その中からダメージ数字を出してみます。
using UnityEngine;
/// <summary>
/// とてもシンプルな敵の HP 管理クラスの例。
/// DamagePopupSpawner を使ってダメージ数字を出す。
/// </summary>
[RequireComponent(typeof(DamagePopupSpawner))]
public class SimpleEnemyHealth : MonoBehaviour
{
[SerializeField] private int maxHp = 100;
private int currentHp;
private DamagePopupSpawner popupSpawner;
private void Awake()
{
currentHp = maxHp;
popupSpawner = GetComponent<DamagePopupSpawner>();
}
/// <summary>
/// 外部から呼ばれるダメージ処理。
/// </summary>
public void TakeDamage(int damage, bool isCritical = false)
{
currentHp -= damage;
currentHp = Mathf.Max(currentHp, 0);
// ダメージ数字を表示
popupSpawner.SpawnDamagePopup(damage, isCritical);
if (currentHp <= 0)
{
Die();
}
}
private void Die()
{
// 実際のゲームではアニメーションやドロップ処理などをここに書く
Destroy(gameObject);
}
}
あとは、攻撃判定側(プレイヤーの武器やスキルなど)から TakeDamage() を呼ぶだけで、
敵の頭上にダメージ数字がふわっと出るようになります。
手順④:動く床やボスなど、別オブジェクトでも使い回す
- プレイヤー:敵に攻撃を当てたときに、敵の
SimpleEnemyHealthを参照してTakeDamage()を呼ぶ。 - トゲ床:プレイヤーが乗ったら、プレイヤー側の HP 管理コンポーネントに
TakeDamage()を呼び、同時にプレイヤーにアタッチしたDamagePopupSpawnerから数字を出す。 - ボス:通常攻撃とクリティカルの色を変えたい場合は、
isCriticalを切り替えてSpawnDamagePopup()を呼ぶ。
どのケースでも「ダメージ数字をどう動かすか」は DamagePopup に閉じ込められているので、
攻撃ロジックや HP 管理のコードはシンプルなまま保てます。
メリットと応用
このように DamagePopup と DamagePopupSpawner を小さなコンポーネントとして分離しておくと、
- プレハブ単位で演出を差し替えやすい
通常ダメージ用、クリティカル専用、毒ダメージ用など、色やフォント、アニメーションを変えたプレハブを複数用意しておけば、
スクリプトはほとんど触らずに演出を切り替えられます。 - レベルデザインが楽になる
敵のプレハブにSimpleEnemyHealthとDamagePopupSpawnerをセットしておけば、
ステージに敵をポンポン配置するだけで、どこでも同じクオリティのダメージ表示が出ます。 - 巨大な God クラスを避けられる
「ダメージ計算」「HP 管理」「表示演出」がそれぞれ別のコンポーネントに分かれているので、
どこか 1 箇所を変更しても他の責務に影響しづらくなります。
また、DamagePopup は「ふわっと動いてフェードアウトする UI 要素」という汎用的な動きなので、
- 回復量の表示
- 取得したコイン数の表示
- 経験値の増加表示
などにもそのまま流用できます。
改造案:ダメージ値に応じて色をグラデーションさせる
例えば「ダメージが大きいほど赤く、小さいほど白く」したい場合は、
DamagePopup にこんなメソッドを追加してみるのもアリです。
/// <summary>
/// ダメージ量に応じて色を補間して設定する例。
/// minDamage ~ maxDamage の範囲で、lowColor から highColor にグラデーション。
/// </summary>
public void SetupWithGradient(int value, int minDamage, int maxDamage, Color lowColor, Color highColor)
{
if (damageText == null) return;
damageText.text = value.ToString();
// 0~1 に正規化
float t = 0f;
if (maxDamage > minDamage)
{
t = Mathf.InverseLerp(minDamage, maxDamage, value);
}
damageText.color = Color.Lerp(lowColor, highColor, t);
}
このように、小さなコンポーネント単位で責務を分けておけば、
「色を変える」「動き方を変える」といった演出の試行錯誤もやりやすくなります。
ぜひ自分のプロジェクトに合わせて、少しずつカスタマイズしてみてください。
