ファイナル判定をやっつける（すり抜け、多段ヒット対策）

# はじめに

この記事は『[Unity Advent Calendar 2021](https://qiita.com/advent-calendar/2021/unity)』の21日目の記事です。

## 完成品

![Animation3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/18d8c747-4471-d0a6-3e3b-24db1db5bf85.gif)

すり抜け対策（当たり判定の補完）
![Animation6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/1d534e33-4e87-d7f5-22c2-a49afaf6b07a.gif)


## ファイナル判定とは？

ここでは、下記のようなガバガバあたり判定を指します。

- 攻撃がすり抜ける
- 逆に1度の攻撃が何度もヒットする

https://dic.nicovideo.jp/a/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%8A%E3%83%AB%E3%82%BD%E3%83%BC%E3%83%89

特にすり抜けはUnityで素直に実装すると陥りがちな罠ですね。
![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/45487fa1-92ba-dbc4-b88b-ea7ec3670899.gif)

今回は上記のような現象の対処方法をご紹介します。

## ライセンス表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/1b0c45d9-44a2-9662-00fc-e3fd66b20495.png)
[ユニティちゃんライセンス条項](https://unity-chan.com/contents/license_jp/)の元、アセットを利用しました。

# まずは素直に実装してみる
とりあえずファイナル判定を実装してみます。

## 武器を持たせる
武器を持たせるためには、アニメーションに合わせた位置に武器を持たせることが大切です。
アニメーションウィンドウから武器装備中のアニメーションを選択し、Previewボタンをクリックしてください。

下記のようにPreview状態になれば準備OKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/464d8234-a7b4-0af0-ac43-cea3df71f776.png)

右手を探して子オブジェクトとして武器を配置します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/afc30ef7-7bee-9665-7888-122dfe173fe2.png)

これで大体一致しますが、おそらく少しずれているので微調整しておいてください。

## 攻撃スクリプトの実装
武器にコライダをアタッチし、トリガーとして設定します。
※スクショに写っていませんが、rigidbodyもアタッチしましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/e9693a92-bbb7-8aa0-8230-80bea20ab19a.png)

**rigidbody設定**
赤枠箇所の設定が大切です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/2c9338d5-f882-869a-a2d7-e2566da8082c.png)


下記関数を含むスクリプトを実装します。

- 攻撃処理開始処理（AnimationEventで呼び出す）
- 攻撃命中時の処理


```C#:実装例
    /// <summary>
    /// 命中時エフェクト
    /// </summary>
    public GameObject hitEffect;

    /// <summary>
    /// コライダ
    /// </summary>
    private Collider col;

    void Start()
    {
        col = GetComponent<BoxCollider>();
    }

    /// <summary>
    /// 攻撃開始処理
    /// </summary>
    public void Attack(){
        // 当たり判定持続時間を雑に実装するためコルーチン化
        StartCoroutine("attack");
    }

    /// <summary>
    /// 攻撃開始実処理
    /// </summary>
    IEnumerator attack()
    {
        col.enabled = true;
        // 当たり判定持続時間を雑に実装
        yield return new WaitForSeconds(0.2f);
        col.enabled = false;
    }

    /// <summary>
    /// 命中時処理
    /// </summary>
    /// <param name="other"></param>
    void OnTriggerEnter(Collider other){ 
        Debug.Log("Hit!");
        // ヒット箇所を計算してエフェクトを表示する
        Vector3 hitPos = other.ClosestPointOnBounds(col.bounds.center);
        GameObject effectIstance = Instantiate(hitEffect, hitPos, Quaternion.identity);
        Destroy(effectIstance, 1);
    }

```

これで攻撃側はいったんOK。
続いてテスト用の敵を作成しましょう。

## 敵に判定を実装
Animationに応じて判定も動くように、ボーンごとに設定していきましょう。
また、Meshコライダーは動作が重いため、プリミティブコライダで設定していくことが推奨されます。

今回は「頭」「首」「体」「足」「脚」て感じで簡単に設定してみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/b16fc978-ca5e-3dad-56dd-257725bfa1c4.png)

## 実際に動かしてみる
ちゃんと動いているように見えます！
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/728c206b-0fa9-bff2-4b61-93a454ac28a6.gif)

しかし問題が…
### 1回の攻撃で複数回のHitを確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/f36a29ea-011e-6af3-cf90-70c2fd0a5433.png)

### すり抜けることも…
![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/45487fa1-92ba-dbc4-b88b-ea7ec3670899.gif)



# 多段ヒット対策
## 原因
アニメーションに合わせるために部位を小分けにした結果、複数の部位に命中判定が発生しているためです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/b5d91713-157f-3683-d92b-959a9105a90b.png)

このように「首」や「前脚」に判定が重なっていることが読み取れますね。

## 解決方法
共通する親を持つオブジェクトに対しては、命中時に処理しないようにしましょう。

### 攻撃受付スクリプト（敵用）の実装

```C#:親オブジェクト用
public class HitMaster : MonoBehaviour
{
    public void TakeDamage(){
        // ダメージを受ける処理とか
    }
}
```

```C#:子オブジェクト用
public class HitZone : MonoBehaviour
{
    /// <summary>
    /// 親スクリプト公開
    /// </summary>
    public HitMaster Master => master;
    HitMaster master;

    void Start()
    {
        master = GetComponentInParent<HitMaster>();
    }
}
```

敵のルートにHitMasterをアタッチします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/24a416af-2e22-3fc4-afee-430a0334a383.png)

コライダーを設定した部位それぞれにHitZoneをアタッチします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/a6623c3f-3014-26ec-2cfb-57ea3ac7cf65.png)

:::note info
まとめて設定するにはタイプ検索を利用すると便利です。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/99a15f70-ec31-38b5-0ce9-fbd2ebd7dde6.png)

### 攻撃側スクリプトの修正

```C#:武器スクリプトの修正：プロパティの追加
    /// <summary>
    /// 当たり判定処理済みを記録
    /// </summary>
    private Dictionary<int, bool> hitMasters { get; } = new Dictionary<int, bool>();
```


```C#:武器スクリプトの修正：攻撃開始実処理の修正
     IEnumerator attack()
    {
        hitMasters.Clear(); // 追加
        col.enabled = true;
        yield return new WaitForSeconds(0.2f);
        col.enabled = false;
    }
```

```C#:武器スクリプトの修正：攻撃命中時の処理修正
    void OnTriggerEnter(Collider other){ 
        // 追加
        // 攻撃対象部位ならHitZoneが取得できる
        var hitZone = other.GetComponent<HitZone>();
        if(hitZone == null) return;

        // 攻撃対象部位の親のインスタンスIDで重複した攻撃を判定
        int masterId = hitZone.Master.GetInstanceID();
        if(hitMasters.ContainsKey(masterId)) return;
        hitMasters[masterId] = true;

        Debug.Log("Hit!");
        // ダメージ計算とかこのへんで実装できますねぇ
        hitZone.Master.TakeDamage();
        // ヒット箇所を計算してエフェクトを表示する（前回から特に変更なし）
        Vector3 hitPos = other.ClosestPointOnBounds(col.bounds.center);
        GameObject effectIstance = Instantiate(hitEffect, hitPos, Quaternion.identity);
        Destroy(effectIstance, 1);
    }
```
### 実行結果
1回の攻撃につき、最初に触れた1部位に1度だけ命中するようになりました！
![Animation3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/18d8c747-4471-d0a6-3e3b-24db1db5bf85.gif)

# すり抜け対策
## 原因
実行速度を1/10に落として、当たり判定を可視化してみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/52d1284e-f29c-2f46-6eac-f313d7153954.png)

![Animation4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/7897bf62-346a-8468-3003-edcde78ac486.gif)

物理演算は30fpsと控えめの設定とはいえ…
なるほど。これはすり抜ける訳だ。

※アニメーションが滑らかなのは、スロー再生により実質600fpsで更新されているため。
　同設定で「Animator」の「UpdateMode」を「Animate Physics」にするとカクカクになります。

## 解決方法

物理演算の処理間隔を短くするという方法もありますが、60fpsでもすり抜けはゼロになりません。
また、設定に比例して処理が重くなってしまいます。

そこで、中間部分のあたり判定を動的に生成して補完してあげることにしました。
### 準備
まずは武器に直接アタッチしていた攻撃用スクリプト、コライダーを無効化します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/3a4a4779-b043-8e1d-7829-a8efc3dd82f4.png)

兄弟オブジェクト「WeaponCollider」を生成して、
攻撃用スクリプト、コライダー、rigidbodyをコピペ、有効化してあげましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/9146f951-8146-9cbe-46c0-1b33bad72796.png)

次に「WeaponCollider」の子オブジェクトとして切っ先の位置を表す「TipPosition」を生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/3820ce2d-b0b4-c2cc-a1d3-ec416a5b0f86.png)

これで準備OKです。
スクリプトを実装しましょう。

### 動的コライダ生成の実装

```C#:当たり判定補完スクリプト
using UnityEngine;
[RequireComponent(typeof(MeshFilter), typeof(MeshCollider), typeof(Rigidbody))]
public class ComplementCollider : MonoBehaviour {
    /// <summary>
    /// 切っ先部分の位置追跡用
    /// </summary>
    public Transform TipPosition;

    /// <summary>
    /// メッシュ生成用
    /// </summary>
    Mesh mesh;

    /// <summary>
    /// メッシュコライダ
    /// </summary>
    MeshCollider meshCollider;

    /// <summary>
    /// 前フレーム切っ先位置
    /// </summary>
    Vector3 lastTipPosition;

    /// <summary>
    /// 前フレーム
    /// </summary>
    Vector3 lastPosition;

    void Start () {
        mesh = GetComponent<MeshFilter>().mesh;
        meshCollider = GetComponent<MeshCollider>();
        // トリガーとして設定する
        meshCollider.convex = true;
        meshCollider.isTrigger = true;
        // メッシュを指定
        meshCollider.sharedMesh = mesh;
        // 前回位置の初期化
        lastTipPosition = TipPosition.position;
        lastPosition = transform.position;
    }

    void FixedUpdate () {
        // 前回位置と同じ時は処理しない（エラーになる）
        if(lastPosition == transform.position)
            return;
        if(lastTipPosition == TipPosition.position)
            return;

        // 毎フレームメッシュを作り直す
        mesh.Clear ();

        // 前回位置と今回位置の中間位置を補完する（こうしないと中間部分の判定が短くなる）
        var colliderLength = (TipPosition.position - transform.position).magnitude;
        var startMidPoint = Vector3.Lerp(transform.position, lastPosition, 0.5f);
        var endMidPoint = Vector3.Lerp(TipPosition.position, lastTipPosition, 0.5f);
        var assumedMidEndPoint = startMidPoint + (endMidPoint - startMidPoint).normalized * colliderLength;

        // 頂点（ローカル座標に変換して設定）
        mesh.vertices = new Vector3[] {
            transform.InverseTransformPoint(lastPosition), transform.InverseTransformPoint(lastTipPosition),
            transform.InverseTransformPoint(startMidPoint), transform.InverseTransformPoint(assumedMidEndPoint),
            transform.InverseTransformPoint(transform.position), transform.InverseTransformPoint(TipPosition.position),
        };

        // さっきの頂点を順に指定して三角形をつくる
        mesh.triangles = new int[]{
            0, 1, 2,
            1, 2, 3,
            2, 3, 4,
            3, 4, 5,
        };

        // 反映
        meshCollider.enabled = false;
        meshCollider.enabled = true;

        // 前回位置の記憶
        lastTipPosition = TipPosition.position;
        lastPosition = transform.position;
    }
}
```

### スクリプトをアタッチ
上記スクリプトを「WeaponCollider」オブジェクトにアタッチし先ほど生成した「TipPosition」を指定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/55ea298f-7a0a-0235-4609-2a018e317eb4.png)


### 実行結果
![Animation6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1054787/1d534e33-4e87-d7f5-22c2-a49afaf6b07a.gif)

前回の位置から今回の位置までが補完されるようになりました！
これで30FPS設定でもすり抜けることはないでしょう。

# さいごに
いざ自分でゲームを作ってみると、簡単そうに見える部分も実は難しくて、時間がいくらあっても足りません。
最後まで作り上げた某ファイナルソードは、（品質はともかく）とてもスゴいと思います。



分かりづらい点、ソースコードの改善提案などあればコメントください！
思ったより長い記事になってしまいましたが、最後までご覧いただきありがとうございました。

## 使用アセット（一部有料）

https://unity-chan.com/

https://assetstore.unity.com/packages/3d/props/weapons/free-low-poly-swords-rpg-weapons-198166

https://assetstore.unity.com/packages/vfx/particles/hit-slashes-vol-3-91648

https://assetstore.unity.com/packages/3d/animations/frank-slash-pack-5-2-katana-assassin-warrior-dual-2handed-spear--141184

https://assetstore.unity.com/packages/3d/characters/creatures/dragons-45330


※アフィ含みません

## 環境

下記の環境で撮影しました。
`Unity:2020.3.20f1`