Edited at

ドット絵ゲームをunityで作るときに押さえておきたいポイント

前置として、これは私がUnityに初挑戦でドット絵のゲームを作ろうとしたものです。思っていたより調べるのが大変だったため、次の人が迷わないように記録を残そうと思います。Unity熟練者による記事ではないことに注意してください。

チュートリアル形式ではなく、ドット絵に関する部分の概要とキーワードを中心に書いていきますので、実装方法に関してはソースを見ながら、キーワードをもとにググっていただければと思います。幸い日本語記事はたくさんあります。何が必要で何を調べれば良いかわかれば初期段階は早いです。今回必要な情報にたどり着けるガイドができればと思います。

こんな感じのゲームです。(動くだけでゲームぽいルールはないです。)

screen004.gif

Unity2017を使用します。ソースはこちらに置いてあります。

https://github.com/pixelflag/hako

(Soundファイルだけ、権利の問題で抜いてありますので必要な方は差し替えてみてください。)


ドット絵を表示するための基本設定


ゲームの画面サイズを決める

ゲームビューから画面サイズを決定します。すべてはここから始めます。今回は1920x1080サイズの1/4サイズの480x270としました。ドットを4倍サイズで表示する想定です。

image001.png


カメラのサイズを決定する

Sizeにゲームビューのサイズ480x270のうち、縦270の半分サイズである135を設定する。

image002.png


アンチエイリアスを切る

メニューから、[Edit] -> [ProjectSetting] -> [Quality]

Inspectorから、Anti AliasingをDisabled(無効)にします。これを無効にしないと、画像がチラチラします。

image003.png


画像の設定をする

画像を扱うときは、すべてこの設定を行う必要があります。


  • Pixels Per Unitを1にする。画像の1ドットをunityの1ユニットに合わせる。座標計算をしやすくします。

  • FilterModeをPoint(No Filter)にする。画像のピクセル補完を切ります。これをやらないとぼやけたり、チラチラします。

  • CompressionをNoneにする。画像の圧縮を切ります。圧縮が入るとピクセルが崩れたり変色します。ドット絵の場合それが目立ちます。
    image004.png


表示する時に、表示座標の少数点を切る。

Unityは座標計算を小数点以下まで計算しています。しかし、ドット絵の場合、半ドット動くとチラつきを起こします。(アンチエイリアスは切っていますので。)なので座標はぴったりである必要があります。しかし、物理演算などは小数点以下まで計算せねばならないため、表示する時にだけ、座標を一時的に修正し、その後元の座標に戻すやり方をします。

このコードをすべての表示物に適用します。

public class PixelObjectBase : MonoBehaviour

{
private Vector3 cashPosition;

void LateUpdate()
{
cashPosition = transform.localPosition;
transform.localPosition = new Vector3(
Mathf.RoundToInt(cashPosition.x),
Mathf.RoundToInt(cashPosition.y),
Mathf.RoundToInt(cashPosition.z)
);
}

void OnRenderObject()
{
transform.localPosition = cashPosition;
}
}

これで、ドット絵がちゃんと表示され、画面上で動かす準備ができました。次はキャラクターを動かしてみます。


キャラクターのアニメーション

Unityではアニメーションと遷移を作成するとき、Animationオブジェクトを作成し、アニメーションの遷移をつないでAnimatorコンポーネントで制御する形を取るようなのですが、シンプルなドットゲーの場合、これは少々大げさでコントロールを困難にします。

そこで、簡易なスクリプトで制御することを選びました。

(これが正解というわけではないので、一例としてみてください。)

2018/6/10追記:Animator使っても結構大丈夫そうになってきました。

Unityでドット絵アニメーションをコードで編集して撮影する


オブジェクトの階層構造

アニメーションの前提として、以下の階層構造があります。

image006.png

今回はBaseSpriteのみに注目してください。

BaseSpriteは、SpriteRendererコンポーネントを持っています。このSpriteRenderer.spriteを入れ替えてアニメーションを実装します。


アニメーションの制御

歩行を例にします。

image005.png

全方向のアニメーションパターンのSpriteを配列で保持し、指定のフレーム数をループしてアニメーションをします。

方向のヘッドフレームを設定し、アニメーションの開始位置を切り替えます。

switch (direction)

{
case CharacterDirection.DOWN: return sprites[downHeadFrame + animationFrame];
case CharacterDirection.LEFT: return sprites[leftHeadFrame + animationFrame];
case CharacterDirection.UP: return sprites[upHeadFrame + animationFrame];
case CharacterDirection.RIGHT: return sprites[rightHeadFrame + animationFrame];
}

方向のヘッドフレーム+アニーメーションのフレームが、その時表示するイメージ番号になります。

今回の場合、歩行(Walk)の他に、待機(Idle)、攻撃(Attack)、ダメージ(Damage)の状態を持ちます。

それぞれにアニメーションを持ちます。(1コマのみですが、制御上はアニメーションです。)

状態はステートで管理し、アニメーションを切り替えます。

        switch (status)

{
case CharacterStatus.IDLE:
case CharacterStatus.WALK:
// 省略
break;
case CharacterStatus.ATTACK:
// 省略
break;
case CharacterStatus.NOCK_BACK:
// 省略
break;
default:
break;
}

状態、方向、アニメーションのフレーム番号の3要素から表示するSpriteを決定し、SpriteRenderer.spriteを入れ替えてアニメーションを表示しています。

エフェクトのアニメーションも、方向や状態が無いにせよ同様の仕組みです。SpriteRenderer.spriteのSpriteを入れ替えるとだけわかれば、他の環境での経験者なら、アニメーションを作れると思います。

スクリプト制御を行うことで、量産を考えたときに、1キャラクターずつアニメーションを登録したりなどの手間を省くことができるのかなと思います。

(いろいろな方法を知らないので、もっと良い物があると思いますが。とりあえずはこれを使いました。)


キャラクター同士の衝突判定

当たり判定は恐らく一般的な手法になります。地形(TileMap)との当たり判定と、キャラクターの当たり判定で別でまとめます。


コリジョンを設定する

共通してキャラクターには、CircleCollider2Dと、RigidBody2Dコンポーネントをつけます。

CharacterRootについているのは、画像イメージとは座標を別で管理するためです。(今回はそういう設定にしましたが、ここは自由。)

image007.png

RigidBody2Dの初期設定から、Sumulated、Gravity Scale、Collision Detectionを変更します。

Gravity Scaleは、ほうっておくと下に落ちていくので0にします。

Collision Detectionは、Continuousにしないと、グイグイ押し込むと、少しずつめり込んでいく現象が起こります。

Simulatedは、よくわかりませんが、安定しました。

image008.png

これで、キャラクター同士が設定したコリジョン同士でグイグイ押し合いするような挙動をすることができるようになります。


攻撃のコリジョンの設定

つぎは、攻撃などのコリジョンどうしが接触したときの処理を作ります。

攻撃の当たり判定は、一つ一つGameObjectにして今回はここに配置しています。目安として武器の画像を配置しています。(これはゲーム中は隠れています。)

image010.png

このgameObjectを攻撃のアニメーションの開始に合わせて有効無効を切り替えて制御します。

hitCollision.enabled = true;


Colliderを設定する。

攻撃判定用のGameObjectにBox Collider2Dを設定します。

このとき、Is TriggerをOnにします。これでこのcollisionをトリガーした情報を得られるようになります。

image009.png


Tagを設定する。

あとで何のTriggerがあったのか判定に使用します。

image011.png


トリガーしたときの処理

敵のスライムの内に、攻撃を受けたときの処理を書きます。

OnTriggerEnter2Dで、自身がもつColliderに、他のColliderが重なったときにトリガーが反応します。

なんのトリガーが反応したのかを判定するためには先ほど設定したtag名を使用します。


Slime.cs

    private void OnTriggerEnter2D(Collider2D col)

{
if (col.tag == "Attack")
{
// ダメージ処理
}
}

OnTriggerでトリガーを受け取るには、RigidBodyコンポーネントが付いている必要があるようです。


表示優先の管理

今回はジャンプをしない、高さの概念が無い、2D平面上のアクションを想定します。


Y軸でソートする

メニューから、[Edit] -> [ProjectSetting] -> [Graphics]

Transparency Sort Modeを、CustomAxisにします。

Transparency Sort AxisのYに1を入れます。

これで、Y値が多いものが表示のソートで奥に表示されるようになります。

image012.png

追記:Yの値をsortingOrderに入れてあげる方法のほうが、柔軟性がありそうです。


グループ化する

Y軸基準に表示のソートを行うと、影などのキャラクターよりも奥に表示したいがY値は小さいというケースが出てきて破綻します。このとき、グループ化をすることで制御します。

対称のGameObjectにSorting Groupコンポーネントを追加することで、Yソートが行われる時に、その階層以下のオブジェクトをグループとして扱うようになります。

image013.png

キャラクターの階層をこうしたのはsorting Groupを想定したものです。

CharacterRootにSorting Groupコンポーネントが適用されています。CharacterRootを、地面との接地面に設定することで、キャラクターの大きさに左右されずにYソートができるということです。

image006.png


Sorting Group内の表示優先の調整

階層内で上のほうが奥に描画されるようです。あとはz軸での調整もできます。カメラから遠いほど奥に表示されます。

Sorting Orderを使用することもできますが、これはグループ内の相対的なソートではなく、全体に影響するので、ここでは使用しません。


TileMap コリジョンの設定

まだ比較的新しい機能なので、情報が少ないですが、使っていくには有効そうです。

image014.png

作り方はググっていただいて、階層は3つにしてあります。

groundは、一番奥の地面です。

collisionは、当たり判定がある地形です。

treeは、木など、プレイヤーよりも手前に表示するためのレイヤーです。

それぞれ表示優先をsorting orderで設定しています。この設定を忘れると、キャラクターが表示されないように見えます。(裏に隠れてます。)

cell sizeは16としましたが、これは任意にマップ用のSpriteサイズと合わせます。

image015.png

collisionレイヤーには、Tilemap Collider2Dコンポーネントが設定されています。

マップのcell単体のコリジョン設定があるかわかりませんが、デフォルトではそのレイヤー全体がコリジョンになるようです。

image016.png

image017.png


FixidUpdateを使用する。

普段、毎フレームに処理をするのは

    void Update()

{
// 座標の更新処理など
}

とすると思いますが、この状態だとマップのコリジョンと衝突したときにブルブル震えます。これは、コリジョンの処理がUpdateの後で行われるためだそうです。(その間に描画処理が入ってる?)

なので、FixedUpdateを使います。

追記:ブルブル震えるのは、オブジェクトの移動にTransform.positionを更新する方法をとっていたからでした。Collisionを使用する場合、rigidBody2Dに対して、addforceなりで操作をするほうが良いようです。

    void FixedUpdate()

{
// 座標の更新処理など
}


その他ハマったポイント

unityの場合、座標を直接代入する方法が無いようです。gameobject.transform.positionにそのgameObjectの座標がありますが、直接position.x = 10のような記述ができません。これは読み取り専用になっています。

座標の更新を行う場合、以下の方法をとるようです。

相対的な移動を指定する。

gameObject.transform.Translate(new vector3(1,0,0));

positionごと入れ替える。

gameObject.transform.position = new Vector3(0,0,0);


おわり

以上です。他にもポイントになる箇所はあると思いますが、代表的なものに絞りました。

とりあえず、ここまで進むことが出来ました。

このやり方で、この先嵌ることもあるかもしれません。特に描画効率、メモリ効率などスマートフォンで問題が起こるんじゃないかなーと予想はしています。

初学者が罠に嵌らないためにも、おかしな点ありましたら訂正コメントを頂けるとありがたいです。(そして私も知りたい!)