はじめに
今回はグローバルゲームジャム2019で用いた、開発のコツやUnityのテクニックについてまとめます。
設計についての話はこちらを参照。
どんなに手間でもSceneは別けよう
たとえ手間でも、「タイトルシーン」と「ゲーム本編のシーン」は分離して作った方が楽です。
理由としては次のとおり。
- UIがメインとなるタイトルを別シーンに切り出すことで、分業しやすくなる
- ゲームの初期化が簡単にできる (最悪、ゲーム本編のシーンのみを再ロードすればScene初期化が完了する)
ゲームジャムのゲームをみていると、「初期化が作れなかったので、1回ゲームを遊ぶたびに再起動しないとだめです」といった作品をたまにみます。
Sceneを分割しておくと、同じSceneをリロードするだけで初期化ができるのでオススメです。
簡単なScene遷移機構
Sceneを別けた方がいいと言いましたが、やはりそこを実装するのは結構な手間がかかります。
そこで、シンプルなトランジションエフェクト付きのScene遷移機構の作り方を紹介しておきます。
1.TransitionCotrollerコンポーネントを定義する
Scene遷移の実行および、トランジションエフェクトを管理するコンポーネントを定義します。
(UniRxを使っています)
using System;
using System.Collections;
using UniRx;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace TransitionSample
{
public class TransitionCotroller : MonoBehaviour
{
[SerializeField] private Image coverImage;
[SerializeField] private float transitonSeconds;
private readonly BoolReactiveProperty isTransferring = new BoolReactiveProperty(false);
public IObservable<Unit> OnTransitionFinished
{
get
{
// シーン遷移をしていないなら、即イベント発行
if (!isTransferring.Value) return Observable.Return(Unit.Default);
// シーン遷移中なら終わったときにイベント発行
return isTransferring.FirstOrDefault(x => !x).AsUnitObservable();
}
}
/// <summary>
/// シーン遷移を開始する
/// </summary>
public void TransitionStart(string nextSceneName)
{
if (isTransferring.Value) return; //すでにシーン遷移中なら何もしない
isTransferring.Value = true;
StartCoroutine(TransitionCoroutine(nextSceneName));
}
private IEnumerator TransitionCoroutine(string nextSceneName)
{
var time = transitonSeconds;
// 画面のクリックイベントをTransitionCanvasでブロックする
coverImage.raycastTarget = true;
// 画面を徐々に白くする
while (time > 0)
{
time -= Time.deltaTime;
coverImage.color = OverrideColorAlpha(coverImage.color, 1.0f - time / transitonSeconds);
yield return null;
}
// 完全に白くする
coverImage.color = OverrideColorAlpha(coverImage.color, 1.0f);
// 画面が隠し終わったらシーン遷移する
yield return SceneManager.LoadSceneAsync(nextSceneName);
// 画面を徐々に戻す
time = transitonSeconds;
while (time > 0)
{
time -= Time.deltaTime;
coverImage.color = OverrideColorAlpha(coverImage.color, time / transitonSeconds);
yield return null;
}
// クリックイベントのブロック解除
coverImage.raycastTarget = false;
coverImage.color = OverrideColorAlpha(coverImage.color, 0.0f);
// シーン遷移完了
isTransferring.Value = false;
}
private Color OverrideColorAlpha(Color c, float a)
{
return new Color(c.r, c.g, c.b, a);
}
}
}
2. TransitionCotrollerをつけたPrefabを用意する
つづいて、さきほどのTransitionCotroller
を貼り付けたPrefabを用意します。
PrefabはかならずResourcesディレクトリ以下に配置してください。
- uGUIのCanvasを用意する
- GameObjectの名前を「TransitionCotroller」にする
- Canvasの
Sort Order
を 大きめの数値にしておく - Canvasの子に、
Image
を追加する -
Image
を画面全体を覆うように配置し、好きな色で塗りつぶしておく(Source Imageは空でよい) - Canvas側のGameObjectに
TransitionCotroller
コンポーネントを貼り付ける - コンポーネントにさきほどの
Image
を紐づけ、Transition Seconds
を設定する - このGameObjectを Resourcesディレクトリ以下へ保存してPrefab化する
3. TransitionCotrollerをよびだすstaticクラスを定義する
TransitionCotroller
を直接さわると扱いにくいので、仲介するTransitionManager
を定義します。
using System;
using UniRx;
using UnityEngine;
namespace TransitionSample
{
public static class TransitionManager
{
/// <summary>
/// TransitionCotrollerが存在しないなら生成する
/// </summary>
private static Lazy<TransitionCotroller> controller = new Lazy<TransitionCotroller>(() =>
{
var r = Resources.Load("TransitionCotroller");
var o = UnityEngine.Object.Instantiate(r) as GameObject;
UnityEngine.Object.DontDestroyOnLoad(o);
return o.GetComponent<TransitionCotroller>();
});
private static TransitionCotroller Controller => controller.Value;
/// <summary>
/// 次のシーンへ遷移する
/// </summary>
public static void StartTransition(string nextSceneName)
{
Controller.TransitionStart(nextSceneName);
}
/// <summary>
/// シーン遷移完了を通知する
/// </summary>
public static IObservable<Unit> OnTransitionFinishedAsync()
{
return Controller.OnTransitionFinished;
}
}
}
4. 実際に使う
using UniRx;
using UnityEngine;
using UnityEngine.UI;
namespace TransitionSample
{
// ボタンが押されたらシーン遷移する
public class GotoButton : MonoBehaviour
{
[SerializeField] private string _nextScene;
[SerializeField] private Button _button;
private async void Start()
{
// シーン遷移が終わるまで待つ場合はOnTransitionFinishedAsyncをawaitすればよい
await TransitionManager.OnTransitionFinishedAsync();
_button.OnClickAsObservable()
.Subscribe(_ => { TransitionManager.StartTransition(_nextScene); });
}
}
}
面倒くさい人向け
GitHubにサンプルプロジェクトを上げたので、自由に使ってください。
staticフィールド/DontDestroyOnLoadは理由がない限り使わない
staticフィールドやDontDestroyOnLoadを使うと、Sceneをまたいで状態を保持させることができるようになります。
たしかに便利ではあるのですが、なんでもかんでもこれにしてしまうと初期化処理が辛いことになるので多用は止めましょう。
たとえば「Sceneをまたいでデータをやりとりする」といったときにDontDestroyOnLoad
を使うことはまったく問題ありません。
Debugしやすくしておこう
動作確認が簡単にできるような仕組みを整えておくと作業効率をあげることができます。
たとえば、#if UNITY_EDITOR
と#endif
で包んだブロックはUnityEditor上でのみ機能させることができるようになります。
この機能を利用して、エディタ実行中のみショートカットコマンドが使える、みたいな状態にしておくと確認作業が楽になるためオススメです。
破壊してもいいSceneで作業しよう
ゲームジャムでは複数人が短時間に一気に作業を行います。
そのため、メインとなるSceneで全員が作業をすると速攻で作業内容がコンフリクトしてしまいます。
これを避けるために、各自が自分専用の作業Sceneを用意するなど、最悪Sceneが壊れて戻せなくなっても構わないように作業するといいでしょう。
AnimationCurveは便利
Unityの標準機能にAnimationCurveというものがあります。
これを用いるとちょっと複雑な計算を簡略化できる場合があるためオススメです。
AnimationCurve使用例1:アニメーションの速度を変化させる
たとえば、次のようなオブジェクトを加速させながら回転させるスクリプトがあったとします。
using UnityEngine;
public class Rotate : MonoBehaviour
{
private float _startTime;
/// <summary>
/// 加速する時間
/// </summary>
[SerializeField] private float _accelerationDuration;
/// <summary>
/// 最大到達速度
/// </summary>
[SerializeField] private float _maxSpeed;
void Start()
{
_startTime = Time.time;
}
void Update()
{
// 加速時間で指定した秒数をかけて最大到達速度まで「線形」に加速していく
var rate = (Time.time - _startTime) / _accelerationDuration;
var currentSpeed = Mathf.Lerp(0, _maxSpeed, rate);
transform.rotation = Quaternion.AngleAxis(currentSpeed * Time.deltaTime, Vector3.forward) * transform.rotation;
}
}
このスクリプトを用いると、「オブジェクトを 等速 に加速させながら回転させる」ことができます。
(加速時間3秒、最大速度180度/秒)
ここで「等速に加速するのはダサいからもっとギュイーン!って加速してほしい!」
となったときに、それをスクリプトで実装するのはなかなか手間です。
そこでAnimationCurve
を用いてみましょう。
using UnityEngine;
public class Rotate : MonoBehaviour
{
private float _startTime;
/// <summary>
/// 加速する時間
/// </summary>
[SerializeField] private float _accelerationDuration;
/// <summary>
/// 最大到達速度
/// </summary>
[SerializeField] private float _maxSpeed;
/// <summary>
/// 加速具合
/// </summary>
[SerializeField] private AnimationCurve _animationCurve;
void Start()
{
_startTime = Time.time;
}
void Update()
{
// 加速時間で指定した秒数をかけて最大到達速度まで「線形」に加速していく
var rate = (Time.time - _startTime) / _accelerationDuration;
var currentSpeed = _animationCurve.Evaluate(rate) * _maxSpeed; //AnimationCurveを使って速度を算出
transform.rotation = Quaternion.AngleAxis(currentSpeed * Time.deltaTime, Vector3.forward) * transform.rotation;
}
}
AnimationCurve
を[SerializeField]
で定義すると、インスペクター上でカーブを設定できるようになります。
ここで「回転の加速具合」を設定し、スクリプト上でそのときのパラメータを読み取ることで任意の加速具合を表現することができるようなります。
// Evaluateの引数にx軸のパラメータを指定すると、そのときのyの値が取得できる
var currentSpeed = _animationCurve.Evaluate(rate) * _maxSpeed;
たとえば、このような最初はほとんど加速せず、最後に一気に加速するカーブを描くとこのような挙動にできます。
(加速時間3秒、最大速度180度/秒、さっきと同じ)
AnimationCurve使用例2:距離減衰の実装に
たとえば「爆弾の爆風」のような、中心部のもっとも威力が大きく、端にいくほど威力がさがるような攻撃を実装するとします。
こういった実装もAnimationCurve
を使うと簡単に実装できます。
using UnityEngine;
class Explosion : MonoBehaviour
{
[SerializeField] private float _maxPower;
[SerializeField] private AnimationCurve _powerCurve;
private float _radius; //自分の最大半径
private void Start()
{
_radius = GetComponent<SphereCollider>().radius;
}
/// <summary>
/// 爆風の範囲内に入ったときの挙動
/// </summary>
private void OnTriggerEnter(Collider other)
{
var targetRigidbody = other.gameObject.GetComponent<Rigidbody>();
var vector = (other.gameObject.transform.position - transform.position);
var distance = vector.magnitude; //中心からの距離
var direction = vector.normalized; //ふっとぶ方向
Debug.Log(direction);
Debug.Log(distance / _radius);
// 中心からの距離を用いてふっとばし力を算出
var power = _powerCurve.Evaluate(distance / _radius) * _maxPower;
// ふっとばす
targetRigidbody.AddForce(power * direction, ForceMode.VelocityChange);
}
}
たとえばこのような「一定距離までは減衰しないが、ある点を境に急激に減衰する」といったAnimationCurve
を設定すれば、次のような挙動が簡単に作ることができます。
時間を管理するManagerを用意すると便利
ゲームにおいて「時間」を管理したいことがあります。
その場合は1つ専用のクラスを定義し、そこに押し付けてしまうと管理が楽になるのでオススメです。
1個のクラスにまとめておくとあとから時間間隔の調整がインスペクター上で簡単に変更できるようになります。
GameStateをReactivePropertyで管理すると楽
GameState
とは、たとえば「初期化フェーズ」「バトル中フェーズ」「結果発表フェーズ」といった、ゲームの進行状況を表すものです。
これをenum
で定義し、ReactiveProperty
で管理すると全体の制御が非常に楽なのでオススメです。
public enum GameState
{
Initializing,
Ready,
Battle,
Finished,
Result
}
みたいなのを用意しておくと、たとえば「Battleフェーズのみプレイヤを操作したい」という場合はこんな感じで実装が終わります。
ZenjectSceneLoaderが便利
ZenjectSceneLoader
はZenjectの機能の1つです。
かんたんに説明すると、シーンをまたいで前のシーンのデータを持っていける機能です。
さきほど説明したシーン遷移機構と合わせて利用すると結構便利です。
使い方
ZenjectSceneLoader
をInjectして、それを使ってシーン遷移すればOKです。
Action<DiContainer>
のデリゲートに、次のシーンにBind
したい処理を記述することができます。
詳しい使い方はもんりぃ先生のブログを参照してください。
public class TitleMenuManager : MonoBehaviour
{
[Inject] private ZenjectSceneLoader _zenjectSceneLoader;
public void MoveToBattleScene(int players)
{
var battleMenuInfo = new BattleMenuInfo(playerCount: players);
_zenjectSceneLoader.LoadScene("Battle", LoadSceneMode.Single, container =>
{
// 次のシーンにBattleMenuInfoを持ち越す
container.Bind<BattleMenuInfo>()
.FromInstance(battleMenuInfo).AsCached();
});
}
}
/// ゲームを開始するために必要な情報
/// </summary>
public struct BattleMenuInfo
{
/// <summary>
/// プレイヤの数
/// </summary>
public int PlayerCount { get; }
public BattleMenuInfo(int playerCount)
{
PlayerCount = playerCount;
}
}
ちなみに移動先のシーンにこういうInstaller
を配置しておくとデバッグもしやすいのでオススメ。
public class HogeInstaller : MonoInstaller
{
public override void InstallBindings()
{
// BattleMenuInfoがBindされていないなら、
// シーン初期化のタイミングでデバッグ用のパラメータを埋めておく
if (!Container.HasBinding<BattleMenuInfo>())
{
Container.Bind<BattleMenuInfo>()
.FromInstance(new BattleMenuInfo(2))
.AsCached();
}
}
}
ちなみに、さっき紹介した「シーン遷移機構」と併用することもできます。
(さっきとクラス名違うけど内容はおなじ)
ZenAutoInjectorが便利
Zenjectを使っていると、PrefabのInstantiateがやりにくいという欠点があります。
というのも、DIContainerにDIしてもらうためには、DIContainerにInstantiateを検知してもらう必要があるからです。
そのため、正攻法でなんとかしようとするとFactory
を用意して、Installer
を書く必要があるなどかなり面倒くさいです。
そこで、これらの手間をすべて無視できるZenAutoInjector
というコンポーネントを紹介しておきます。
ZenAutoInjector
はPrefabに貼り付けておくと、Awakeのタイミングで自分からDIContainerに対してInjectを要求してくれるというコンポーネントです。
このコンポーネントをPrefabに貼り付けておけば、Factory
定義やInstaller
定義が不要になるため、覚えておくとよいでしょう。
ただし気軽に使うのはゲームジャムだけにしておきましょう。業務などでZenjectを用いる場合は、ZenAutoInjector
の乱用は管理できなくリスクがあるのでオススメしません。
まとめ
ゲームジャムは「引き出しの数」が重要です。少しでもこの記事が手助けになれば。