Unityで2Dアクションゲームを制作したときの知見


はじめに

まずは公式リファレンスを見よう

一番言いたいことを最初に書きました。

UnderRocketというゲーム制作を通して得た知見を(自分用のメモとして)残しておきます。
Unityを学び始めて1年未満くらいの人向けの内容です。

特に以下のようなものを実装したい人向けです
・2Dのアクションゲーム
・WebGLビルドをし、公開する
・ハイスコアを保存、共有したい

(UnderRocketは以下のサイトで遊べるので、ぜひプレイしてみてください^^)
UnderRocket | unityroom

汚いですがコードはこちら
UnderRocket GitHub

TilemapとCinemachine

ステージ外を移さないカメラ移動

Cinemachineを利用します。
インストールの仕方を調べると、AssetStoreからインストールという記事がたくさんありますが、
現在はUnityエディタからインストールできます。

一番注意する点として、カメラの枠より小さい範囲を映そうとすると上手くいきません。
ステージの大きさや、映す範囲には注意してください。

Cinemachineインストール方法
Cinemachineの基本的な使い方

Tilemapを利用する

2Dゲームで素直にsceneにgameObjectを置いてステージを作成しようとすると、だいぶ大変です。
Tilemapを利用すると、簡単に描画・Collider配置ができます。

以下の2サイトを見れば、できると思うので、利用したことがない人はぜひ
コガネブログ
Qiita

Tilemapのtileひとつひとつの間隔が空いてしまう

原因: SpritesのPixels Per Unitのサイズがタイルに合っていない

上の画像の場合は、Pixels Per Unitの値を16にする

TilemapとCinemachineを共存させる方法

Cinemachineの映す範囲を、Cinemachine ConfinerのBounding Shape2Dで指定します。

ここで、代入するものは、Tilemapとは別でステージの範囲に合わせて作成したCompositeCollider2D。

当然ですが、TilemapのcompositeColliderをカメラの枠にするとうまく動きません。
そのため、ステージの枠用のCompositeCollider2Dを別で用意する必要があるのです。

ただし、Tilemapと別ステージ枠を設定することで、本来狭すぎてCinemachineで映せなかった部分を、実際のステージより大きくすることで映すということができます(ステージ外が映ってしまいますが)。

NCMB

NCMBを利用し、オンラインランキング機能を実装する

今回オンラインランキング機能を実装するにあたり、
NCMB(Nifty Cloud Mobile Backend)というサービスを利用しました。
無料でひと月100万回APIリクエストできるすごいやつです。
NCMB公式サイト

公式のマニュアルや、記事を漁れば環境構築と基本的な実装は難しくないはずです。
クイックスタート(公式)
機能別コード例
コルーチンをいい感じに

前座: NCMBはUnityのWebGLビルドに対応しているのか

開発している途中で、実装は間違っていないはずでUnityエディタ上では動くのに、ブラウザ上では上手く動かないということがありました。
調べると「NCMBはUnityのWebGLに対応していない」とか「WebGL専用のスクリプトを用意してます」みたいなニュアンスの記事を目にします。

結論を言うと専用のスクリプトを使わなくても、WebGL版でもちゃんと動きます。
動かないとすれば、ビルドの設定やその他諸々の問題です。

createDateが参照できない

NCMBのデータストアを利用する際、最初からデータ格納日時である「createDate」とデータ更新日時である「updateDate」が用意されています。
しかし、このcreateDateが参照できなく...ない...ない...
...ありました。

自分で作成したDBでいうところのカラム(PlayerName, Scoreなど)はobj["PlayerName"]などと参照しているので、てっきりobj["createDate"]で参照できると思っていました。
obj.CreateDateでした。
この仕様は一般的なんですかね...私はわかりません。

private NCMBObject obj;

Debug.Log(obj["createDate"].ToString()); //error
Debug.Log(obj.CreateDate.ToString()); //ok

データストアへの登録が終わったかわからない

NCMBObjectに用意されているSaveAsyncを利用してデータストアへのセーブ作業をしているのですが、この関数、エラーは返しても結果は返してくれません。
SaveAsync

例えばスコアランキングを作っていたとして、
①プレイヤーのスコアをデータストアに登録
②データストアからスコアが高い順にn人取得
とやったときに①が完了する前に②をおこなうと、②の結果が変わる可能性があります。

この問題は、プレイヤーの送信予定のデータとデータストアから持ってきたデータを比較することで解決しました。
①データストアからスコアが高い順にn人取得
②n人のスコアと、プレイヤーのデータで比較、処理(クライアント側)。
③プレイヤーのスコアをデータストアに登録

実装

当たり判定を残したまま、壁で跳ね返す

これには様々な方法があると思いますが、今回はRigidbody2Dを利用した方法です。

・Rigidbody2DのBodyTypeをDynamic
・CircleCollider2DのIsTriggerをfalse
・動きの制御はRigidbody2Dで
これさえ守れば、PhysicsMaterialの値次第で様々な跳ね返りができますね。

しかし、「壁でだけ跳ね返ってほしいのに、他のgameObjectとも物理的な衝突処理がされてしまう」という問題になりがちです。

この問題の解決策のひとつとして、「壁衝突用、他衝突フラグ用でふたつのgameObjectを用意する」があります。
・壁衝突用...CircleCollider(IsTrigger = false)、Layerを壁用のLayerと検知(他不要なものは検知しない)

・他衝突フラグ用...見た目に合わせたCollider(IsTrigger = true)、Layerは特に指定なし(Layer単位で無視したいものがあれば)

Layer同士無視するかどうかは、Edit->ProjectSettings->LayerCollisionMatrixから設定できます

毎回データストアを参照しない

UnderRocketの話をすると、各ステージごとにクリアタイム順10名と最近クリア10名の名前とタイム(or日付)を表示します。
プレイヤーがミッションクリアしたときは、ランクインしている可能性があるのでデータストアから最新の情報を読み取ります。
しかし、ミッション失敗したときはどうでしょう?私は最新の情報である必要性は低いと考えました。
そのため、ランキングデータを初回取得時にクライアント側に格納し、ランキング一覧表示や失敗時には、すでにあるデータはクライアント側から、無いデータだけをデータストアからとってくるという処理にしました。

データはDictionaryとして格納
keyはミッション名(SceneManager.GetActiveScene().nameと一致)
valueはstring2つ(playerNameと、timeまたはdate)を持つクラス

プロパティもシリアライズ化したい

「プロパティだけでいいのに、SerializeFieldでInspector上から編集したいから、仕方なくフィールドを用意。プロパティの初期値をフィールドから参照。」

[SerializeField] private int hp;
public int Hp {get; private set;}
private void Awake(){
    this.Hp = this.hp;
}

こういうとき、ないですか?
プロパティの初期値をSerializeFieldで変えられたら...最高ですよね?

そんな魔法がこちら↓
コガネブログ

※公式で意図されたものかわからないのでご用心

フォント

WebGLのビルドでは、Unityのデフォルトのフォント(Arial)を利用していると日本語が表示されません。
日本語に対応したフォントに変更する必要があります。
また、フォントによって大きさが違うので、表示の変化に注意してください。

フォントを一括で変更するには、以下のサイトを参考にしました↓
フォント一括変更

ツイートボタン

WebGL版でツイートする場合、現在開いているウィンドウとは別ウィンドウで開く、という処理が必要になります。
「エディタ上ではできるけど、ブラウザ上からはツイートできない」みたいな人は必見↓

WebGLでツイートボタン

Maximum call stack size exceeded


⇈エラー画像

NCMBを利用して初めてのビルドをし、unityroomに反映させたら出たエラー。
当時、ロードシーンにNCMBSettings等のgameObjectや、BGMなどをロードするgameObjectを置いていました。
そしてロードシーンのStart()でタイトルシーンへ移動するようにしていたのです。

つまり、最初の処理でやることが多すぎ、ということだったんですね。
「NCMBがダメなのか?」と当時試行錯誤しましたが、NCMB関連のgameObjectをタイトルシーンに移動させることで解決しました。

フルスクリーンのときに文字の表示が違う(大きさ)

フルスクリーンにしたときに、Canvas内の見え方が変わるというお話。
今まで見えていたTextがフルスクリーンにすると範囲をはみ出て見えなくなってしまった、なんてこともありました。

Canvasの設定でなんとかできそうですが、今はどちらのサイズでも注意する、とだけしています。
どなたか知りませんか(小声)

停止処理をtimeScaleに頼らない

動作停止の簡単な実装方法としてTime.timeScaleを0にするというものがあります。
これによりUpdateは呼ばれたままで、Time.deltaTimeが0になったり、FixedUpdateが呼ばれなくなったりします。
つまり、プレイヤーの入力は受け取るまま、時間経過によるgameObjectの動きを止められます。

最初この方法を使っていたのですが、NCMBを利用するにあたって、主にコルーチンが動かしたいときに動かせない状態になってしまいました。
自作のものなら実時間で測るコルーチンを利用すれば、できなくはないのですが...。

他の機能も実装しづらくなる可能性があるので、timeScaleを使わずフラグによって処理する形にしました。
Update()の冒頭で、止まっているフラグならRigidbody2D.simulated = falseにするなどです。

Unityの便利機能

ColliderとRigidbodyのPhysicsMaterialの違い

ColliderとPhysicsの両方にPhysicsMaterialを代入できます。

ColliderとPhysicsのPhysicsMaterialには優先順位があります。
Colliderのほうが優先順位が高く、
基本となるPhysicsMaterialはRigidBodyに、特定の部分だけ変える場合はColliderに
という認識でよさそうです。

ちなみに、何も代入しない場合のデフォルトは
friction = 0.4、bounciness = 0

詳しくはこちら↓
UnityEngine.Rigidbody2D-sharedMaterial

UnityEvent

UnityEvent 公式リファレンス

Buttonで出てくるこれ

Inspector上でボタンが押されたときにする処理を設定できて、便利ですよね。

注意する点
・UnityEventにInspector上から何も代入してないときに、Invoke()で呼ぶと、エラーにならず次の処理に進む。
・呼ぶ関数の引数は1つまで

サンプルコード

using UnityEngine.Events;

/*省略*/

[SerializeField] private UnityEvent haveDamaged; //攻撃を受けた際の処理
[SerializeField] private UnityEvent destroyMe; //自身が破壊されるときの処理
[SerializeField] private UnityEvent contactEnemy; //敵にあたったときの処理

private void OnTriggerEnter2D(Collider2D collision)
{
    if (collision.CompareTag("Enemy"))
    {
        this.contactEnemy.Invoke();
    }
    if (collision.CompareTag("AttackToPlayer"))
    {
        this.hp--;
        this.haveDamaged.Invoke();
        if (this.hp <= 0)
        {
            this.destroyMe.Invoke();
            Destroy(this.gameObject);
        }
    }
}

スクロールビュー

テラシュールブログ

Tilemap同様、使ってみたら意外と便利だったシリーズ。
大事なのは、contentにvertualGroupをいれることです。
contentの中に空のgameObject→その中に入れたいものを複数入れるという流れ
contentに入るContentSizeFitterは「Unconstrained」にしておくことで自由にwidth/heightを変えることができます

公式リファレンス

OnEnable()

gameObjectがactiveになったときに呼ばれます。
Awake()より後、Start()より前です。
OnDisable()もあります。
Update()等目立ちすぎて、影薄くなりがちですが、大変便利です。

呼ばれる順番

クリックされたときに呼ばれたいが、ボタンのように選択してほしくない

これはUnderRocketの右上歯車アイコンを押すと、設定が開く、といったときの話です。
Buttonの画像だけ変えるとそれっぽくなりますが、どうしてもゲームの入力中に間違って選択してしまい、そのままEnter/Spaceで押してしまったりします。

そこで、EventTriggerをアタッチしてPointerClickの処理を追加することで、簡単にマウスクリック時の処理ができます。
知ってるかどうかの問題ですが、本当便利ですね。
なお、クリックされたかの判定は、Imageの画像の範囲に自動で合わせてくれていました。