Unity

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

🖥開発環境

  • Unity2018.1.3f2

📝この記事の概要

先日 unityroom で開催されたオンラインゲームジャムイベント Unity1週間ゲームジャム お題「ぎりぎり」にて、自分は「ぎりぎりでボールを避ける」ゲームを作りました。 こちら からプレイできます。

unity1week8.gif

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

[Unity] Unity1週間ゲームジャム (ぎりぎり) でCAFU (Clean Architecture For Unity) を使って実装してみた - Qiita

ポストエフェクトで画面全体に効果を掛ける

BEFORE
before.png

AFTER
lens.png

画面全体にVignette効果を適用する

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

🛠実装

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

ooah_2.png

ここからは公式ドキュメントに従い作業を進めます。

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

ppv2.gif

画面全体にBloom効果を適用する

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

🛠実装

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

ppv2-2.gif

画面を魚眼レンズ風に見せる

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

🛠実装

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

ppv2-3.gif

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

ゲーム開始時にフェードインさせる

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

ppv2-4_fadein.gif

🛠実装 (DOTweenを使用する方法)

  • (DOTweenの導入に関しては別途記事を書いたので こちら を参照)
  • 任意のImageを全面に配置する
  • 以下のScriptをアタッチする
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を別途用意している

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

文字を点滅させる

現在やってほしい動作に関して、動きがあるとやや注意を促すことができます。
今回の場合、「Spaceボタンを押すとゲームを開始できるよ!」という注意文言を点滅さています。
DOTweenを使用した方法について こちら にまとめています

ppv2-5.gif

背景に幾何学模様のパーティクルを降らす

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

ppv2-6.gif

ボール衝突時演出(光の柱を表示する)

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

ppv2-7.gif

🛠実装

  • 演出以外のコンテキストも含んでいて読みにくいと思いますが、そのまま貼りました。
  • ポイントとしては以下の通りです
    • UniRx.ToolKit.ObjectPool を利用してオブジェクトを使い回す 1
    • DOTweenのDOSizeDeltaで表示した柱を横に縮める 2
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;
        }
    }
}
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

ppv2-8.gif

🛠実装

Camera Play - Asset Store 2018-07-21 17-56-44.png

今回は CameraPlay という有料アセットに頼りました。これにより衝突時に以下のようなコードを挟むだけで指定した時間だけ演出を挟むことができます。 4

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

スコアを更新する

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

ppv2-11.gif

🛠実装

DOTween を使用して実装します。

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

🛠実装

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

Unity 2018.1.3f1 Personal (64bit) - [PREVIEW PACKAGES IN USE] - Main.unity - 1WeekGameJamXXX - WebGL (Personal) <Metal> 2018-07-21 18-15-02.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

🛠実装

音楽に合わせて処理を行うのに Music.cs というライブラリを使用しています。
リズムに合わせて大きさを変更する方法は こちら に記載しています。

まとめ

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

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

  1. サボってPoolのインスタンスを生成された側に渡してReturnを叩いているが、UniRxでイベントを伝搬させて、生成する側でReturnさせたほうが筋が良さそうな気がするけど、どうだろう 

  2. Canvas内でAnimationさせるのは描画のコストが高いので雑なプロジェクトでなければ避けたほうが良い (光の柱の演出の場合、SpriteRendererで実装してDOScaleするか、Particleで作ってしまうのが妥当な気がする) 

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

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