テンテケテンテンテンテケテケテケガ〜キのころ見た赤とんぼ(あいさつ)
こんにちは。おれです
ちょっと前に犬がころがるゲーム、コロガリーヌをつくりました。 ブロックを動かして犬がころがる道をつくってゴールさせるパズルゲームのようなものです。
リンク : コロガリーヌ
大傑作。
どんなふうに作ったか書いていきます。それが「心意気」だから…
概要
- 開発期間 : 1ヶ月ほど
- 使用したぎぢつ(技術)
- Unity 2022.3
- Tilemap
- Afinity Designer(イラスト)
- Unity 2022.3
企画
犬の状態を「なんとかーヌ」と表すネットミームがあります。 「かわいい犬 カワイーヌ」「賢い犬 カシコイーヌ」など。
あるとき「ころがる犬 コロガリーヌ」っていうやつを思いついたので、これを言いてえなと思い、うちに犬はいないので、なら作るかということで、ゲーム製作をはじめることにしました。
おれはダジャレを言うためにゲームを作ることがよくあります。いわゆるDDD(ダジャレ駆動開発 Dajare-Driven-Design)です。
とにかく犬が転がればいいだろうということで
- スマホを傾けて犬を転がすゲーム
- 犬をゴルフボールみたいに飛ばしてゴールに入れるゲーム
なども考えましたが、最終的にはブロックを動かして地形を作って、物理エンジンで犬を転がすパズルゲームに落ち着きました。
なんかこう、犬を完全に自分の手で転がすより、犬がころがる状況を準備して犬が上手にころがるのを祈るデザインのほうが、自由に生きて思い通りにならない犬を演出できてかわいいからです。
ぎぢつ選定
つくるものは決まったのですが、どうやってつくろうかな〜と悩みました。 ず〜っと、半年ぐらい悩んだりゲームエンジンを調べたりしました。
要件としては
- Webで公開できる
- できるだけ軽いファイルサイズ
- 犬を転がす地形を作れる(タイルマップ機能がある)
- 犬が自分でころがる(物理エンジンがある)
- できるだけラクに開発したい
などが挙げられました。
これらを満たすゲームエンジンとして
- Unity
- GameMaker Studio2
- Godot
- Phaser(jsエンジン)
- Defold
あたりが候補となりました。これをひとつずつ検証しました。 正直どれ使っても製作はできますが、できるだけラクなやつを選びたいわけです。
以下、簡単な検証結果・感想
GameMaker Studio2
Webに書き出した時にファイルサイズも小さく(1.6MB程度)、クールなエディタもついている。
が、GMLという独自のプログラム言語を使用するので、Unity(C#)やPhaser(TypeScript or Javascript)ほど自由に書けないだろう、また慣れていないので書くのがしんどいな〜。
Godot
Unityの料金周りでひと悶着あった時の、代替として注目されていたやつ。 10年ぐらい前にちょっとだけ触ったことありました(後方彼氏ヅラ)
エディタもクールだし、GDScriptという独自プログラム言語ではあるけどGameMakerStudioよりは自由度が効きそうな感じもある。
ただ致命的にWeb用にエクスポートしたときのサイズがでかい。26MBぐらいある。でかすぎる。(Brotliで結構圧縮できるらしい?)
Defold
あまりメジャーではないが、マルチプラットフォームに対応できて、エンジン自体も小さく。 小さく洗練された感じの雰囲気をもつシャレたゲームエンジン
Webビルドも2MBぐらいのサイズでいい感じで、悪いところはあまりなかった。
ただ、たぶん作るとなったらPhaserを使う場合と工数がそんなに変わらないのでないか?となり、ほなjavascriptのエンジン使った方がいいかとなり、今回は見送らせていただきました。
Phaser
最後までUnityと争った有力候補。javascript製のゲームエンジン。 ファイルサイズも1MBぐらいでいい感じだし、プログラムも自由に書けるし、機能も豊富。
ただこの時点ではタイルマップエディタのTiledを使ったことがないので、タイル周りのプログラムを書くのがしんどいかもな〜
また物理エンジンもUnityのものを使ったほうがいいかもな〜、となりました。
Tiledについてはへとびおじのつくりかたで書いています。
Unity
結局Unityで作ることになりました。
1番の懸念はWebに書き出した時のファイルサイズ、または安定して動くかどうか?だったわけですがむかし(Unity 5ぐらい)と比べてだいぶ良くなっており、空のプロジェクトをビルドした時のサイズも5~6MB程度で、まあ許容範囲内かとなりました。というか小さいね。すごい。
お仕事で使っていることもあって一番慣れているので、Unityで作ることになりました。
開発
ゲームエンジンが決まったのでいよいよ開発に入っていくわけです。
ついては製作の時間を捻出しなければなりません。
おれはメシを食うためにおちごと(お仕事)をしているのですが、おちごと終わったあとはもう疲れちゃってだらだらしてマンガとか読みたいし、寝たいし、ムリ!ヤ!ヤーッ!となっているので、一計を案じなければなりません。
めちゃくちゃ賢いおれはすぐに策を思いつきました。
めちゃくちゃ早起きすればいい、と。
めちゃくちゃ早起きして、ゲーム製作して、おちごと行けばいい。
このゲームの製作を始めてから、夜9時に寝て朝4時ぐらいに起きる生活になりました。
これ書いてるのも朝6時です。
製作開始が8月頭だったので、朝と夏休みをすべて捧げ、ころがる犬のゲームを8/31にリリースすることができました。
ちなみに8/31はおれの誕生日です。よければアップルパイとか焼いて送ってください。
製作の概要
このゲームをつくるうえで、システム的に一番キモの部分は、ブロックの配置部分と、犬の物理挙動です。
物理挙動については各オブジェクト(犬とかネコ)のRigidBody2Dパラメータを、ひたすらに調整します。
どのぐらい重さを持つか?どのぐらい摩擦があるか?ものに当たった時どのぐらい反発するか?など調整して動かして試すの繰り返し。
エディタで動くときとビルド後にブラウザで動かすときと微妙に違ったりするわけです。おそらくフレームレートとかの関係で。そこんところを直したり時には諦めたりしながら開発をしていきます。
ブロックの配置は、Unity標準のTilemap機能を使います。 ブロックのセット(パレット)を作っておいて、それをペシペシ塗っていきます。
こんな感じ。右がパレット。右で選択したブロックを左に配置します。
あれです。スーパーマリオメーカーみたいな感じです。 マリオメーカーとちょっと違うのは、1つ1つのブロックに対して自分でプログラムを書けるところです。
バネに当たった時、ゴールに当たった時、矢印ブロックに当たった時の処理を書いておくことができます。跳んだり重力が変わったりするギミックを自分で作れるわけですね。
ステージの初期位置はこれで開発者(おれ)が配置することができるのですが、緑のブロックはゲームのプレイ中にユーザーが自分で動かすことができるので、緑ブロックの挙動はプログラムで書いていく必要があるわけです。
ブロックを配置するしくみ
ただ図形を置いていくだけならUnityのTilemap機能のままで実装できるのですが、上の画像のように、犬とかネコとかバネとかゴールとか、動かせるブロックとかを配置して、それが動いたり動かせたりしたいときはどうにかそういう仕組みを考えて処理をつくる必要があります。ありました。あるはず。
実際にどんな感じでプログラムを書いたのか見ていきます。でも2ヶ月ぐらい経ってもう全部完全にわすれたので、当時の資料とか見ながら書くね。がんばるね。がんばろっ。
静止ブロック・移動ブロック
まず基本的なしくみとして、静止ブロックと移動ブロックと、二つのタイプのブロックがあるということにしました。
静止ブロックは移動しない固定ブロック、移動ブロックはユーザーが動かすことができるブロックという区分けです。
- 静止ブロック
- 青の四角ブロック
- 青の三角ブロック
- 青のバネブロック
- 青の矢印ブロック
- 移動ブロック
- 緑の四角ブロック
- 緑の三角ブロック
- 緑のバネブロック
- 緑の矢印ブロック
ブロックをうごかすとき
ユーザーがブロックをうごかすときを見てみます。
緑の移動ブロックはタッチして、ずらして好きなところに置けるということにしています。
これがタッチしたときのプログラムです。
public void GrabTile()
{
var worldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Vector3Int cellPosition = tilemap.WorldToCell(worldPosition);
//タイルの種類を識別する
TileBase tile = tilemap.GetTile(cellPosition);
if(tile == null) return;
if(moveTile.IsGrabbing == true) return;
MoveTileName tilename;
if (!System.Enum.TryParse(tile.name, out tilename))
{
return;
}
movableCheck.OnCheck(cellPosition);
//タイル名のMoveTileTypeを取得
Quaternion rotation = tilemap.GetTransformMatrix(cellPosition).rotation;
moveTile.GrabTile(tile, tilename, cellPosition,rotation);
//移動元タイルのアルファ値を0.5fにする
tilemap.SetTileFlags(cellPosition, TileFlags.None);
tilemap.SetColor(cellPosition, new Color(1, 1, 1, 0.5f));
}
英語がいっぱい書いてあってかっこいいですね。
GrabTile()は関数というやつで、タップを検知したときに処理が走るようになっています。
var worldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Vector3Int cellPosition = tilemap.WorldToCell(worldPosition);
//タイルの種類を識別する
TileBase tile = tilemap.GetTile(cellPosition);
↑ここのところで
- マウスの位置を取得
- マウスの位置がタイルマップのどのマスにあるか?を取得
- そのマスのタイルを取得
というようなことをやっています。
[SerializeField] Tilemap tilemap;
↑タイルマップはこう定義されていて、ここに配置したブロックの情報とかが入っているわけですね。
WorldToCellというのがUnityで用意されていて、これでもって画面上のある座標がタイルマップ上のマス目でいうとどのマスなのか〜ということを教えてくれます⇩
Vector3Int cellPosition = tilemap.WorldToCell(worldPosition);
画面 x : 100, y: 240 は 2行目の1列目のマス、みたいな
で、そのマスのタイルを取得して、そいつが何物なのかを判断します⇩
TileBase tile = tilemap.GetTile(cellPosition);
GetTileにマス目を渡すと、そのマスのタイルを返してくれるという具合ですね。
//タイルの種類を識別する
TileBase tile = tilemap.GetTile(cellPosition);
if(tile == null) return;
if(moveTile.IsGrabbing == true) return;
MoveTileName tilename;
if (!System.Enum.TryParse(tile.name, out tilename))
{
return;
}
↑さわったところになにもなかったら処理を終了、すでにブロックを移動中だった時も処理を終了、タイルはあるけど移動ブロックではない時も処理を終了、という具合でいらん処理を弾いていきます。
移動ブロックかどうかは列挙型というやつで判定を行っています。⇩
// 動かせるタイルを列挙 ファイル名:タイル名に合わせる
public enum MoveTileName
{
move_sankaku,
move_square,
move_jump,
move_gravity
}
この中になかったら移動ブロックではない、という具合。
画像ファイル名,タイルのファイル名がそのまま列挙型の名前となるようにしました。これはあまり美しくないソリューションかもしれない。もっといい方法あるのかもしれない。だがいいんだそんなこたあ。今あるカードを切っていくしかねえんだよオラッ
moveTile.GrabTile(tile, tilename, cellPosition,rotation);
//移動元タイルのアルファ値を0.5fにする
tilemap.SetTileFlags(cellPosition, TileFlags.None);
tilemap.SetColor(cellPosition, new Color(1, 1, 1, 0.5f));
↑これがゆびの位置にブロックを追従させる処理と、移動元のタイルを半透明にしておく、見栄えの部分です。
moveTile.GrabTile(tile, tilename, cellPosition,rotation);
↑追従ブロック(moveTileクラス)は独立したゲームオブジェクトとしてシーンに1個作ってあるという想定です。これにデータを渡すと移動元のブロックの画像が、指についてくるようになってます。
ブロックをはなしたとき
ブロックを掴んで移動して離した時の処理がこれです⇩
public void ReleaseTile()
{
if(!moveTile.IsGrabbing) return;
moveTile.ReleaseTile();
tilemap.SetTileFlags(moveTile.BeforePosition, TileFlags.None);
tilemap.SetColor(moveTile.BeforePosition, new Color(1, 1, 1, 1f));
tilemap.SetTileFlags(moveTile.BeforePosition, TileFlags.LockColor);
var worldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Vector3Int hit = HitTest(worldPosition);
movableCheck.OffCheck();
// tilemap min max の範囲内か
var bound = tilemap.cellBounds;
if(hit.x < bound.min.x || hit.x >= bound.max.x || hit.y < bound.min.y || hit.y >= bound.max.y)
{
return;
}
//ヒット位置にタイルが無ければタイルを置く
if(tilemap.GetTile(hit) == null)
{
tilemap.SetTile(hit, moveTile.TileBase);
//回転を付与する
tilemap.SetTransformMatrix(hit, Matrix4x4.Rotate(moveTile.BeforeRotation));
//移動元のタイルを削除
tilemap.SetTile(moveTile.BeforePosition, null);
//移動元のタイルのアルファ値を1fにする
}
}
なんだっけなこれ
moveTile.ReleaseTile();
tilemap.SetTileFlags(moveTile.BeforePosition, TileFlags.None);
tilemap.SetColor(moveTile.BeforePosition, new Color(1, 1, 1, 1f));
tilemap.SetTileFlags(moveTile.BeforePosition, TileFlags.LockColor);
↑moveTileのReleaseTileも呼びつつ、SetColorで移動前のブロックの色を元に戻していますね。その前後は色変える時だけロックを外してる(TileFlags.LockColor)みたいなノリです。たぶんね。
そしてタッチを離した位置(移動先)の座標を取ります⇩
var worldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Vector3Int hit = HitTest(worldPosition);
で、移動先が画面外とかだったら困るので、想定したマスの領域に収まっているかどうかを調べています⇩
// tilemap min max の範囲内か
var bound = tilemap.cellBounds;
if(hit.x < bound.min.x || hit.x >= bound.max.x || hit.y < bound.min.y || hit.y >= bound.max.y)
{
return;
}
そして、移動先のマスが空だったら、いよいよタイルを置く処理を入れるという具合です⇩
//ヒット位置にタイルが無ければタイルを置く
if(tilemap.GetTile(hit) == null)
{
tilemap.SetTile(hit, moveTile.TileBase);
//回転を付与する
tilemap.SetTransformMatrix(hit, Matrix4x4.Rotate(moveTile.BeforeRotation));
//移動元のタイルを削除
tilemap.SetTile(moveTile.BeforePosition, null);
//移動元のタイルのアルファ値を1fにする
}
この部分がタイルをセットする処理⇩
tilemap.SetTile(hit, moveTile.TileBase);
hitの位置に、moveTileクラスに保持していたTileBaseというやつを入れる、という感じ。 TileBaseはタイルのことだと思ってもらえればいいです。この位置にタイル置くぞ、という命令ですね。
tilemap.SetTransformMatrix(hit, Matrix4x4.Rotate(moveTile.BeforeRotation));
↑ここでタイルの角度を設定しています(処理は省きますが短くタッチするとタイルが回転する機能があります)。移動元のタイルの角度と合わせて設定します。
移動についてはおおむねこんな具合です。
生成ブロック
これに加えて、なんらかのロジックをもつオブジェクトを召喚する生成ブロックというものがあるということにしました。バネとかゴールとかのなんらかアクションを起こすものや、キャラクターの犬とかネコとかのことです。
ゴールするとか、あたったらハネるブロックとかはTilemapだけで作るのはきびしいわけです。
そこで、生成ブロックに属するものは、プレイボタンが押された瞬間に、生成ブロックの位置に、対応したゲームオブジェクトを生成することにしました。
犬のタイルが置かれている位置に、犬の実体(ゲームオブジェクト)が生成されると言う感じですね。Unityやっていないとイメージがつかみにくいかもしれませんが、Unityは基本的にゲームオブジェクトという単位で、モノを動かしていきます。
犬のオブジェクトには、犬のプログラムや、犬の物理的実体、犬のあたり判定、などが詰まっとるわけです。
犬の実体
これを、プレイボタンを押した時にただのタイルからオブジェクトにスッと差し替えるワケです。案外バレませんわな。同じ見た目しとるからな
以下がプログラムです。よみとばしていいです。
public void CreateObjectTile(){
var bound = tilemap.cellBounds;
inuCount = 0;
for (int y = bound.max.y - 1; y >= bound.min.y; --y)
{
for (int x = bound.min.x; x < bound.max.x; ++x)
{
TileBase tile = tilemap.GetTile(new Vector3Int(x, y, 0));
if(tile != null)
{
//タイル名がObjectTileNameに含まれていたらオブジェクトを生成する
ObjectTileName objectTileName;
if (!System.Enum.TryParse(tile.name, out objectTileName))
{
continue;
}
Quaternion rotation = tilemap.GetTransformMatrix(new Vector3Int(x, y, 0)).rotation;
switch(objectTileName){
case ObjectTileName.fix_gravity:
case ObjectTileName.move_gravity:
GameObject gravityBlock = Instantiate(spawnObjectPrefab[(int)objectTileName], tilemap.GetCellCenterWorld(new Vector3Int(x, y, 0)), Quaternion.identity);
gravityBlock.transform.SetParent(objectTileParent);
gravityBlock.transform.rotation = rotation;
gravityBlocks.Add(gravityBlock.GetComponent<GravityBlock>());
break;
default:
GameObject obj = Instantiate(spawnObjectPrefab[(int)objectTileName], tilemap.GetCellCenterWorld(new Vector3Int(x, y, 0)), Quaternion.identity);
obj.transform.SetParent(objectTileParent);
obj.transform.rotation = rotation;
break;
}
if(objectTileName == ObjectTileName.Inu){
inuCount++;
}
//タイルを透明にする
tilemap.SetTileFlags(new Vector3Int(x, y, 0), TileFlags.None);
tilemap.SetColor(new Vector3Int(x, y, 0), new Color(1, 1, 1, 0f));
}
}
}
}
なげーこと書いてますが、1マスずつタイルを調べて、対応するオブジェクトを生成するということをやっているだけですね。オブジェクトを生成したところの生成ブロックは透明にしています。これですりかわるというわけ。チョロいね。
ブロックの配置する仕組みについてはこんなところです。そしてこのゲームのプログラムっぽいもの大部分がこれです。だいたいこれだけです。
バネ
バネな。
かんたんそうに見えて、実装が結構やっかいなやつです。
バネをつくろうというときに、だいたいふたつの方法がありました。
- バネに犬があたった時、犬自身に加速をつけて飛ばす
- 物理的実体をもったバネの床をハネあげて、物理エンジンで飛ばす
最初は1で作ってましたが、物理で飛ばしたほうが自然な挙動に見えるので最終2になりました。物理的な物体であれば相手がなんだろうが飛ばせるので、犬でもネコでもそのまんま飛ばせるのはよかったです。
見づらいかもですが、赤いところにある四角の線がバネのあたり判定で、これが物理的な実体をもってます。これがすげー勢いで上に移動することで、上にいる犬も飛ぶという、シンプルな仕組み。
しかしシンプルゆえに色々悪さができます。バネが上にハネあがるとき、バネの床は反作用とかを受けない無限の力で動きます。なので使い方によってはすげえ力がでる。バネと壁で犬を挟んでバネ動かすと無限のちからで犬をおしつぶし、犬がすげえ力で飛ぶ、運が悪いと犬が壁を突き抜け、虚空へ消える。
そんな感じでゲームを破壊する力をもったバネです。
あとバネの挙動(押し出す力とか)をちょっと変えると、バネを使うステージを全部見直すことになるので、結構たいへんですね
とってもたいへん、でも何回かやりました。
矢印ブロック
ふむと矢印の方向に重力が変わる矢印ブロック。
仕組みは結構シンプルで、実装もわりと簡単で、ゲーム的な効果は高い、かわいいやつです。
UnityはPhysics2D.gravityってやつで重力の方向を変えられるのでこれを矢印の方向へ変更するだけ。カンタンっすね。
カンタンなわりに費用対効果の高いヤツで、こいつのおかげで狭いマップのなかで動きの選択肢を増やすことができました。このゲームのMVPです。よーやった。
犬とねこ
犬とねこです、かわいいね。
あまり言うことはないです。ころがるだけだから、こいつら。
ねこは犬より物理的実態(RigidBody2d)のMassが高く、ねこのほうが重いので、犬とねこがあたると犬がぶっとぶようにしています。それぐらいです。
かわいいね。
ステージづくり
ブロックを動かす仕組みをつくり、バネや矢印を用意し、犬とねこがころがるようになりました。
いよいよステージをつくっていくわけです。
ここからが長い。開発時間の半分以上はステージ作ってる時間ですね。
いろいろネタを考え、ちまちまとブロックを配置しては試し、調整するの繰り返し
たいへんたいへん、たーいへん。わからん、なーんもわからん。おもしろいのか?むずかしいのか?かんたんすぎるのか?おもしろいのか?だいじょうぶなのか?だいじょうぶ?これおもしろい?だいじょうぶ?むずかしい?ねえ?どうなの?ねえって!!キャーーーーーーーーー!!!
とかなりながら、地道に作っていきます。ひと夏中、これを繰り返し、ついには狂気に蝕まれ、正気を失う。人間性を捧げよ。しかし、ここが重要です、ゲームは完成する。
公開
そんなわけで人ではなくなりましたが、ゲームは完成しました。公開をしていきます。 UnityにはWebビルドというものがあり、これ使ってWebに公開できます。
読み込み時間を短くするためになるべく小さいファイルサイズで出したいので、 圧縮とかの設定をやっておきます。
- BuildSettings > Code Optimization : Data Size
- ProjectSettings > Compression Format : Brotli
あと WebGLTemplatesフォルダを作っておくとよいです。 こんな感じのHTMLファイルで書き出しますよというのを指定するやつですね。
PCでもスマホでもいい感じにスケールして動くようにちょっとcssを書いたりしています。 どんな感じなのかはゲームのページを開いて実際に見てみような。
F12とかで開発者ツール開いて見れます。
おわり
おわりだああああーーーーーーーー!!!!解散ーーーーーーーー!!!!
あとは各自好きに生きろ!!!!!
元気でな!!!あばよ!!!!!!!!!