- 投稿日:2020-08-18T18:15:07+09:00
1Week Game Jam で4日間でクリッカーゲームを作った話
はじめに
本ゲームを作成するにあたって以下のライブラリを使用しています。細かな技術解説はしていないのでご了承ください。
・UniRx *1
・UniTask *2
・Extenject *3普段行っているゲームの開発に集中できなくなってきてしまったので、気分転換にUnityRoomで開催されているUnity1Week お題「ふえる」に参加してみることにしました。また、最近あまり勉強できていなかったので気になっていたUnityのマルチシーンエンディング+Extenjectでロジックとビューごとにシーンを分けての開発を試してみることに。結果としてはそこそこいい感じにできたのかな?と思うので開発記録を記事にしてみた。
ゲーム概要
今回作成したゲームは、クリックでスライムを増やして出荷することでお金を稼ぐクリッカーゲームです。想定したプレイ時間は1時間とかなり長めですが、他の参加者様のゲームを遊んでいる間は放置して戻ってきたときにスライムが大量に増えているという状態になってくれたらいいなと思っています。
# ゲームシステム
本ゲームは、クリックでスライムを増やし、出荷しすることでお金を稼ぐゲームです。
稼いだお金でアイテムを購入したり、牧場をレベルアップすることでスライムの生産効率や出荷効率を上げていくことが本ゲームの目的となっています。Unity1Week1では牧場のレベルを11にすることでクリアできるようにあんっています。
こだわりポイント
スライムのボイス
ただのクリッカーゲームだと1度手が止まった瞬間に離脱してしまうだろうと思い、何か継続する要素が欲しかったためスライムにキャラクター性を持たせることにしました。ただその辺を動いているだけのスライムでも生産されるときに可愛らしく鳴くだけで愛着が湧き、無慈悲に出荷されるスライム達を寂しく思ってもらえるのではないかと考えました。今思うと出荷されるときにスライムの悲しい声をつけていればより印象的になったかもしれません。
クリック回数
本ゲームはクリッカーゲームですが、他のゲームをプレイしている合間にちょっと触る程度のプレイ感覚という目標があったので、思ったよりはクリックしなくてもクリアできるようにしました。なるべく少ないクリック回数で簡単になりすぎないバランスの追求が難しく今でも調整したほうが、と悩んでいます。
開発1日目
ゲームの構想で迷ってしまうと確実に間に合わなくなってしまうので、ゲームの構想は10分程度で終わらせてすぐ開発に入りました。今回はマルチシーンエンディングを試してみるという目的があったので、ゲームのロジックをLootSceneにUIをUIScene、スライムを生産するシーンをGameSceneに分けることにしました。
それぞれのSceneのSceneContextは、ContactNameによってLootSceneを親、GameSceneとUISceneをそれぞれ子になるようにしています。そのためGameSceneとUISceneは直接干渉することができなくなっています。
1日目はLootSceneのSceneContextにBindするDomain関連のコードをひたすら書いていました。基本要素となる、スライムの生産、出荷、アイテムの購入に必要なEntityやGameScene,UIScene両方で必要になるUseCaseなどを思いつく限り列挙していきました。
LootDomainInstaller.cspublic class LootDomainInstaller : MonoInstaller<LootDomainInstaller> { public override void InstallBindings() { SignalBusInstaller.Install(Container); // シーン間のデータのやり取りはEntityの変更、もしくはSignalによってのみ行います。 Container.DeclareSignal<ShipSignal>(); // 本来ならTimeInstallerのようにカテゴリで分けて整頓したほうがいいですが、面倒で煩雑に TimeInstaller.Install(Container); // 以下のEntityの中にはGameSceneやUISceneだけでのみ使うのもあるかもしれませんが時間が Container.BindInterfacesTo<FarmInfoEntity>() .AsSingle(); Container.BindInterfacesTo<ItemEntity>() .AsSingle(); Container.BindInterfacesTo<MoneyEntity>() .AsSingle(); Container.BindInterfacesTo<SlimeNumEntity>() .AsSingle(); Container.BindInterfacesTo<SpawnIndexEntity>() .AsSingle(); Container.BindInterfacesTo<VolumeEntity>() .AsSingle(); Container.BindInterfacesTo<ShipUseCaseUseCase>() .AsSingle(); Container.BindInterfacesTo<ClickSlimeUseCase>() .AsSingle(); } }2日目
Domainは1日目であらかた作成し終わったので2日目からはPresentationの作成にとりかかりました。テクスチャを含まないUnityのプレーンなオブジェクトを配置するホワイトモックを作成しながらView,Presenterのコードを作成していきます。このとき以下のようにパーツごとにInstallerを分けアタッチしたPrefabを作成しています。
ScreenButtonPackage.cs[RequireComponent(typeof(Button))] public class ScreenButtonPackage : MonoInstaller<ScreenButtonPackage> { [SerializeField] private ScreenEnum _screenEnum = default; public override void InstallBindings() { Container.Bind<ScreenEnum>().FromInstance(_screenEnum).AsSingle(); Container.BindInterfacesTo<ScreenMoveButtonPresenter>() .AsSingle(); Container.Bind<Button>() .FromComponentOnRoot(); } }たとえば画面遷移のトリガーとなるボタンを上記のようなパッケージとしてPrefabにして置いておくことでUISceneの中であればインスペクタからEnumを設定するだけで任意の画面に遷移するボタンを設置することができるようになっています。
こういったものをホワイトモックの段階で大量に作っておくと開発効率がグンと上がるのでおすすめです。3日目
Presentationを書きながら足りないDimainを埋めこの辺りで一旦遊べる段階になりました。ここからテクスチャやサウンドのAssetStoreから探し始めました。しかし理想のアセットがそう簡単に見つかるわけもなく1番時間がかかった部分かなと思います。素直に絵が描ける人をさがして依頼するのが賢い気がします。
4日目(最終日)
テクスチャを当てはめつつ、Animationを作成し仮データでテストプレイを行い不具合なくリザルトまでたどり着けるよう修正を行いました。不具合修正は思ったよりあっさり終わったのですが、この後のデータ作りが想像以上に時間がかかってしまい遅刻が確定。。。なるべくクリック回数を減らしたいと思いながら調整しているうちに、作っておいたアイテムや牧場の効率やコスト用の数式はどっかに飛んでいき、最終的には感覚で調整することに。手打ちしてしまったため桁ミスなども頻発し0時ギリギリの投稿となってしまいました。
おわりに
時間ギリギリになったことでチェックが甘くなりミスによって結果的に時間がかかってしまったりと4日めでかなり時間を無駄にしてしまったような気がします。ですが、マルチシーンエンディング込みの設計などそこそこ気を使いながらコードを書けたので、その点はよかったかなと思います。
最後に今回作成したゲームのコード部分をgithubで公開してありますので、タグに設定してある技術に興味のある方や有識者の方はコメントいただけると幸いです。https://github.com/sai-maple/SlimeFarm/tree/master
参考
- 投稿日:2020-08-18T13:32:39+09:00
ARCoreアプリが起動直後に必ず落ちる問題
忙しい人向け
64bit対応をするのです。
環境
モジュール バージョン Unity 2019.4.1f1 ARFoundation 2.1.8 ARCore XR Plugin 2.1.11 現象
上記環境でARCoreの最低限の設定を行いビルドを行うと、正常にapkは生成されます。
しかしapkを転送してアプリを実機(Pixel3)で実行させると、Unityロゴ表示後、一瞬アプリの画面が表示(カメラ映像は出ていない)されてから、アプリが必ず異常終了してしまいます。なお、過去にUnityで作成して動作していたハズの他のARCoreアプリケーションについても、同様の現象によってUnityロゴ表示後に異常終了をしてしまいます。
原因
原因は以前からアナウンスされていた、以下URLのARCore 64bit要件によるものらしいです。
ARCore 64-bit requirement
https://developers.google.com/ar/64bitIn August 2020, Google Play Services for AR (ARCore) removed support for 32-bit-only apps on 64-bit devices running Android 10 (API level 29) and later.
これによって2020年8月以降、Android10を搭載した64bit端末上で、ARCoreを利用した32bitのみのアプリケーションは動かなくなってしまったようです。
解決策
Android Developersの以下のURLの項目通り、
1. [Scripting Backend]を[Mono]から[IL2CPP]へ変更
2. [Target Archtecture]で[ARM64]を有効にする
3. 通常通りビルド実行
すると、動くようになると思います。64 ビット アーキテクチャのサポート
https://developer.android.com/distribute/best-practices/develop/64-bit#change-build-settingsさいごに
かなり以前からアナウンスされている事だったので、わかっている人にとっては当たり前の事だと思います。
が、自分のようにたまにちょっとARCoreのアプリをテスト的に作るかー!みたいな人だと、何で落ちんねん・・・。何で前動いていたアプリすら落ちんねん・・・。となってしまうのではないかと思います。きっとそうです。そんな人たちの助力になれば。
- 投稿日:2020-08-18T00:49:19+09:00
Unityにおけるポーズ処理の実装
はじめに
Unityでゲームのポーズメニューを実装するためにはポーズ処理が必要である。ネットでいろいろ調べたが丁度いいのが見つからなかったので自分で書いた。
今回の方法はRigidbody2Dを使うことが前提なので2Dゲームにしか使えない。基本実装
基本的な仕様はこちらの記事と同じ。behaviourを継承しているコンポーネントをdisableにする。
ただこの方法だとRigidbodyが停止しないので処理を追加する必要がある。Unity 5.5以降ではRigidbody2Dにsimulatedというbehaviourのenabledのようなメンバがあるので上の記事とほぼ同じ方法でポーズできる。using UnityEngine; using System.Collections; using System.Collections.Generic; using System; public class Pauser : MonoBehaviour { static List<Pauser> targets = new List<Pauser>(); // ポーズ対象のスクリプト Rigidbody2D[] pauseRB = null; //ポーズ対象のRigidbody bool isPause = false; // 初期化 void Awake() { // ポーズ対象に追加する targets.Add(this); } // 破棄されるとき void OnDestory() { // ポーズ対象から除外する targets.Remove(this); } // ポーズされたとき void OnPause() { if ( isPause ) { return; } pauseRB = Array.FindAll(GetComponentsInChildren<Rigidbody2D>(), (obj) => { return obj.simulated; }); foreach ( var com in pauseRB ) { if(com != null)com.simulated = false; } isPause = true; } // ポーズ解除されたとき void OnResume() { if ( !isPause ) { return; } foreach ( var com in pauseRB ) { if(com != null)com.simulated = true; } pauseRB = null; isPause = false; } // ポーズ public static void Pause() { foreach ( var obj in targets ) { if(obj != null) obj.OnPause(); } } // ポーズ解除 public static void Resume() { foreach ( var obj in targets ) { if(obj != null) obj.OnResume(); } } }問題点
使ってみたところ問題が発生した。まず、コルーチンが止まらない。
下のように対策として停止するコンポーネントのOnDisableで止める。IEnumerator hoge; void OnDisable() { if( hoge != null ) { StopCoroutine(hoge); } } void OnEnable() { if( hoge != null ) { StartCoroutine(hoge); } }これで何とかなるかと思ったがまだ上手くいかない。色々調査して分かったが、WaitforSecondsのカウントが止まってない。そこでポーズ中にカウントが止まるWaitメソッドをカスタムコルーチンで実装した。
PauserのStaticIsPauseというboolメンバがfalseのときのみカウントを進める仕様。namespace PauserFunctions { public class PausableWaitForFrames : CustomYieldInstruction { private float waitTime; //実際の待ち時間 IEnumerator wait; public PausableWaitForFrames(int frame) { wait = Wait(frame); } public override bool keepWaiting { get { return wait.MoveNext(); } } IEnumerator Wait(int frame) { int i = 0; while (i < frame) { yield return null; if (!Pauser.StaticIsPause) i++; } } } public class PausableWaitForSeconds : CustomYieldInstruction { private float waitTime; //実際の待ち時間 IEnumerator wait; public PausableWaitForSeconds(float time) { wait = Wait(time); } public override bool keepWaiting { get { return wait.MoveNext(); } } IEnumerator Wait(float time) { float t = 0; while (t < time) { yield return null; if (!Pauser.StaticIsPause) t += Time.deltaTime; } } } }問題を起こしているWaitforSecondsを上記のPausableWaitforSecondsに置き換えることでちゃんと止まるようになった。
まとめ
全部まとめたコード。
コルーチンはBehaviourのOnDisableで止める。
WaitforSecondsを上記のPausableWaitforSecondsに置き換える。using UnityEngine; using System.Collections; using System.Collections.Generic; using System; public class Pauser : MonoBehaviour { static List<Pauser> targets = new List<Pauser>(); // ポーズ対象のスクリプト Behaviour[] pauseBehavs = null; // ポーズ対象のコンポーネント Rigidbody2D[] pauseRB = null; //ポーズ対象のRigidbody bool isPause = false; public static bool StaticIsPause = false; // 初期化 void Awake() { // ポーズ対象に追加する targets.Add(this); } // 破棄されるとき void OnDestory() { // ポーズ対象から除外する targets.Remove(this); } // ポーズされたとき void OnPause() { if (isPause) { return; } // 有効なBehaviourを取得 pauseBehavs = Array.FindAll(GetComponentsInChildren<Behaviour>(), (obj) => { return obj.enabled; }); pauseRB = Array.FindAll(GetComponentsInChildren<Rigidbody2D>(), (obj) => { return obj.simulated; }); foreach (var com in pauseBehavs) { if (com != null) com.enabled = false; } foreach (var com in pauseRB) { if (com != null) com.simulated = false; } isPause = true; } // ポーズ解除されたとき void OnResume() { if (!isPause) { return; } // ポーズ前の状態にBehaviourの有効状態を復元 foreach (var com in pauseBehavs) { if (com != null) com.enabled = true; } foreach (var com in pauseRB) { if (com != null) com.simulated = true; } pauseBehavs = null; pauseRB = null; isPause = false; } // ポーズ public static void Pause() { foreach (var obj in targets) { if (obj != null) obj.OnPause(); } StaticIsPause = true; } // ポーズ解除 public static void Resume() { foreach (var obj in targets) { if (obj != null) obj.OnResume(); StaticIsPause = false; } } } namespace PauserFunctions { public class PausableWaitForFrames : CustomYieldInstruction { private float waitTime; //実際の待ち時間 IEnumerator wait; public PausableWaitForFrames(int frame) { wait = Wait(frame); } public override bool keepWaiting { get { return wait.MoveNext(); } } IEnumerator Wait(int frame) { int i = 0; while (i < frame) { yield return null; if (!Pauser.StaticIsPause) i++; } } } public class PausableWaitForSeconds : CustomYieldInstruction { private float waitTime; //実際の待ち時間 IEnumerator wait; public PausableWaitForSeconds(float time) { wait = Wait(time); } public override bool keepWaiting { get { return wait.MoveNext(); } } IEnumerator Wait(float time) { float t = 0; while (t < time) { yield return null; if (!Pauser.StaticIsPause) t += Time.deltaTime; } } } }