これはKMCアドベントカレンダー2020の23日目の記事です。 adventar.org
昨日の記事はtronさんの「マルコフ連鎖でデュエマの能力テキストを作る」でした。自分も確率モデルを使って面白いものを作ってみたいです。 tron-factory.hatenablog.com
ここ最近、Unityという機能豊富なゲームエンジンを使ってゲームを作っています(そろそろ完成させたい)。基本的には提供されている機能を使えると楽なのですが、物理法則に微妙に従わない挙動を実現させようとしたところ上手くいかず、結局Unityの機能の一部(Collider)を再発明することになった、という話です。
ゲームの概要と制作チームの人々
キャラクターをキーボードで操作して、迷路にいる敵を避けながらゴールを目指すゲームです。制作に取り組んでいるのは、面倒を見てくれている上級生を除いて、主ディレクター1人、イラストレーター1人(後半で1人増えました)、プログラマー4人です。自分は副ディレクター(取りまとめ業)とプログラマをやっていることになっています(なっています、ディレクターは能力がないと邪魔にしかならない)。明らかにデザインの人手の方が少なく、気軽に描き直しは頼めない陣容です。
何に困ったか
キャラクターの大きさと迷路の幅が同じことです。この仕様を厳密に守ると、今いる通路から直角に出ている通路に入ろうとしても、干渉してしまって曲がれません。また、曲がりやすいようにキャラクターの当たり判定を緩くすると、通路中でキャラクターがふらついてしまいます。
対策0
- ブロックのサイズとキャラクターのサイズを変更する
- キャラクターの移動をジャンプのような不連続な移動に変更する
- 参考:かたぐるまクローンズ:unity1weekの第2位になっていたゲーム
1つ目の選択肢はイラスト再制作になるのと、仕様書があちこち書き直しになるので実施しませんでした。2つ目の選択肢は魅力的に見えますが、気付いた時には実装がほぼ終わってしまっていました。
対策1(ボツ)
今回のようなゲームでは、キャラクターにアタッチするコライダー(衝突判定用のunity組み込みのオブジェクト)は普通1つで十分だろうと思います。そのコライダーが大きすぎて干渉するのが問題なので、角の部分を削ろうというという案です。小さめのBoxColliderを4つ使っています。この案だと角にはコライダーが設置されておらず、曲がり角と重複しても干渉しません。
問題点その1
企画書をよく確認すると、操作する動物キャラクターには「狭い通路」も通過出来るもの、出来ないものの2種類が存在することが分かりました。この実装だと1種類の通路にしか対応できていません。
対策2(ボツ)
BoxColliderを8つ使って細かい切れ込みを入れたもの、そうでないもの、の2種類を使用するという案です。切れ込みがない方は通路入り口の小さいブロックに干渉して「細い」通路には侵入出来ません(薄い茶色の部分は人間には見えますが衝突判定には関わりません)。
問題点その2
ここまでは通路への侵入の可否について考えてきました。しかし、通路に入ることだけでなく、通路の中からは出口以外で外に出ないことも必要です。上の図では、キャラクターは細い通路の内部にいますが、出口でないのに外に出られてしまいます。
この問題点には安直な解決策があって、細い通路を構成するブロックの周りは普通の壁で囲まれている、という仕様を追加することで解決します。さて、これで最後まで問題なくゲームが作り切れるでしょうか?自分は細かい考慮漏れを防ぐのは得意ではありません。ゲームの仕様書を確認すると、「細い通路」以外にも「一方通行の場所」や「動くブロック」などもあって、それらの対応がまだ出来ていません。
対策3(Colliderの再発明)
複数の長方形を良い具合に配置するパズルを解いても将来に向けて身に付くものがなさそうです(飽きました)。UnityEngine.Colliderを使うのをやめて自力で実装してしまうことにします。これまでの検討で、各地点から隣の区画に移動可能かという情報を、キャラクターの種類(大きさ)ごとに調べられると良いことは分かっています。
注意が必要なのは「中途半端」な位置にいるキャラクターを適切な位置にどう回復してやるかという点です。これ以上考慮漏れで作り直しをするのはつらいので、手間を惜しまず全パターン列挙して漏れなく考慮します。(ここからは、壁はブロックを並べて構成するものとします。また、キャラクターの速度に対して十分に速いフレームレートが維持できているものとします。)
網目のマスはブロックのある位置、青色の枠は補正前のキャラクターの位置、赤色は補正後のキャラクターの位置です。青の枠が網目のマスに重複しているのはおかしいので、修正が必要です。1フレーム前は適切な場所にいたはずなので、それを考慮して修正したいと思います。
対角の区画にのみブロックがある場合以外はブロックとの重複が大きい辺をブロック外に戻すだけで良さそうです。逆に、対角にある場合は、位置だけでなく、キャラクターの移動方向に応じた変則的な修正が必要だと分かります。上方向に進んでいて許容範囲内のズレの場合はx座標を整数に、左方向に進んでいて許容範囲内のズレの場合はy座標を整数に、それ以外の場合はx、y座標ともに整数に修正します。
ブロックの実装
実装について考えます。各種Block
、キャラクター
の2種類のクラスは最低限必要です。移動方向に合わせて位置を修正する関数はキャラクタークラスに置きたいですが、その関数の内部でブロックの内部の配列等(しかもブロックの種類ごとに異なるはず)を触るのは、ブロッククラスの実装の詳細を知りすぎに思います。修正に必要なのはブロック全てではなく、キャラクターと接触している一部のブロックだけなので、その情報を伝達するクラス、それも使い捨て出来るものがあると良いです。そこでUnityのCollliderに似たもの
も実装することにしました。
あとはやるだけです。適宜テストを書いてやっていきます(これは嘘で一回書いてからちょっとリファクタリングしました)。
- 普通のブロック
配列でブロックの位置を管理するだけのやつです。
public class DefaultBlockSet { // blockがある座標はブロックのID、ない座標は-1 private int[,] board; private int xmin, xmax; private int ymin, ymax; // "pos"にブロックがあるか調べる public bool Exist(int[] pos); // キャラクターの位置がposの時に干渉する座標のうち、posから一番近い整数座標以外のリストを返す public List<int[]> GetConflictPos(UnitVec2 pos); }
- 細い通路を含むブロック
普通のブロックとほとんど同じですが、方向によって侵入不可だったりするので4種類の配列を持ちます。また、キャラクターの大きさに応じて応答を変えます。
public class PathBlockSet { private int[,] board; // isXXXSide[i, j] is true if the small character can enter from the XXX-side private static bool[,] isLeftSideOpen; private static bool[,] isUpSideOpen; private static bool[,] isRightSideOpen; private static bool[,] isDownSideOpen; private int xmin, xmax; private int ymin, ymax; // "pos"にブロックがあるか調べる public bool Exist(int[] pos); // "(pos[0] + dx, pos[1] + dy)" に干渉せず "pos"に進めるか調べる public bool IsOpen(int[] pos, int dx, int dy, AnimalSize size = AnimalSize.Large); // キャラクターの位置がposの時に干渉する座標のうち、posから一番近い整数座標以外のリストを返す public List<int[]> GetConflictPos(UnitVec2 pos, AnimalSize size); }
- Collider
コライダーはブロックの詳細を隠蔽しつつブロックの存在をキャラクターに伝えます。動くブロックが仕様にあるのでコライダーを押すことができるようにしてあります。
public class BlockCollider { public BlockType type { get { return _type; } } private BlockType _type; // ブロックの種類ごとに内部の実装が異なるのでinterfaceの背後に隠しておく private IBlockColliderImpl impl; public UnitVec2 position { get { return impl.position; } } // 動くブロックも仕様にあるのでこのような関数も用意する public void PushTo(UnitVec2 position, AnimalType type) { impl.PushTo(position, type); } // ブロックの種類毎に異なる関数で生成する public static BlockCollider CreateDefault();
- Colliderを生成するクラス
このクラスをキャラクターに渡しておくとColliderを適宜生成できます。
public interface IColliderSpawner { List<BlockCollider> GetBlockCollider(UnitVec2 position, AnimalSize size); }
- 動くブロックの位置の辻褄を合わせるクラス
毎フレームごとにキャラクターの動きと移動しないブロック類の位置と矛盾しないようにブロックの位置を決めます。
public interface IPositionManager { // キャラクターは移動したら位置を知らせる void RegisterMove(IPlayerMoverForPM player, UnitVec2 position); void RegisterMove(NonPlayerMover nonPlayer, UnitVec2 position); // 動くブロックに対応するColliderはPushされたらこの関数を呼ぶ void RegisterMovablePushed(int idx, UnitVec2 position); } public class PositionManager : MonoBehaviour, IPositionManager { // プレイヤーをメンバに持っておく private IPlayerMoverForPM player; private UnitVec2 playerNewPos; // 各種ブロックをメンバに持っておく private DefaultBlockSet dblock; private PathBlockSet pblock; private List<FragileBlock> fblock; private List<MovableBlock> mblock; private System.Object _lockmblockNewPos = new System.Object(); private List<Tuple<int, UnitVec2>> mblockNewPos = new List<Tuple<int, UnitVec2>>(); }
クラスの定義は以上のような雰囲気になりました。キャラクターの位置の修正については、以下の図のように、PositionManagerクラスのUpdate関数を起点に必要な関数群を循環して呼び出す感じになりました(これの解決は難しそう)。
最後に
テストと画像の読み込みなども含めて4500行くらいになりました。普通の想定だと500行も必要ないものが膨らんでしまったことになります。自分はあまりゲームで遊んだことがないので気付かなかったのですが、キャラクターのサイズと通路の幅が同じでかつ、キャラクターが連続的に移動するゲームというのはあまり例がなさそうで、経験豊富なゲームプログラマは仕様の段階で修正を依頼しているのかもしれないという感想になりました。また、もっとマシな実装などがあれば教えていただけると嬉しいです。ゲームは3月のNF(11月祭の略、しかしMFでは?)で公開予定です。
明日はcc141さんの「旅館のはなし」です。一体どんな旅館の話なのでしょうか?明日も楽しみです。
KMCについて
KMCの名前にはマイコンとありますが、それに限らず本当に色々あります。新入生プロジェクトだけに限っても、ゲーム制作、ウェブサービス制作、音楽制作、競技プログラミングなどがあり、コンピューターを使って何かしてみたい人という人には思いがけないきっかけが多い環境だと思います。気になった方はホームページからどうぞ。