Appearance
第03回 衝突判定とダメージ
前回: 第02回 カメラ操作とコンポーネント分割 / 次回: 第04回 攻撃処理と生成
前回の振り返り
入力、移動、カメラを分割
PlayerControllerが入力を受け取り、PlayerMoveが移動と振り向きを担当し、CameraMoveがカメラの追従と回転を担当する形に分割しました- ひとつの巨大なスクリプトにまとめるのではなく、 役割ごとにComponentを分けることで、 各処理の責務を明確にしました
今回は、攻撃ボタンを押したときだけ敵にダメージを与える仕組みを作ります
今回のテーマ
接触イベントと ダメージシステム
攻撃判定とHP管理を 役割ごとに分ける
今回作るもの
Playerの攻撃でダメージを与える
- Player が攻撃ボタンを押すことで攻撃を発生
- 攻撃中だけ当たり判定を有効にする
- 当たり判定に入った相手を検出し、 ダメージを通告する
- ダメージを受け取った相手はHPを減らす
- これらの処理を、攻撃動作、攻撃判定、 ダメージ解決、HP管理に分けて実装します
text
Player
攻撃ボタンを押す
子ObjectのPlayer Attackへ攻撃開始を伝える
Player Attack
攻撃中だけColliderが有効になる
Enemyに触れた瞬間を検出する
Enemy
DamageReceiverがダメージを受け取る
HealthがHPを減らす今回の到達目標
攻撃、接触、ダメージ、HP管理を分ける
- Playerがボタン入力で攻撃を開始できるようにします
- Playerの子Objectとして、攻撃判定用の
Player Attackを作ります - 攻撃中だけ
Player AttackのTrigger Colliderを有効にします OnTriggerEnterで攻撃が当たった相手を検出します- 相手の
DamageReceiverにダメージを渡します - HP計算は
Healthに任せます AttackResolverに攻撃結果の解決を集めます
直接的すぎる実装を避ける
PlayerAttackがEnemyのHPを直接減らすのはNG?
- 例えば、PlayerAttack の
OnTriggerEnterでEnemyのHealthを 直接減らす実装でも、小規模なゲームなら問題はありません - ですが一定規模以上のゲームでは、開発が進むにつれて 攻撃を扱うだけの PlayerAttack にゲームのルールや特殊な条件判定を 書き連ねていくことになりかねません
- 壊せるObject、無敵状態、防御力、属性などの仕様が増えるたびに PlayerAttackの中に分岐が増え続けるのは問題です 攻撃命中の処理と、その後何が起きるかの処理は分けるべきでしょう
責務ごとに処理を分ける
コンポーネントの責任を最小に
- 例えば
PlayerAttackでダメージ計算まで 扱うのではなく、ゲームの仕様に関する処理はAttackResolverに任せる様な構成が 考えられます - どのコンポーネントが何を扱い、それ以外を 扱わないかを徹底することでコードの可読性と メンテナンス性を高めることができます
text
PlayerController
入力を扱い、攻撃の指示を出す
PlayerAttack
攻撃の有効時間やタイミング、
攻撃判定のColliderを管理する
AttackResolver
攻撃を評価して適切に振り分ける
DamageReceiver
ダメージを受ける処理を直接担当
Health
HPの値とその増減を扱うゲーム中の接触
まずは衝突判定から
- 攻撃範囲に敵が入った、弾がキャラクターに命中した、 アイテムに触れて取得した、ダメージ床に入ってHPが減った、 これらの動作はすべて接触をきっかけに起こります
- ゲームでインタラクションを作るためには、 なんらかの当たり範囲同士が重なったときに 検知する仕組みが必要になります
- Unityでは Collider と物理エンジンを使って接触を検出できます 攻撃の命中判定で利用しましょう
Collider とは
物体の当たり範囲を表すComponent
- Collider は、GameObjectの当たり範囲を表すComponentです
Box Collider、Sphere Collider、Capsule Colliderなど、 用途に応じた形状のColliderを使い分けます- 見た目の形と当たり範囲を完全に一致させる必要はありません 多くの場合、プレイ感を優先してシンプルな形状にします
- 攻撃判定も、見た目の武器そのものではなく、 調整しやすい単純な形で作ることが多いです
Collision と Trigger
押し合うか、通過して検出するか
- Collision Colliderを持つObject同士がぶつかり、押し合います 壁、床、障害物などに使います
- Trigger Object同士は通過しますが、 範囲に入ったことだけを検出します 攻撃範囲、アイテム取得、ダメージ床などに使います
今回、攻撃判定では Is Trigger を有効にします
Triggerイベントの発生条件
少なくとも片方にRigidbodyが必要
- Collider同士が重なるだけでは Triggerイベントは発生しません
- 接触はUnityの物理エンジンが判定するため、 少なくとも片方のObjectに
Rigidbodyが 必要です - 今回はPlayer側に
Rigidbodyを設定します Playerはスクリプトで移動させるためIs Kinematicを有効にします
text
// 構成例
Player
Rigidbody
Collider
Player Attack
Collider(Is Trigger)OnTriggerEnter
Triggerに入った瞬間に呼ばれるコールバック関数
cs
private void OnTriggerEnter(Collider other)
{
Debug.Log(other.name);
}- 別のColliderがTriggerに入った瞬間にUnityから呼ばれます
otherには Triggerに入ってきたColliderが入ります- 範囲内にいる間ずっと処理する場合は
OnTriggerStayを、 範囲から出た瞬間を扱う場合はOnTriggerExitを使います
オブジェクト構成
PlayerとEnemyの例
- Player と Enemy の双方に
DamageReceiverを 設定して、ダメージの受け取りを可能にします - Player の攻撃判定用の
PlayerAttackは Player本体とは別の子Objectとして作ります
text
Player
Rigidbody
Collider
PlayerController
PlayerMove
DamageReceiver
Player Attack
Attack Collider(Is Trigger)
Enemy
Collider
DamageReceiver
EnemyUnityに依存しない処理は分離する
MonoBehaviour を使わない C# クラスで実装
- HPの増減や死亡判定は、Scene上の位置やColliderとは関係ありません Unityの機能を使わなくても成立する処理は、 できるだけ普通のC#クラスとして書くことを心がけましょう
- 例えば HP計算を扱う
Healthクラスは ダメージを受けた際の HPの増減や、死亡判定を担当しますが、 MonoBehaviourを継承せず、Unityの機能に依存しない形で作れます - 処理をゲームエンジンの機能から切り離せれば、単体でのテストや再利用が 容易になり、コードの内容もシンプルになるはずです
Healthの実装例
HPの値と計算を管理する C# クラス
Healthは HP計算を担当する C#クラスでMonoBehaviourを利用しません- こうした単純なデータ管理クラスは、Unityの 機能に依存しない形で作ることができます
- Unity の依存から切り離すことで、 HPの増減や死亡判定のロジックをシンプルに 保ち、単体でのテストも行いやすくなります
cs
[System.Serializable]
public class Health
{
[SerializeField] private int maxHealth = 10;
public int CurrentHealth { get; private set; }
public int MaxHealth => maxHealth;
public bool IsDead => CurrentHealth <= 0;
public void Reset()
{
CurrentHealth = maxHealth;
}
}HealthのHP計算
TakeDamageとHealで値の変更を管理
Healthは、HPをどの範囲で増減させるかを 担当します- 0以下のダメージや回復量は無視し、 死亡後に追撃が入っても二重に死亡しないよう 処理します
- HP の数値を直接操作させず管理し、
TakeDamage()やHeal()を通すことで 増減のルールを一元管理できます
cs
public void TakeDamage(int damage)
{
if (damage <= 0 || IsDead)
return;
CurrentHealth =
Mathf.Max(CurrentHealth - damage, 0);
}
public void Heal(int heal)
{
if (heal <= 0 || IsDead)
return;
CurrentHealth =
Mathf.Min(CurrentHealth + heal, maxHealth);
}DamageReceiverの初期化
Healthを持つMonoBehaviour
cs
public class DamageReceiver : MonoBehaviour
{
[SerializeField] private Health health = new();
public event Action<int, int> Damaged;
public event Action<int, int> Healed;
public bool CanReceiveDamage => !health.IsDead;
public bool IsDead => health.IsDead;
public int CurrentHealth => health.CurrentHealth;
public int MaxHealth => health.MaxHealth;
private void Awake()
{
health.Reset();
}
}- HPを持たせたいObjectには
DamageReceiverを Componentとして追加します DamageReceiverはHPの増減の対象となるかを 扱うコンポーネントです- 攻撃の対象となるか否かを管理することで 攻撃者は都度相手がEnemyであるかなどの チェックを行わずにダメージを与えられます
DamageReceiverのダメージ受付
Healthへ処理を渡す
DamageReceiverは、外部からダメージを 受け取る処理の共通コンポーネントです- HP計算は
Healthに任せ、変化があった ときだけDamagedイベントで通知、 演出などを起動します
cs
public void ReceiveDamage(int damage)
{
var previousHealth = CurrentHealth;
health.TakeDamage(damage);
if (CurrentHealth == previousHealth)
return;
Damaged?.Invoke(
previousHealth - CurrentHealth,
MaxHealth);
}AttackResolver
攻撃ルールを取り廻す 審判的なstatic クラス
AttackResolverは、攻撃が命中した際に 具体的に何が起きるかのルールをまとめる クラスです- 攻撃結果の判断をここに集めることで、 将来、攻撃の属性や防御力、無敵時間などの 仕様を追加する際の管理がしやすくなります
- 今後間接攻撃や全体攻撃が追加されても 対応しやすくなるでしょう
cs
public static class AttackResolver
{
public static void Resolve(
AttackContext attackContext,
DamageReceiver receiver)
{
if (receiver.transform ==
attackContext.Attacker.transform)
return;
if (receiver.transform.IsChildOf(
attackContext.Attacker.transform))
return;
if (!receiver.CanReceiveDamage)
return;
receiver.ReceiveDamage(attackContext.Damage);
}
}AttackContext
攻撃の情報をまとめる構造体
PlayerAttackは、攻撃した自分の情報と 与えるダメージ量をAttackContextに まとめてAttackResolverに渡します- 後に攻撃の属性や特殊効果などを追加する 際も、
AttackContextに情報を追加していく 形で対応できます - ダメージ処理に必要な情報が集約されるため 仕様の複雑化への防波堤としての役割も 期待できます
cs
public readonly struct AttackContext
{
public AttackContext(GameObject attacker, int damage)
{
Attacker = attacker;
Damage = damage;
}
public GameObject Attacker { get; }
public int Damage { get; }
}攻撃者自身にはダメージを与えない
AttackResolverで攻撃者を除外する
- Player自身に
DamageReceiverがあると 自分の攻撃が自分には当たりかねません PlayerAttackで除外しても構いませんが、 ここではAttackResolverに対応を任せて います- この場所が適切かは、一考の余地があります ゲームの仕様と照らして、何処でフィルター するのが適切か、都度判断していきましょう
cs
// 攻撃者と被弾した相手が同じ場合は処理をキャンセルする
if (receiver.transform == attackContext.Attacker.transform ||
receiver.transform.IsChildOf(attackContext.Attacker.transform))
return;PlayerController
入力を受け取って攻撃へ渡す
PlayerControllerは入力を読み取り、移動や 攻撃の担当Componentへ処理を渡します- ここでは攻撃ボタンが押された瞬間に
PlayerAttack.Attack()を呼んでいます - 攻撃の時間管理や当たり判定は
PlayerAttackの担当です - 攻撃不可能な場合もあるかもしれませんが その判定は
PlayerAttackの仕事になります
cs
public class PlayerController : MonoBehaviour
{
[SerializeField] private PlayerMove playerMove;
[SerializeField] private CameraMove cameraMove;
[SerializeField] private PlayerAttack playerAttack;
private InputSystem_Actions _inputActions;
private void Awake()
{
_inputActions = new InputSystem_Actions();
}
private void Update()
{
// 中略
// 攻撃の発動
if (_inputActions.Player.Attack.WasPressedThisFrame())
playerAttack.Attack();
}
}Player Attack に Collider を設定
攻撃に命中判定を付与する

- Playerの子Objectとして
Player Attackオブジェクトを作ります Player AttackにPlayerAttackコンポーネントを追加しますCapsule Colliderなどを追加してIs Triggerを有効にしますPlayerAttackのattackColliderに コライダーへの参照を設定して準備完了です
PlayerAttack
攻撃時間と判定時間を管理する
PlayerAttackは、プレイヤーの攻撃に関する 処理を担当します- まず、攻撃可能な時間と、攻撃判定が有効な 時間を管理します
- 一度の攻撃が同じ相手に複数回ヒットしない ように記録も行います
- 攻撃のエフェクトの再生リクエストも ここで行います
cs
public class PlayerAttack : MonoBehaviour
{
[SerializeField] private AttackEffect attackEffect;
[SerializeField] private Collider attackCollider;
[SerializeField] private int damage = 1;
[SerializeField] private float attackDuration = 0.5f;
[SerializeField] private float hitStartTime = 0.15f;
[SerializeField] private float hitEndTime = 0.3f;
private readonly List<DamageReceiver> _hitReceivers = new();
private bool _isAttacking;
private bool _isHitActive;
private float _attackElapsedTime;
}PlayerAttackの攻撃開始
Attackで攻撃、内部情報を初期化
cs
public class PlayerAttack : MonoBehaviour
{
// 中略
public void Attack()
{
attackEffect?.Play();
_hitReceivers.Clear();
_isAttacking = true;
_isHitActive = false;
_attackElapsedTime = 0f;
}
}Attack()は攻撃開始用の関数です 今回はPlayerControllerから呼ばれています- この時点ではまだColliderを有効にせず、
Updateで判定開始タイミングをチェックします - 攻撃演出があれば再生し、 前回命中した相手の記録を削除するなど 攻撃に必要な初期化をまとめて行います
PlayerAttackの時間管理
Updateで判定の開始と終了を制御する
- ゲームでの攻撃は、入力と同時に即時有効とは 限りません
- 次の攻撃までのクールダウン時間、 攻撃判定発生の遅延時間、 攻撃判定の有効時間などの管理が必要です
- これらの時間管理は、攻撃の開始と終了を 制御する
PlayerAttackの仕事になります
cs
public class PlayerAttack : MonoBehaviour
{
// 中略
private void Update()
{
if (!_isAttacking)
return;
_attackElapsedTime += Time.deltaTime;
if (!_isHitActive && _attackElapsedTime >= hitStartTime)
StartHit();
if (_isHitActive && _attackElapsedTime >= hitEndTime)
EndHit();
if (_attackElapsedTime >= attackDuration)
EndAttack();
}PlayerAttackの攻撃判定
Collider.enabledで 当たり判定を有効化/無効化
attackColliderは攻撃判定用のTrigger Colliderです- 攻撃していない時間はColliderが無効なので、 ダメージ判定は発生しません
- 迂遠な処理に見えますが、これで攻撃の演出と 実際に当たるタイミングを分けて調整できます
cs
public class PlayerAttack : MonoBehaviour
{
// 中略
private void Awake()
{
attackCollider.enabled = false;
}
private void StartHit()
{
_isHitActive = true;
attackCollider.enabled = true;
}
private void EndHit()
{
_isHitActive = false;
attackCollider.enabled = false;
}PlayerAttackの命中検出
OnTriggerEnterで DamageReceiverを探す
cs
public class PlayerAttack : MonoBehaviour
{
// 中略
private void OnTriggerEnter(Collider other)
{
var receiver =
other.GetComponentInParent<DamageReceiver>();
if (receiver == null)
return;
if (_hitReceivers.Contains(receiver))
return;
_hitReceivers.Add(receiver);
var attackContext =
new AttackContext(gameObject, damage);
AttackResolver.Resolve(attackContext, receiver);
}OnTriggerEnterは、攻撃判定Colliderに 他のコライダーが入った瞬間に呼ばれます- 今回はダメージを与える相手か否かを
DamageReceiverを持っているかで 判定しています - 見つかった場合は
AttackResolverに渡し、 実際にダメージを与えるかどうかの 判断を任せます
同じ攻撃で何度も当たる問題の回避
命中済みの相手を覚える
cs
private readonly List<DamageReceiver> _hitReceivers = new();- 敵が複数Colliderを持つと、1回の攻撃で複数回命中しかねません
- そこで1回の攻撃中に命中した相手をListに記録して、 すでに記録されている相手には同じ攻撃で再度ダメージを与えないようにします
- 他にも、攻撃の開始と終了でColliderを有効にする方法や、 攻撃のIDを付与して管理する方法などがあります
- ※ 実はListでの管理はあまり効率の良いやり方ではありません 他の方法がないかも考えてみてください
Enemy
最小構成で攻撃の的を作る
- 今回は移動も攻撃もしない、攻撃テスト用の 敵として仮に実装します
- ダメージの受け取りは
DamageReceiverに 任せ、HPが0になったら削除する処理だけをEnemyクラスで担当します
cs
[RequireComponent(typeof(DamageReceiver))]
public class Enemy : MonoBehaviour
{
private DamageReceiver _damageReceiver;
private void Awake()
{
_damageReceiver = GetComponent<DamageReceiver>();
}
private void Update()
{
if (_damageReceiver.IsDead)
Destroy(gameObject);
}
}プレイヤー側も DamageReceiver を持つ
次回以降の拡張に備える
- 今後の拡張に備えて
Playerクラスを新設し、 PlayerのHP管理や死亡処理を担当させます - 次回から敵から攻撃されるようにするため、 Playerにも
DamageReceiverを付けておきます
cs
[RequireComponent(typeof(DamageReceiver))]
public class Player : MonoBehaviour
{
// プレイヤーの体力や状態を管理する
private DamageReceiver _damageReceiver;
// 略接続の全体像
攻撃からHP減少まで
- プレイヤーの攻撃入力 →
- 攻撃の発生と命中範囲の有効化 →
- ヒットが判定されたら 攻撃の情報をAttackResolverに渡す →
- AttackResolverがダメージを 受け取るべき相手を判断 →
- DamageReceiverがダメージを反映
text
Player
├─ PlayerController(入力)
└─ Player Attack
├─ PlayerAttack(攻撃時間と命中処理)
└─ Collider(Is Trigger)
│ OnTriggerEnter
▼
AttackResolver
│
▼
Enemy
├─ DamageReceiver(Healthを保持)
└─ Enemy(死亡時にDestroy)Playerに必要な設定
攻撃する側を作る
- Playerに
PlayerControllerを追加します - Playerの子Objectとして
Player Attackを作ります Player AttackにPlayerAttackを追加しますPlayer AttackにColliderを追加しますPlayer AttackのColliderでIs Triggerを有効にしますPlayerAttackのattackColliderにPlayer AttackのColliderを割り当てます
Enemyに必要な設定
ダメージを受ける側を作る
- Enemy用のCubeやCapsuleを配置します
- EnemyにColliderが付いていることを確認します
- Enemyに
DamageReceiverを追加します - Enemyに
Enemyを追加します - PlayerまたはEnemyのどちらかにRigidbodyがあることを確認します
Enemy本体のColliderをTriggerにする必要はありません
よくある確認ポイント
攻撃が当たらないときは設定を見る
- 攻撃ボタンで
PlayerAttack.Attack()が呼ばれているか PlayerAttack.attackColliderにColliderが割り当てられているか- 攻撃判定Colliderの
Is Triggerが有効か - 攻撃中に
attackCollider.enabledがtrueになるか - EnemyにColliderが付いているか
- Enemyに
DamageReceiverが付いているか - PlayerまたはEnemyのどちらかにRigidbodyが付いているか
- Layer Collision Matrixで接触が無効になっていないか
実習
Playerの攻撃でEnemyにダメージを与える
Health、DamageReceiver、AttackContext、AttackResolverを 作りますPlayerAttackコンポーネントをPlayer Attackに追加しますEnemyコンポーネントを 攻撃テスト用Enemyに追加します- Playerの
PlayerControllerからPlayerAttack.Attack()を呼びます Player AttackのColliderをTriggerにし、attackColliderに割り当てます- Enemyに
DamageReceiverと Colliderがあることを確認します - 攻撃ボタンを押したときだけEnemyの HPが減ることを確認します
- EnemyのHPが0になったら 削除されることを確認します
追加課題
見た目とUIを追加する
余裕のある人向け
AttackEffect
剣本体を表示して振る

- 攻撃中だけ剣本体を表示し transform.localRotation で剣を振る
AttackEffectを追加しましょう - 任意の剣閃エフェクトを 再生する機能も追加してみましょう Materialを操作する
SlashEffectを 追加してこだわってみましょう - 攻撃判定とは関係ない、あくまで演出として 作成してみましょう
HealthGauge
敵の HP を表示する

- さらに余裕があれば HP ゲージを追加しましょう
DamageReceiverのDamagedイベントを 受け取って、HPの減少をUIに反映させます- エフェクトの実習で作ったゲージを 流用するのも良いでしょう
今日のポイント
攻撃、接触、HP管理を分けて考える
- Playerはボタン入力で攻撃を開始します
PlayerAttackは攻撃中だけ攻撃判定Colliderを有効にしますOnTriggerEnterでDamageReceiverを探しますAttackResolverは攻撃結果を解決しますDamageReceiverはダメージを受ける入口になります- HP計算は
Healthに任せます - 見た目やUIは、攻撃判定の基本が動いてから追加します
Health.cs
cs
using UnityEngine;
[System.Serializable]
// 体力の値とダメージ計算を管理する
public class Health
{
[Tooltip("最大体力")]
[SerializeField] private int maxHealth = 10;
// 現在の体力
public int CurrentHealth { get; private set; }
// 最大体力
public int MaxHealth => maxHealth;
// 体力が0以下なら死亡扱い
public bool IsDead => CurrentHealth <= 0;
public void Reset()
{
CurrentHealth = maxHealth;
}cs
public void TakeDamage(int damage)
{
// 0以下のダメージや死亡後のダメージは無視する
if (damage <= 0 || IsDead)
return;
CurrentHealth =
Mathf.Max(CurrentHealth - damage, 0);
}
public void Heal(int heal)
{
if (heal <= 0 || IsDead)
return;
CurrentHealth =
Mathf.Min(CurrentHealth + heal, maxHealth);
}
}DamageReceiver.cs (1/2)
cs
using System;
using UnityEngine;
// ダメージを受ける側の入口になるコンポーネント
public class DamageReceiver : MonoBehaviour
{
[Tooltip("このオブジェクトの体力")]
[SerializeField] private Health health = new();
public event Action<int, int> Damaged;
public event Action<int, int> Healed;
// ダメージを受けられる状態かどうか
public bool CanReceiveDamage => !health.IsDead;
// 体力がなくなっているかどうか
public bool IsDead => health.IsDead;
// 現在の体力
public int CurrentHealth => health.CurrentHealth;
// 最大体力
public int MaxHealth => health.MaxHealth;cs
private void Awake()
{
health.Reset();
}
// ダメージを受ける
public void ReceiveDamage(int damage)
{
var previousHealth = CurrentHealth;
// 体力の増減はHealthに任せる
health.TakeDamage(damage);
if (CurrentHealth == previousHealth)
return;
Damaged?.Invoke(
previousHealth - CurrentHealth,
MaxHealth);
}DamageReceiver.cs (2/2)
cs
public class DamageReceiver : MonoBehaviour
{
// 前半は前ページ
// 回復を受ける
// Note: 次回以降の予約で今回は使わない
public void ReceiveHeal(int heal)
{
var previousHealth = CurrentHealth;
// 体力の増減はHealthに任せる
health.Heal(heal);
if (CurrentHealth == previousHealth)
return;
Healed?.Invoke(
CurrentHealth - previousHealth,
MaxHealth);
}
}AttackContext.cs / AttackResolver.cs
cs
using UnityEngine;
// 1回の攻撃で使う情報をまとめる
public readonly struct AttackContext
{
public AttackContext(
GameObject attacker,
int damage)
{
Attacker = attacker;
Damage = damage;
}
// 攻撃した側のGameObject
public GameObject Attacker { get; }
// この攻撃で与えるダメージ量
public int Damage { get; }
}cs
// 攻撃が命中したあと、
// 実際にダメージを与えるかを判定する
public static class AttackResolver
{
public static void Resolve(
AttackContext attackContext,
DamageReceiver receiver)
{
// 自分自身にはダメージを与えない
if (receiver.transform ==
attackContext.Attacker.transform ||
receiver.transform.IsChildOf(
attackContext.Attacker.transform))
return;
// ダメージを受けられない状態なら何もしない
if (!receiver.CanReceiveDamage)
return;
// ToDo: 必要ならここで状況別のダメージ計算
receiver.ReceiveDamage(attackContext.Damage);
}
}PlayerAttack.cs (1/3)
cs
using System.Collections.Generic;
using UnityEngine;
// プレイヤーの攻撃判定を管理する
public class PlayerAttack : MonoBehaviour
{
[Tooltip("攻撃時に再生する見た目の演出")]
[SerializeField] private AttackEffect attackEffect;
[Tooltip("攻撃中だけ有効にする当たり判定")]
[SerializeField] private Collider attackCollider;
[Tooltip("攻撃が命中した相手に与えるダメージ")]
[SerializeField] private int damage = 1;
[Tooltip("攻撃全体の時間")]
[SerializeField] private float attackDuration = 0.5f;
[Tooltip("攻撃開始から当たり判定を有効にするまでの時間")]
[SerializeField] private float hitStartTime = 0.15f;cs
[Tooltip("攻撃開始から当たり判定を無効にするまでの時間")]
[SerializeField] private float hitEndTime = 0.3f;
private readonly List<DamageReceiver> _hitReceivers = new();
private bool _isAttacking;
private bool _isHitActive;
private float _attackElapsedTime;
private void Awake()
{
attackCollider.enabled = false;
}
private void Update()
{
if (!_isAttacking)
return;
_attackElapsedTime += Time.deltaTime;PlayerAttack.cs (2/3)
cs
if (!_isHitActive &&
_attackElapsedTime >= hitStartTime)
StartHit();
if (_isHitActive &&
_attackElapsedTime >= hitEndTime)
EndHit();
if (_attackElapsedTime >= attackDuration)
EndAttack();
}
// 攻撃の呼び出し
public void Attack()
{
attackEffect?.Play();
// 前回命中した相手の記録をクリアしてから攻撃を開始する
_hitReceivers.Clear();
_isAttacking = true;
_isHitActive = false;
_attackElapsedTime = 0f;
}cs
private void OnTriggerEnter(Collider other)
{
var receiver =
other.GetComponentInParent<DamageReceiver>();
// 攻撃の命中対象しか扱わない
if (receiver == null)
return;
// 1回の攻撃で同じ相手に何度もダメージを与えない
if (_hitReceivers.Contains(receiver))
return;
// 被弾済みの相手の登録
_hitReceivers.Add(receiver);
// 攻撃の効果をAttackResolverに任せて解決
var attackContext =
new AttackContext(gameObject, damage);
AttackResolver.Resolve(attackContext, receiver);
}PlayerAttack.cs (3/3)
cs
private void EndAttack()
{
_isAttacking = false;
EndHit();
}
private void StartHit()
{
_isHitActive = true;
attackCollider.enabled = true;
}
private void EndHit()
{
_isHitActive = false;
attackCollider.enabled = false;
}
}PlayerController.cs
cs
using UnityEngine;
public class PlayerController : MonoBehaviour
{
[SerializeField] private PlayerMove playerMove;
[SerializeField] private CameraMove cameraMove;
[SerializeField] private PlayerAttack playerAttack;
private InputSystem_Actions _inputActions;
private void Awake()
{
_inputActions = new InputSystem_Actions();
}
private void OnEnable()
{
_inputActions.Player.Enable();
}
private void OnDisable()
{
_inputActions.Player.Disable();
}cs
private void Update()
{
var moveInput =
_inputActions.Player.Move.ReadValue<Vector2>();
var lookInput =
_inputActions.Player.Look.ReadValue<Vector2>();
// カメラを回す
cameraMove.Look(lookInput);
// カメラの向きを基準に移動方向を作る
var forward = cameraMove.PlanarForward;
var right = cameraMove.PlanarRight;
var direction =
forward * moveInput.y + right * moveInput.x;
direction = Vector3.ClampMagnitude(direction, 1f);
// 実際の移動はPlayerMoveに任せる
playerMove.Move(direction);
// 攻撃の発動
if (_inputActions.Player.Attack.WasPressedThisFrame())
playerAttack.Attack();
}
}Enemy.cs
cs
using UnityEngine;
// 攻撃の的になる最小構成の敵
[RequireComponent(typeof(DamageReceiver))]
public class Enemy : MonoBehaviour
{
[Tooltip("この敵の体力を表示するゲージ")]
[SerializeField] private HealthGauge healthGauge;
private DamageReceiver _damageReceiver;
private void Awake()
{
_damageReceiver =
GetComponent<DamageReceiver>();
}
private void OnEnable()
{
_damageReceiver =
GetComponent<DamageReceiver>();
_damageReceiver.Damaged += OnDamaged;
_damageReceiver.Healed += OnHealed;
}cs
private void OnDisable()
{
_damageReceiver.Damaged -= OnDamaged;
_damageReceiver.Healed -= OnHealed;
}
private void Update()
{
if (_damageReceiver.IsDead)
Destroy(gameObject);
}
private void OnDamaged(int damage, int maxHealth)
{
healthGauge.TakeDamage((float)damage / maxHealth);
}
private void OnHealed(int heal, int maxHealth)
{
healthGauge.Heal((float)heal / maxHealth);
}
}Player.cs
cs
using UnityEngine;
// プレイヤーの体力や状態を管理する
[RequireComponent(typeof(DamageReceiver))]
public class Player : MonoBehaviour
{
private DamageReceiver _damageReceiver;
[Tooltip("プレイヤーの体力を表示するゲージ")]
[SerializeField] private HealthGauge healthGauge;
private void Awake()
{
_damageReceiver =
GetComponent<DamageReceiver>();
}
private void OnEnable()
{
_damageReceiver =
GetComponent<DamageReceiver>();
_damageReceiver.Damaged += OnDamaged;
_damageReceiver.Healed += OnHealed;
}cs
private void OnDisable()
{
_damageReceiver.Damaged -= OnDamaged;
_damageReceiver.Healed -= OnHealed;
}
private void OnDamaged(int damage, int maxHealth)
{
healthGauge.TakeDamage((float)damage / maxHealth);
}
private void OnHealed(int heal, int maxHealth)
{
healthGauge.Heal((float)heal / maxHealth);
}
}AttackEffect.cs (1/2)
cs
using System.Collections;
using UnityEngine;
// 攻撃中に剣本体を表示して振る
public class AttackEffect : MonoBehaviour
{
[Tooltip("攻撃演出を再生する時間")]
[SerializeField] private float duration = 0.2f;
[Tooltip("攻撃演出を開始するときの角度")]
[SerializeField] private float angleFrom = 90;
[Tooltip("攻撃演出を終了するときの角度")]
[SerializeField] private float angleTo = 270;
[Tooltip("攻撃中だけ表示する剣本体のRenderer")]
[SerializeField] private MeshRenderer swordMeshRenderer;
[Tooltip("任意で再生する剣閃エフェクト")]
[SerializeField] private SlashEffect slashEffect;cs
private Coroutine _playCoroutine;
public bool IsPlaying => _playCoroutine != null;
private void Awake()
{
SetVisible(false);
}
public void Play()
{
if (_playCoroutine != null)
StopCoroutine(_playCoroutine);
// 連続で攻撃した場合は、
// 演出を最初から再生し直す
_playCoroutine = StartCoroutine(PlayCoroutine());
}AttackEffect.cs (2/2)
cs
private IEnumerator PlayCoroutine()
{
SetVisible(true);
slashEffect?.Play(duration);
var elapsedTime = 0f;
while (true)
{
var progress =
Mathf.Clamp01(elapsedTime / duration);
transform.localRotation =
Quaternion.Euler(
0f,
Mathf.Lerp(angleFrom, angleTo, progress),
0f);
yield return null;
// ラスト1フレームを表示するため評価を遅延している
if(progress >= 1f)
break;
elapsedTime += Time.deltaTime;
}cs
SetVisible(false);
_playCoroutine = null;
}
private void SetVisible(bool isVisible)
{
swordMeshRenderer.enabled = isVisible;
}
private void OnDisable()
{
if (_playCoroutine != null)
{
StopCoroutine(_playCoroutine);
_playCoroutine = null;
}
SetVisible(false);
}
}SlashEffect.cs (1/2)
cs
using System.Collections;
using UnityEngine;
// 剣閃シェーダーを使った発展的な攻撃エフェクト
public class SlashEffect : MonoBehaviour
{
private static readonly int MagId =
Shader.PropertyToID("_Mag");
[Tooltip("剣閃エフェクトの広がり方")]
[SerializeField] private AnimationCurve magnitudeCurve =
AnimationCurve.Linear(0f, 0f, 1f, 1f);
[Tooltip("攻撃中だけ表示する剣閃エフェクトのRenderer")]
[SerializeField] private MeshRenderer slashMeshRenderer;
private Material _slashMaterial;
private Coroutine _playCoroutine;cs
private void Awake()
{
_slashMaterial = slashMeshRenderer.material;
SetMagnitude(0f);
SetVisible(false);
}
public void Play(float duration)
{
if (_playCoroutine != null)
StopCoroutine(_playCoroutine);
_playCoroutine =
StartCoroutine(PlayCoroutine(duration));
}
private IEnumerator PlayCoroutine(float duration)
{
SetVisible(true);
var elapsedTime = 0f;SlashEffect.cs (2/2)
cs
while (elapsedTime < duration)
{
var progress = elapsedTime / duration;
SetMagnitude(
magnitudeCurve.Evaluate(progress));
elapsedTime += Time.deltaTime;
yield return null;
}
SetMagnitude(magnitudeCurve.Evaluate(1f));
SetVisible(false);
SetMagnitude(0f);
_playCoroutine = null;
}
private void SetMagnitude(float value)
{
_slashMaterial.SetFloat(MagId, value);
}cs
private void SetVisible(bool isVisible)
{
slashMeshRenderer.enabled = isVisible;
}
private void OnDisable()
{
if (_playCoroutine != null)
{
StopCoroutine(_playCoroutine);
_playCoroutine = null;
}
SetVisible(false);
SetMagnitude(0f);
}
private void OnDestroy()
{
Destroy(_slashMaterial);
}
}HealthGauge.cs
cs
using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(RectTransform))]
public class HealthGauge : MonoBehaviour
{
[SerializeField] private Image front;
[SerializeField] private Image back;
[SerializeField] private float shakeMagnitude = 1f;
private float _hp = 1f;
private RectTransform _rectTransform;
// ダメージを受ける
public void TakeDamage(float damage)
{
_hp = Mathf.Clamp(_hp - damage, 0f, 1f);
var sequence = DOTween.Sequence();cs
sequence.
Append(front.DOFillAmount(_hp, 0.2f)).
Join(_rectTransform.DOShakePosition(
0.5f, 10f * shakeMagnitude, 20)).
SetDelay(0.1f).
Append(back.DOFillAmount(_hp, 0.5f));
}
// 回復する
public void Heal(float heal)
{
_hp = Mathf.Clamp(_hp + heal, 0f, 1f);
var sequence = DOTween.Sequence();
sequence.Append(front.DOFillAmount(_hp, 0.5f));
sequence.Join(back.DOFillAmount(_hp, 0.5f));
}
private void Awake()
{
_rectTransform = GetComponent<RectTransform>();
}
}