はじめに
この記事は『Unity Advent Calendar 2021』の21日目の記事です。
完成品
ファイナル判定とは?
ここでは、下記のようなガバガバあたり判定を指します。
- 攻撃がすり抜ける
- 逆に1度の攻撃が何度もヒットする
特にすり抜けはUnityで素直に実装すると陥りがちな罠ですね。
今回は上記のような現象の対処方法をご紹介します。
ライセンス表示
ユニティちゃんライセンス条項の元、アセットを利用しました。
まずは素直に実装してみる
とりあえずファイナル判定を実装してみます。
武器を持たせる
武器を持たせるためには、アニメーションに合わせた位置に武器を持たせることが大切です。
アニメーションウィンドウから武器装備中のアニメーションを選択し、Previewボタンをクリックしてください。
これで大体一致しますが、おそらく少しずれているので微調整しておいてください。
攻撃スクリプトの実装
武器にコライダをアタッチし、トリガーとして設定します。
※スクショに写っていませんが、rigidbodyもアタッチしましょう
下記関数を含むスクリプトを実装します。
- 攻撃処理開始処理(AnimationEventで呼び出す)
- 攻撃命中時の処理
/// <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コライダーは動作が重いため、プリミティブコライダで設定していくことが推奨されます。
今回は「頭」「首」「体」「足」「脚」て感じで簡単に設定してみました。
実際に動かしてみる
しかし問題が…
1回の攻撃で複数回のHitを確認
すり抜けることも…
多段ヒット対策
原因
アニメーションに合わせるために部位を小分けにした結果、複数の部位に命中判定が発生しているためです。
このように「首」や「前脚」に判定が重なっていることが読み取れますね。
解決方法
共通する親を持つオブジェクトに対しては、命中時に処理しないようにしましょう。
攻撃受付スクリプト(敵用)の実装
public class HitMaster : MonoBehaviour
{
public void TakeDamage(){
// ダメージを受ける処理とか
}
}
public class HitZone : MonoBehaviour
{
/// <summary>
/// 親スクリプト公開
/// </summary>
public HitMaster Master => master;
HitMaster master;
void Start()
{
master = GetComponentInParent<HitMaster>();
}
}
コライダーを設定した部位それぞれにHitZoneをアタッチします。
まとめて設定するにはタイプ検索を利用すると便利です。
攻撃側スクリプトの修正
/// <summary>
/// 当たり判定処理済みを記録
/// </summary>
private Dictionary<int, bool> hitMasters { get; } = new Dictionary<int, bool>();
IEnumerator attack()
{
hitMasters.Clear(); // 追加
col.enabled = true;
yield return new WaitForSeconds(0.2f);
col.enabled = false;
}
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度だけ命中するようになりました!
すり抜け対策
原因
実行速度を1/10に落として、当たり判定を可視化してみました。
物理演算は30fpsと控えめの設定とはいえ…
なるほど。これはすり抜ける訳だ。
※アニメーションが滑らかなのは、スロー再生により実質600fpsで更新されているため。
同設定で「Animator」の「UpdateMode」を「Animate Physics」にするとカクカクになります。
解決方法
物理演算の処理間隔を短くするという方法もありますが、60fpsでもすり抜けはゼロになりません。
また、設定に比例して処理が重くなってしまいます。
そこで、中間部分のあたり判定を動的に生成して補完してあげることにしました。
準備
まずは武器に直接アタッチしていた攻撃用スクリプト、コライダーを無効化します。
兄弟オブジェクト「WeaponCollider」を生成して、
攻撃用スクリプト、コライダー、rigidbodyをコピペ、有効化してあげましょう。
次に「WeaponCollider」の子オブジェクトとして切っ先の位置を表す「TipPosition」を生成します。
これで準備OKです。
スクリプトを実装しましょう。
動的コライダ生成の実装
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」を指定します。
実行結果
前回の位置から今回の位置までが補完されるようになりました!
これで30FPS設定でもすり抜けることはないでしょう。
さいごに
いざ自分でゲームを作ってみると、簡単そうに見える部分も実は難しくて、時間がいくらあっても足りません。
最後まで作り上げた某ファイナルソードは、(品質はともかく)とてもスゴいと思います。
分かりづらい点、ソースコードの改善提案などあればコメントください!
思ったより長い記事になってしまいましたが、最後までご覧いただきありがとうございました。
使用アセット(一部有料)
※アフィ含みません
環境
下記の環境で撮影しました。
Unity:2020.3.20f1