[Unity]インパクトの瞬間がカッコ良くなるヒットストップ表現をやってみる


https://twitter.com/flankids/status/1471634478963781638

# はじめに

**[グレンジ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/grenge)** 17日目担当の、flankids です。
普段はインプット管理、カメラワーク作り、アニメーション制御などなどで遊び心地を作ることを主にやってるエンジニアをしています。

今回は、バトル系のゲームやスポーツゲームなど、インパクトの瞬間を気持ちよくする王道のテクニックである、 **ヒットストップの表現** をUnityでやってみました。
**もし気に入っていただけたら、LGTM(いいね)やストックをしてもらえるとスゴく嬉しいです！**

# ヒットストップなしの状態

https://twitter.com/flankids/status/1471634063840940034

まずはヒットストップ以外でできるダメージ表現を入れた状態です。

* ダメージを受けたキャラクターが赤く発光する
* ダメージモーションを再生する
* ヒットエフェクトを生成する

ダメージを与えたことは十分伝わりますが、動きの重みや爽快感を伝えるにはもう一歩足りない感じがします。

# プレイヤーのヒットストップを実装

https://twitter.com/flankids/status/1471634115527311360

攻撃がヒットした瞬間にプレイヤーの動きを一瞬止める実装を入れました。
**動きの重み**が少し加えられた感じがします。

```Player.cs
/// <summary> ヒットストップ時間(秒) </summary>
public float HitStopTime = 0.23f;

[SerializeField]
private Animator _anim;

public void OnAttackHit()
{
    // モーションを止める
    _anim.speed = 0f;

    var seq = DOTween.Sequence();
    seq.SetDelay(HitStopTime);
    // モーションを再開
    seq.AppendCallback(() => _anim.speed = 1f);
}
```

`Animator`の動きを一時的に止めるには、`speed`で0を指定するのが手っ取り早いです。
攻撃がヒットしたタイミングで動きを止め、指定した時間で再開されるようにDOTweenや`deltaTime`のカウントで制御することでヒットストップを実現することができます。

しかし、止まっているのがプレイヤーだけなのでちょっと違和感ありますね。

**【補足】**
`Animator`の`speed`はいろんな用途で使うため役割のバッティングを避けるためには専用の変数を用意して他のスピード設定と共存できるようにしたり、`Update`メソッドを独自に呼び出すようにしたりなど、大きな設計をする際は配慮が必要になるとは思います。

また、コンポーネントの`enable`をfalseにすることでも一時停止はできますが、trueに戻した時にStateの頭から再生されなおしてしまうため、今回のような用途には不向きです。

# 敵のヒットストップを実装

https://twitter.com/flankids/status/1471634202236190722

プレイヤーが止まるのと同じだけ、敵のリアクションも止まるように実装を入れました。
具体的にはダメージモーションと、ダメージによって起きる結果（吹っ飛びやキル）を遅延させています。

```Enemy.cs
[SerializeField]
private Player _player;

[SerializeField]
private ParticleSystem _hitEffect;

private void Damage(Vector3 hitPosition, Vector3 forward)
{
    var seq = DOTween.Sequence();
    // プレイヤーの持つヒットストップ分、演出再生を遅延させる
    seq.SetDelay(_player.HitStopTime); // ← ポイント！
    // 遅延を待った後、吹っ飛ぶ演出を再生
    var backPosition = transform.position + forward.normalized * 1f;
    seq.Append(transform.DOMove(backPosition, 0.2f));

    // ダメージモーション再生
    _anim.CrossFade("Damage", 0.2f);

    // ヒットエフェクトを生成
    var effectPos = transform.position - forward * 0.1f;
    Instantiate(_hitEffect, effectPos, Quaternion.identity);
}
```

基本的にはプレイヤーのモーションを止めるのと同じ仕組みです！

### ■振動演出

https://twitter.com/flankids/status/1471634277679104001

ヒットストップ中は強い衝撃が加わってることを表現するため、ダメージを受けたキャラクターが震えるような動きを入れるとよりそれっぽくなります。

```Enemy.cs
private void Damage(Vector3 hitPosition, Vector3 forward)
{
    var seq = DOTween.Sequence();
    // 演出遅延の代わりに振動演出を入れる
    seq.Append(transform.DOShakePosition(_player.HitStopTime, 0.15f, 25, fadeOut: false)); // ← ポイント！
    // 遅延を待った後、吹っ飛ぶ演出を再生
    var backPosition = transform.position + forward.normalized * 1f;
    seq.Append(transform.DOMove(backPosition, 0.2f));

    // ダメージモーション再生
    _anim.CrossFade("Damage", 0.2f);

    // ヒットエフェクトを生成
    var effectPos = transform.position - forward * 0.1f;
    Instantiate(_hitEffect, effectPos, Quaternion.identity);
}
```

### ■ダメージ演出スキップ

ダメージモーションの止め方は少し工夫の余地があります。
単にモーションの再生を止めてしまうと、ダメージ直前のポーズで固まってしまい、リアクションが薄く見えてしまいます。
そこで、ダメージモーションの思いっきりのけぞってる部分など、**一番「効いてる感」のあるフレームでヒットストップ状態になるよう、Animationの進捗を飛ばすとより良く見えます。**

同様に、ヒットエフェクトも停止こそさせないですが、ヒットストップの瞬間から十分視認できるところまでフレームを飛ばすと見栄えが良くなります。

https://twitter.com/flankids/status/1471634374336847875

```Enemy.cs
private void Damage(Vector3 hitPosition, Vector3 forward)
{
    var seq = DOTween.Sequence();
    // 演出遅延の代わりに振動演出を入れる
    seq.Append(transform.DOShakePosition(_player.HitStopTime, 0.15f, 25, fadeOut: false));
    // 遅延を待った後、吹っ飛ぶ演出を再生
    var backPosition = transform.position + forward.normalized * 1f;
    seq.Append(transform.DOMove(backPosition, 0.2f));

    // ダメージモーションを全体の20%の位置からブレンド時間０秒で再生
    _anim.CrossFade("Damage", 0f, 0, 0.2f); // ← ポイント！

    // ヒットエフェクトを生成
    var effectPos = transform.position - forward * 0.1f;
    var effect = Instantiate(_hitEffect, effectPos, Quaternion.identity);
    // ヒットエフェクトを発生後0.2秒時点から再生する
    effect.Simulate(0.2f, true, true); // ← ポイント！
    effect.Play();
}
```

ダメージ演出という短い期間の中での話ではありますが、**ヒットストップは全体動作の中で一番見られる瞬間を切り取っている**ので、「映える」絵になるよう配慮すると良い演出になりそうです。

**【補足】**
> 一番「効いてる感」のあるフレームでヒットストップ状態になるよう、Animationの進捗を飛ばしています。

これはヒットストップの要らない小さなダメージでは通常通り再生することの棲み分けを配慮しての実装ですが、1フレーム目からブレンド無しで思いっきりのけぞった状態になるダメージモーションを用意する、という方法もアリかなと思います。
エフェクトについても同様です。

# 画面全体の工夫

https://twitter.com/flankids/status/1471634420230922242

ヒットストップとは直接関係ないですが、インパクトの演出をより印象付けるためにカメラを揺らして振動感を入れました。

```Camera.cs
/// <summary>
/// 振動演出
/// </summary>
/// <param name="width">触れ幅</param>
/// <param name="count">往復回数</param>
/// <param name="duration">時間</param>
public void Shake(float width, int count, float duration)
{
    var camera = Camera.main.transform;
    var seq = DOTween.Sequence();
　　　　　　　　// 振れ演出の片道の揺れ分の時間
    var partDuration = duration / count / 2f;
    // 振れ幅の半分の値
    var widthHalf = width / 2f;
    // 往復回数-1回分の振動演出を作る
    for (int i = 0; i < count - 1; i++)
    {
        seq.Append(camera.DOLocalRotate(new Vector3(-widthHalf, 0f), partDuration));
        seq.Append(camera.DOLocalRotate(new Vector3(widthHalf, 0f), partDuration));
    }
    // 最後の揺れは元の角度に戻す工程とする
    seq.Append(camera.DOLocalRotate(new Vector3(-widthHalf, 0f), partDuration));
    seq.Append(camera.DOLocalRotate(Vector3.zero, partDuration));
}
```

DOTween以外に、サインカーブと経過時間を使うなど、いろいろな方法があると思います。
触れ幅、往復回数、時間でコントロールできるようになってると良いでしょう。

**【補足】**
3Dゲームで画面を揺らす場合、座標ではなく角度にオフセットを入れてあげたほうがいい感じになることが多いです。
`eulerAngles`ではなく`position`の値を変えることで座標での振動を試すことができますが、`position`での振動は、カメラから近い位置にあるオブジェクトは余り揺れてるように見えず距離依存になるため、コントロールが難しいです。

個人的には縦の角度揺らしが一番いい感じに見えるかなと思います。



# まとめ
ダメージ演出というと、ダメージモーションやエフェクトに気が行きがちですが、時間のコントロールでかなり印象が変わります。
「なんか爽快感がないな？」と思ったら、演出を派手にする以外に、こういった調整も検討すると低コストで高い効果が得られると思います！

他にもインパクトの瞬間を映させる演出があったらぜひ教えてください！