1週間ゲームジャム お題「space」を見て、 宇宙で駐車スペースに駐車するゲームという物理演算ドタバタゲーを作ったので、 せっかくなら技術解説記事でも書こうと思い立った次第です。
利点
PubSubでフローを管理することで、クラス間の依存が少なく出来てサイコー!って感じです。
MessageBrokerってなに?
UniRxで提供されているPubSub機構です。
型ベースなのが非常に良いです。
本題へ
このゲームの流れはこうです。
- 最初の1秒は説明テキストを見せる(操作不可)
- 操作可能にする
- 何かしらのキーが押されたらゲームスタート
- 時間計測開始
- タイトル画面が消える
- 車が駐車スペースに止まったらゲーム終了
ボタンを押したらリトライ
+ いつでもリトライ出来る
ゲームフロー管理マン
いきなりゲームフローを管理してるクラスです。 上に書いたゲームフローをそのままコードに落としたように綺麗に書けてます。素敵。
public class GameFlow : MonoBehaviour { public IEnumerator Start() { yield return new WaitForSecondsRealtime(1.0f); Controller.Enabled = true; yield return new WaitUntil(() => Controller.Any); var startTime = Time.time; MessageBroker.Default.Publish(new GameStart()); yield return MessageBroker.Default.Receive<CarStay>().First().ToYieldInstruction(); MessageBroker.Default.Publish(new GameFinish(Time.time - startTime)); } public class GameStart { } public class GameFinish { public float Time { get; } public GameFinish(float time) { Time = time; } } // 車が駐車スペースに停車したら飛んでくる public class CarStay { } }
タイトル画面をふわぁ〜っと消す
ゲームが開始したら、タイトル画面(「↑あなた」とか「↑ここにとめる」とかテキストが出てるやつ)をふわぁ〜っと消します。 ゲームスタートが飛んできたら、CanvasGroupごとフェードアウト。 これもコルーチンで回します。非常にシンプル。
public class Title : MonoBehaviour { public IEnumerator Start() { yield return MessageBroker.Default.Receive<GameFlow.GameStart>().First().ToYieldInstruction(); yield return StartCoroutine(GetComponent<CanvasGroup>().FadeOut()); gameObject.SetActive(false); } }
これはゲームフローとは関係ないのですが、ふわぁ〜っとCanvasGroupのalphaを0にするやつも便利なので置いときます。
public static class CanvasGroupExtensions { public static IEnumerator FadeOut(this CanvasGroup group) { var time = 0.0f; while (time < 1.0f) { group.alpha = Mathf.Lerp(1.0f, 0.0f, time); time += Time.deltaTime; yield return null; } group.alpha = 0.0f; } }
リトライはいつでも受け付けたい
リトライはゲームのフローに関係なく、いつでも突然呼び出したい。 そんな時は、普通にSubscribeしましょう。
public class GameFlow : MonoBehaviour { public IEnumerator Start() { MessageBroker.Default .Receive<NextGame>() .Subscribe(_ => SceneManager.LoadScene(SceneManager.GetActiveScene().name)) .AddTo(this); yield return new WaitForSecondsRealtime(1.0f); 〜あとはいっしょ〜
ゲームが終了したら結果を表示する
GameFlow.StartでPublishした値、どこでも誰でも自由に受け取れます。
[RequireComponent(typeof(Text))] public class Result : MonoBehaviour { public IEnumerator Start() { var finish = MessageBroker.Default.Receive<GameFlow.GameFinish>().First().ToYieldInstruction(); yield return finish; GetComponent<Text>().text = $"きろく {finish.Result.Time}びょう"; } }
ツイートボタンでも値を使いたい!
上記、Resultクラスとは何の依存も必要ありません。 こっちも勝手に値を待って、使えばいいだけ。
[RequireComponent(typeof(Button))] public class TweetButton : MonoBehaviour { public IEnumerator Start() { var finish = MessageBroker.Default.Receive<GameFlow.GameFinish>().First().ToYieldInstruction(); yield return finish; GetComponent<Button>().OnClickAsObservable().Subscribe(_ => { Debug.Log($"{finish.Result.Time}秒だったよ!とツイート"); }); } }
まとめ
いかがでしょうか、GameFlowクラスでゲームの流れは全て制御していながら、GameFlowクラスからも、他のクラスからも、お互いに依存は1つもありません。 「ゲームクリアしたときのデバッグがしたい!」という時に、「GameFinishをPublishするボタン」を置いておけばゲームクリアしたことになる。というのも非常に便利です。 ご意見ご感想は、ここのコメント欄か@kyubunsまでお気軽にどうぞ!
余談1
async/awaitを使えば、値を受け取るコードがもっとシンプルに書けて嬉しい。 https://gist.github.com/kyubuns/d0609398e71e16bb87a5451c72963391
余談2
PubSubは関係ないけれど、他にも依存を減らす工夫を紹介します。 キー入力をしてから、車が動いて炎のエフェクトが出るまでの流れです。 ちなみにエフェクトはAssetStoreから拾ってきたCubeSpace - Effectsを使っています。
public static class Controller { public static bool Enabled { get; set; } public static bool Up => Enabled && (Input.GetKey(KeyCode.UpArrow) || Input.GetKey(KeyCode.W)); public static bool Left => Enabled && (Input.GetKey(KeyCode.LeftArrow) || Input.GetKey(KeyCode.A)); public static bool Down => Enabled && (Input.GetKey(KeyCode.DownArrow) || Input.GetKey(KeyCode.S)); public static bool Right => Enabled && (Input.GetKey(KeyCode.RightArrow) || Input.GetKey(KeyCode.D)); public static bool Any => Up || Left || Down || Right; }
public class Handle : MonoBehaviour { public BoolReactiveProperty Up { get; } = new BoolReactiveProperty(); public BoolReactiveProperty Down { get; } = new BoolReactiveProperty(); public BoolReactiveProperty Left { get; } = new BoolReactiveProperty(); public BoolReactiveProperty Right { get; } = new BoolReactiveProperty(); public void Update() { Up.Value = Controller.Up; Down.Value = Controller.Down; Right.Value = Controller.Right; Left.Value = Controller.Left; } }
public class Engine : MonoBehaviour { [SerializeField] private float UpForce; [SerializeField] private float DownForce; [SerializeField] private float RightTorque; [SerializeField] private float LeftTorque; private Handle Handle; private RectTransform RectTransformCache; private Rigidbody2D RigidbodyCache; public void Awake() { Handle = GetComponent<Handle>(); RectTransformCache = GetComponent<RectTransform>(); RigidbodyCache = GetComponent<Rigidbody2D>(); } public void Update() { if (Handle.Up.Value) RigidbodyCache.AddForce(RectTransformCache.up * UpForce * Time.deltaTime, ForceMode2D.Force); if (Handle.Down.Value) RigidbodyCache.AddForce(RectTransformCache.up * -DownForce * Time.deltaTime, ForceMode2D.Force); if (Handle.Right.Value) RigidbodyCache.AddTorque(-RightTorque * Time.deltaTime, ForceMode2D.Force); if (Handle.Left.Value) RigidbodyCache.AddTorque(LeftTorque * Time.deltaTime, ForceMode2D.Force); } }
[RequireComponent(typeof(Handle))] public class CarParticle : MonoBehaviour { [SerializeField] private Handle Handle; [SerializeField] private List<ParticleSystem> Front; [SerializeField] private List<ParticleSystem> Rear; [SerializeField] private List<ParticleSystem> Right; [SerializeField] private List<ParticleSystem> Left; public void Start() { Handle.Up.DistinctUntilChanged().Subscribe(x => SetParticleActive(Rear, x)).AddTo(this); Handle.Down.DistinctUntilChanged().Subscribe(x => SetParticleActive(Front, x)).AddTo(this); Handle.Right.DistinctUntilChanged().Subscribe(x => SetParticleActive(Right, x)).AddTo(this); Handle.Left.DistinctUntilChanged().Subscribe(x => SetParticleActive(Left, x)).AddTo(this); } private static void SetParticleActive(IEnumerable<ParticleSystem> list, bool active) { foreach (var particleSystem in list) { if (active) particleSystem.Play(); else particleSystem.Stop(); } } }