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();
}
}
}