[Unity] Unity1週間ゲームジャム (ぎりぎり) にて実装した演出をまとめる

# 🖥開発環境
- Unity2018.1.3f2

# 📝この記事の概要
先日 [unityroom](https://unityroom.com/) で開催されたオンラインゲームジャムイベント [Unity1週間ゲームジャム](https://unityroom.com/unity1weeks) お題「ぎりぎり」にて、自分は「ぎりぎりでボールを避ける」ゲームを作りました。 [こちら](https://unityroom.com/games/out_of_a_hole) からプレイできます。

![unity1week8.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/a28c2b69-b5ef-ac25-bc0e-e15fbf51ba00.gif)

今回はこのゲームを作る時の演出周りのTIPSについて記載します。
ゲームロジックの実装面に関しての記事は以下になります。

[[Unity] Unity1週間ゲームジャム (ぎりぎり) でCAFU (Clean Architecture For Unity) を使って実装してみた - Qiita](https://qiita.com/lycoris102/items/d7d4d275ab90e6ac792e)

## ポストエフェクトで画面全体に効果を掛ける
BEFORE
<img width="624" alt="before.png" src="https://qiita-image-store.s3.amazonaws.com/0/102664/92639623-9687-e1fb-0a15-31f918e9578c.png">

AFTER
<img width="624" alt="lens.png" src="https://qiita-image-store.s3.amazonaws.com/0/102664/24eb5a5f-d747-d7db-e10a-1a924b4df4a0.png">


### 画面全体にVignette効果を適用する
Vignette(ビネット)効果は画面角にぼんやりと掛かっている黒枠のことです。
これにより、ムードある雰囲気を持たせ、トンネル効果(画面中央に視線を集める)を狙います。

#### 🛠実装
`Post-Processing` を使用します。
Unity2018からはPackageManager経由で最新のバージョンを落とすことが出来ます。

<img width="734" alt="ooah_2.png" src="https://qiita-image-store.s3.amazonaws.com/0/102664/c5b8d1ee-b23b-766a-090c-36adab7398d6.png">

ここからは[公式ドキュメント](https://github.com/Unity-Technologies/PostProcessing/wiki)に従い作業を進めます。

- MainCamera　に `PostProcessLayer` をアタッチ
- 任意のオブジェクトに `PostProcessVolume` をアタッチ
- **任意のオブジェクトのレイヤーと `PostProcessLayer` で指定するレイヤーを合致させる**
- `PostProcessVolume` にて `Profile` を新規で追加する (か指定する)
- `Profile` のファイルを選択し、 `Vignette` にチェックを入れる
- パラメータを調整する (主にIntensity)

![ppv2.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/5d8919bf-17a6-3206-b221-ee5b7b23b011.gif)

### 画面全体にBloom効果を適用する
上記の `Post-Processing` の効果の一つに `Bloom` があります。
これにより明るい物体から光が滲むような効果を追加できます。

#### 🛠実装
- (途中までVignetteと同様)
- `Profile` のファイルを選択し、 `Bloom` にチェックを入れる
- パラメータを調整する (主にIntensity/threshold周り)

![ppv2-2.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/7e2a3017-da4e-4995-7145-efe18abb773c.gif)

### 画面を魚眼レンズ風に見せる
他のゲームとの差別化要因として、魚眼レンズ風な演出も加えます。
こちらも `Post-Processing` の効果として追加できます。

#### 🛠実装
- (途中までVignetteと同様)
- `Profile` のファイルを選択し、 `LensDistortion` にチェックを入れる
- パラメータを調整する

![ppv2-3.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/9cffdbbf-735b-f0df-6eb5-dc9298a88166.gif)

## 個別の要素に対して演出を付ける

### ゲーム開始時にフェードインさせる
任意の単色で塗りつぶしておき、αを下げる(透過させる)ことで、ゆるいトランジション/フェードインを実装します。
Scene遷移時やリトライ時にフェードイン/フェードアウトを合わせ、 "画面を突然切り替えないようにすること" でゲーム全体の作りが丁寧に見えます。

![ppv2-4_fadein.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/2962ec13-3c9f-0531-6b96-8f81cbf9ae6d.gif)

#### 🛠実装 (DOTweenを使用する方法)
- (DOTweenの導入に関しては別途記事を書いたので [こちら](https://qiita.com/lycoris102/items/fe148d2c6803742c6f4d) を参照)
- 任意のImageを全面に配置する
- 以下のScriptをアタッチする

```csharp:FadeinImage.cs
using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;

namespace Unity1Week.Presentation.View.Main
{
    public class FadeinImage : MonoBehaviour
    {
        [SerializeField] private Image fadeinImage;
        [SerializeField] private float durationSeconds;

        private void Awake()
        {
            this.fadeinImage.color = this.fadeinImage.color.ToOpaque();
        }

        private void Start()
        {
            this.fadeinImage.DOFade(0.0f, this.durationSeconds);
        }
    }
}
```

Color.ToOpaque に関しては以下のようなExtensionを別途用意している

```csharp:ColorExtension.cs
using UnityEngine;

public static class ColorExtension
{
    public static Color ToTransparent(this Color color)
    {
        var transparentColor = color;
        transparentColor.a = 0f;
        return transparentColor;
    }
    
    public static Color ToOpaque(this Color color)
    {
        var transparentColor = color;
        transparentColor.a = 1f;
        return transparentColor;
    }
}
```

#### 🛠実装 (Timelineを使用する方法)
- Timelineアセットを作成する
- 任意のImageを全面に配置する
- 任意のGameObjectにPlayableDirectorをアタッチし、Timelineアセットを指定する
- PlayableDirectorのPlayOnAwakeを有効にする
- AnimationTrackを追加し、Imageを対象として指定する
- Recordを押して、αを変化させる

![ppv2-4_timeline.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/7091f16c-8f3e-5d49-9b95-7e91e0f5db5d.gif)

### 文字を点滅させる
現在やってほしい動作に関して、動きがあるとやや注意を促すことができます。
今回の場合、「Spaceボタンを押すとゲームを開始できるよ！」という注意文言を点滅さています。
DOTweenを使用した方法について [こちら](https://qiita.com/lycoris102/items/fe148d2c6803742c6f4d) にまとめています

![ppv2-5.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/20647c90-7667-18fc-ee55-fc658a00409b.gif)

### 背景に幾何学模様のパーティクルを降らす
[子供向けアプリを作るときに気をつけていること｜higo｜note](https://note.mu/higo/n/nee6a34af3474)
上記の記事が個人的に勉強になったのですが「動かすべきか」の項目で「死んだ画面」という表現があります。画面において動く要素がないと落ち着いて見えはするのですが、興味も合わせて失ってしまう可能性があります。コアのゲーム/UIを阻害しないレベルで動く要素を配置するという視点で、背景に幾何学的なオブジェクトを降らすことにしました。これにより世界観も合わせて表現することができます。

![ppv2-6.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/0dfb4f90-978c-558a-23a5-2025b92a08d0.gif)

### ボール衝突時演出(光の柱を表示する)
このゲームのメインの目的は「ボールを避ける」ことにあるので、この行為が "成功した/失敗した" ことに強いフィードバックが必要であると考えました。今回は光の柱を表示することで達成を試みました。余談ですが背景を暗くしたのも、このフィードバックの強調を目的にしています。

![ppv2-7.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/cbb318af-4a20-7a40-3a3c-52a149d93717.gif)

#### 🛠実装
- 演出以外のコンテキストも含んでいて読みにくいと思いますが、そのまま貼りました。
- ポイントとしては以下の通りです
    - [UniRx.ToolKit.ObjectPool](https://qiita.com/toRisouP/items/2a5fb86654525a4a8453#unirxtoolkitobjectpool) を利用してオブジェクトを使い回す 
[^1]
    - DOTweenのDOSizeDeltaで表示した柱を横に縮める [^2]

[^1]: サボってPoolのインスタンスを生成された側に渡してReturnを叩いているが、UniRxでイベントを伝搬させて、生成する側でReturnさせたほうが筋が良さそうな気がするけど、どうだろう
[^2]: Canvas内でAnimationさせるのは描画のコストが高いので雑なプロジェクトでなければ避けたほうが良い (光の柱の演出の場合、SpriteRendererで実装してDOScaleするか、Particleで作ってしまうのが妥当な気がする)

```csharp:BallHitEffectSpawner.cs
using CAFU.Core.Presentation.View;
using UniRx;
using UniRx.Toolkit;
using UnityEngine;

namespace Unity1Week.Presentation.View.Main
{
    public class BallHitEffectSpawner : MonoBehaviour, IView
    {
        [SerializeField] private BallHitEffect ballHitEffectPrefab;

        private BallHitEffectPool ballHitEffectPool;

        private void Start()
        {
            this.ballHitEffectPool = new BallHitEffectPool(this.ballHitEffectPrefab, this.transform);
            this.GetPresenter().OnHitGroundAsObservable()
                .Subscribe(ballHitModel =>
                {
                    var ballHitEffect = ballHitEffectPool.Rent();
                    ballHitEffect.SetPosition(ballHitModel.Position);
                    ballHitEffect.InjectModel(ballHitModel);
                    ballHitEffect.PlayEffect();
                })
                .AddTo(this);
        }
    }

    public class BallHitEffectPool : ObjectPool<BallHitEffect>
    {
        private BallHitEffect ballHitEffectPrefab { get; }
        private Transform parentTransform { get; }

        public BallHitEffectPool(BallHitEffect prefab, Transform parentTransform)
        {
            this.ballHitEffectPrefab = prefab;
            this.parentTransform = parentTransform;
        }

        protected override BallHitEffect CreateInstance()
        {
            var ballHitEffect = GameObject.Instantiate(this.ballHitEffectPrefab);
            ballHitEffect.transform.SetParent(parentTransform, false);
            ballHitEffect.SetPool(this);
            return ballHitEffect;
        }
    }
}
```

```csharp:BallHitEffect.cs
using CAFU.Core.Presentation.View;
using DG.Tweening;
using Unity1Week.Application.Enum;
using Unity1Week.Domain.Model;
using UnityEngine;
using UnityEngine.UI;

namespace Unity1Week.Presentation.View.Main
{
    [RequireComponent(typeof(Image))]
    public class BallHitEffect : MonoBehaviour
    {
        [SerializeField] private Color safeColor;
        [SerializeField] private Color outColor;

        private IBallHitModel ballHit;
        private BallHitEffectPool ballHitEffectPool;
        private RectTransform rectTransform;
        private Vector2 baseSize;
        private Image image;

        private void Awake()
        {
            this.image = this.GetComponent<Image>();
            this.rectTransform = this.GetComponent<RectTransform>();
            this.baseSize = this.rectTransform.sizeDelta;
        }

        public void PlayEffect()
        {
            this.image.color = this.ballHit.GroundType.Equals(GroundType.Safe) ? this.safeColor : this.outColor;
            this.rectTransform.sizeDelta = this.baseSize;
            this.rectTransform.DOSizeDelta(new Vector2(0, this.baseSize.y), 0.5f)
                .OnComplete(() => this.ballHitEffectPool.Return(this))
                .Play();
        }

        public void SetPosition(Vector2 position)
        {
            this.transform.position = position;
        }

        public void SetPool(BallHitEffectPool pool)
        {
            this.ballHitEffectPool = pool;
        }

        public void InjectModel(IBallHitModel model)
        {
            this.ballHit = model;
        }
    }
}
```

### ボール衝突時演出 (グリッヂエフェクトを走らせる/カメラを振動させる)
さらにボール衝突時のフィードバックを強めるために、グリッヂエフェクト/カメラシェイクを挟みました。画面全体に効果を走らせることにより、より特別なことをしたという感覚を強めるのが狙いでした。 [^3]

[^3]: 今思うとグリッヂエフェクトは故障/失敗のニュアンスを持つので、ポジティブなフィードバックに見えない可能性がありました

![ppv2-8.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/9c3a0eb4-b40e-ec5b-3fef-ae93ad62aea7.gif)

#### 🛠実装

<img width="452" alt="Camera Play - Asset Store 2018-07-21 17-56-44.png" src="https://qiita-image-store.s3.amazonaws.com/0/102664/e3f1b288-2926-b961-4f5a-a8aeeb242214.png">

今回は [CameraPlay](https://assetstore.unity.com/packages/tools/particles-effects/camera-play-60790) という有料アセットに頼りました。これにより衝突時に以下のようなコードを挟むだけで指定した時間だけ演出を挟むことができます。 [^4]

[^4]: 少なくともカメラ自体の transform.position を動かして振動するのは、カメラを移動する系のコンテンツとの相性が悪いので避けたほうが良さそうです。今ならカメラシェイクはChinemachineで対応するのが良さそう。 [【Unity】衝撃があった時にカメラを揺らす / 振動させる - テラシュールブログ](http://tsubakit1.hateblo.jp/entry/2018/07/18/023553)

```
CameraPlay.Glitch(0.1f);
CameraPlay.EarthQuakeShake(0.2f, 0.5f);
```

### スコアを更新する
スコアが上がることもユーザにとってポジティブなフィードバックなので、簡単なモーション (フェードイン/座標の移動) を挟んで強調します。

![ppv2-11.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/00c56a54-bec7-c971-e1aa-e251ab952644.gif)

#### 🛠実装
`DOTween` を使用して実装します。


```csharp:Score.cs
using CAFU.Core.Presentation.View;
using DG.Tweening;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace Unity1Week.Presentation.View.Main
{
    [RequireComponent(typeof(Text))]
    public class Score : MonoBehaviour, IView
    {
        private RectTransform rectTransform;
        private Vector2 fromAnchoredPosition;
        private Vector2 baseAnchoredPosition;
        private Text scoreText;

        private void Awake()
        {
            this.scoreText = this.GetComponent<Text>();
            this.rectTransform = this.GetComponent<RectTransform>();

            // 実際に表示したい位置を保持しておく
            this.baseAnchoredPosition = this.rectTransform.anchoredPosition;

            // どのくらい下から移動開始するか
            this.fromAnchoredPosition = new Vector2(
                this.baseAnchoredPosition.x,
                this.baseAnchoredPosition.y - 50f
            );
        }

        private void Start()
        {
            this.GetPresenter().OnGameStartAsObservable()
                .Subscribe(_ => this.GetPresenter().OnChangeScoreAsObservable().Subscribe(this.ShowScore))
                .AddTo(this);
        }

        private void ShowScore(int score)
        {
            this.rectTransform.anchoredPosition = this.fromAnchoredPosition; // 少し下に移動
            this.scoreText.color = this.scoreText.color.ToTransparent(); // 透過しておく
            this.scoreText.text = score.ToString();

            DOTween.Sequence()
                .Append(this.scoreText.DOFade(1.0f, 0.5f)) // フェードイン
                .Join(this.rectTransform.DOAnchorPos(this.baseAnchoredPosition, 0.5f) // 下の位置に戻る
                    .SetEase(Ease.OutBounce))
                .Play();
        }
    }
}
```


### レベルアップ演出を表示する
レベルアップに対しての達成感を強調するため、またレベルに応じて難易度が変わるのでその気付きとしてに画面全体にレベルアップ演出を表示しています (レベルアップに対する気付きがない弱いといつの間にか難しくなってしまい納得感が不足する)

![ppv2-9.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/e4e58d08-7d06-784c-d2df-d2a083136252.gif)

#### 🛠実装
- `DOTween` を使用して実装します。
- ポイントとしては以下の通りです
    - `RectTransform.DOAnchorPos` で座標を移動させる
    - 起点を `fromPosition` / 終点を `toPosition` とし、UnityEditor上で指定できるようにした
    - 滞在させたいポジションを `basePosition` (UnityEditor上で配置した座標) とする
    - `DOTween.Sequence` を使用して順番にTween処理を実行させる (Sequenceに関する詳細は [こちら](https://qiita.com/lycoris102/items/6a9e1e39bfa69880eaba))
    - `Ease.OutCubic` (徐々に減速) / `Ease.InCubic` (徐々に加速) を順番に実行することで `basePosition` の滞在時間を長くする

<img width="701" alt="Unity 2018.1.3f1 Personal (64bit) - [PREVIEW PACKAGES IN USE] - Main.unity - 1WeekGameJamXXX - WebGL (Personal) &lt;Metal&gt; 2018-07-21 18-15-02.png" src="https://qiita-image-store.s3.amazonaws.com/0/102664/2a71dcab-800d-1dfe-ca20-6ecbb5728a2e.png">

```csharp：LevelUp.cs
using CAFU.Core.Presentation.View;
using DG.Tweening;
using UniRx;
using UnityEngine;

namespace Unity1Week.Presentation.View.Main
{
    public class LevelUp : MonoBehaviour, IView
    {
        [SerializeField] private ParticleSystem particleSystem;
        [SerializeField] private RectTransform textRectTransform;
        [SerializeField] private Vector2 fromPosition;
        [SerializeField] private Vector2 toPosition;
        [SerializeField] private AudioSource audioSource;
        private Vector2 basePosition;

        private void Awake()
        {
            this.basePosition = this.textRectTransform.anchoredPosition;
            this.textRectTransform.anchoredPosition = this.fromPosition;
        }

        private void Start()
        {
            this.GetPresenter().OnChangeLevelAsObservable()
                .Where(levelModel => levelModel.Level != 1)
                .Subscribe(_ =>
                {
                    this.textRectTransform.anchoredPosition = this.fromPosition;
                    this.particleSystem.Play();
                    Music.QuantizePlay(this.audioSource);
                    DOTween.Sequence()
                        .Append(this.textRectTransform.DOAnchorPos(basePosition, 1.2f).SetEase(Ease.OutCubic)) // 徐々に減速
                        .Append(this.textRectTransform.DOAnchorPos(toPosition, 1.2f).SetEase(Ease.InCubic)) // 徐々に加速
                        .Play();
                })
                .AddTo(this);
        }
    }
}
```

### 音のピッチを変更する
音楽のピッチが上がる/下がるという効果を挟んでいます。
音のピッチが上がるということは焦せらせる/緊張感を生み出します。このゲームは進行するにつれ、ボールを当てることができる範囲が狭くなり緊張感が高くなりますが、それを強調させます。また、スロー機能においてはピッチを下げることで同じようにゆっくりになった感覚を強調させます。

#### 🛠実装
`AudioSource.Pitch`を変更します。
もしくは `DOTween` を使用している場合 `AudioSource.DOPitch` を使用することで指定した秒数間で徐々にピッチを変更することも可能です。

### 音楽に合わせてタイトルロゴを動かす
これも「死んだ画面にしない」ようにするための工夫であり、音楽を強調するための表現です。音楽に合わせてオブジェクトサイズを変更することは特にピッチ変更とのシナジーは高く、よりピッチの効果を強調することができます。

![ppv2-10.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/7f637b3d-3f5c-200f-4a84-8779d13397b3.gif)

#### 🛠実装
音楽に合わせて処理を行うのに [Music.cs](https://github.com/geekdrums/MusicEngine) というライブラリを使用しています。
リズムに合わせて大きさを変更する方法は [こちら](https://qiita.com/lycoris102/items/0969a07d09e3655b11ce#-%E3%83%AA%E3%82%BA%E3%83%A0%E3%81%AB%E5%90%88%E3%82%8F%E3%81%9B%E3%81%A6%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E5%8B%95%E3%81%8B%E3%81%99) に記載しています。

## まとめ
今回は以下の3点を意識して演出を作成しました。
特にゲームを作り始めたばかり/Unityを始めたばかりの人の参考になりましたら幸いです。

- **ポストエフェクトで全体の雰囲気を整える**
- **行動に対する結果のフィードバックを強調する**
- **「死んだ画面」にならないように、ゲームを阻害しない程度に動くオブジェクトを挟む**