実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

Flame05:Flame公式ドキュメントを読み下していく5

はじめに

Flame公式ドキュメントを読み下していく第5弾の今回は当たり判定です。

キャラクタの表示と、そして当たり判定とその処理がゲームでは欠かせないものですよね。逆に言うとこの仕組さえわかればゲームづくりの骨子はもう見えてきた段階です。

あとは動きを簡単につくりことできるエフェクトなどが大物として残っていますが、一番大事なのはもうこの回でだいたい出てきたと考えてよいかと思います。

少し座標の話だったり、当たり判定のもとになるキャラクタのサイズや角度との関係だったりで分かりづらい部分も出てくるかもしれません。

このあたりはお話で理解するより、サンプルを動かせばすぐに分かる話ですので、ここではまずどういう登場人物がいるかを認識する目標で読み進んでいけばOKです。





衝突判定

ゲーム中に本格的な物理エンジンを使いたいのであればForge2Dをお勧めします。これにはflame_forge2dパッケージを使います。ただもっとシンプルにコンポーネント同士の衝突を検出したいという場合は、Flameのビルトイン衝突検出を使うとよいです。

以下のようなことをやりたければForge2Dをおすすめします:

  • リアルに相互作用する力
  • 相互作用するパーティクル(粒子)システム
  • ボディと関節
  • 50以上の大量のものを同時に扱う(「大量」の具体的な数はプラットフォームによりますが)

以下のような場合はFlameの衝突判定システムを使うと良いでしょう:

  • コンポーネントのいくつかが衝突したことを判定する
  • コンポーネントが画面の端に衝突した場合
  • 複雑な形状のヒットボックスを扱い、ジェスチャーをより正確に把握
  • ヒットボックスのどの部分が何かに衝突したのかを知りたい

衝突検出システムは3つの異なるヒットボックスの形状をサポートしています。ポリゴン、四角形、円形の3つです。ヒットボックスは衝突の検出や特定の点を含んでいるかどうかの判定を行うためのエリアを多種の形状から選択することができます。特定の点を含んでいるかどうかの判定は、正確なジェスチャーの検出に有用です。

衝突判定は二つのヒットボックスが衝突したときに「何が起こるか」については扱いません。何が起こるかについては利用者側の実装で決めていきます。


あらかじめ備わっている衝突検出システムでは、二つのヒットボックスが非常に素早く動いて突き抜けた場合、衝突を検出できないことに注意してください。これは物体の動きが速すぎたり、updateのときの差分時間が長すぎる場合に起こり得ます。この振る舞いはトンネリングと呼ばれます。

また衝突検出システムはCollidableな(衝突を検出する指定をされている)コンポーネントの親を拡縮させた場合に、適切に働かないことに注意してください。

https://docs.flame-engine.org/1.0.0/collision_detection.html

Flameにはお手軽に使える衝突検知システムがあって、コンポーネント同士が衝突したタイミングと場所をしることができます。

物理エンジンのように精緻な衝突判定を活用したければFlameと連動する物理エンジンのForge2Dをおすすめしていますね。一般的なアクションゲーム、シューティングゲームのようなものであればFlameのビルトインの衝突判定で十分でしょう。


pub.dev

最後に「突き抜け」と「拡縮」について注意点が書いてあります。突き抜けはフレームが飛んでしまってキャラクタが瞬間移動して壁や他キャラを突き抜ける現象(トンネリング)ですね。

ちなみにあんまり関係ないですけれどもトンネリングは現実世界でも起こりうるんですよね(現実的には電子レベルのミクロの世界ですけれども)。詳しくはこちらなどで:


www.youtube.com

関係ないついでにすこし続けると、量子力学・量子化学って世界を量子化する(つまりゲームで言うドットの集まりとして世界を考える)ことで、この世の中を上手く捉えられるという話なんですよね。

この世界はバーチャルリアリティである、という説があって、なんやそれと思われるかもしれませんが、量子論で世界はドットの集まりであるというような考え方で世界がうまく説明できているという現実があります(だからといって世界がドットでできている、とはすぐに結論付けられませんが、しかしこれでうまく説明できているということは?と思ってしまいますよね)。

この極限のミクロまで世の中をズームアップして考えると、壁を抜けてしまうトンネリングとか、数々のなんか「バグってる」現象が観察できてしまいます。これって究極のハックですよね。量子論に興味を持たれた方はぜひ勉強してみてください。


Mixins

衝突判定で使われるいくつかのMixinを見ていきます。

Mixinがなにかわからないという方はこちらを先にどうぞ:


flutter.gakumon.jp

後半の「ミックスイン」の章の内容を押さえておいて下さい。

HasHitboxes

HasHitboxesというmixinは主に2つのことで使われます。一つは他のヒットボックスとの衝突検出、もう一つはPositionComponent上のジェスチャーの正確な検出です。例えばSpriteComponentのまんまるの岩があったとして、岩が存在していない隅の角に対する入力は受け付けたくないとします。このときHasHitboxes mixinを使うとポリゴンなどでより正確に入力イベントを受け付けるエリアの形状を定義することができます。

HasHitboxesに新しい形状を加える場合は、次に述べるCollidableの例と同様にします。

https://docs.flame-engine.org/1.0.0/collision_detection.html

当たり判定の範囲を示すのに一番簡単なのは四角形で、幅と高さを与えておくものですね。あるいは円形が距離を測るだけなので簡単かもしれません。

しかしそれ以外にも、複雑な形状の当たり判定を構成することができます。

これについては次に具体的な例を述べていきます。



Collidable

CollidableミックスインはHasHitboxesを持っているPositionComponentに加えられ、他のCollidableとの衝突を検知するために使われます。もしHasHitboxesをコンポーネントに入れていなければ、衝突は起こりません。

もしコンポーネントのサイズいっぱいの四角形のデフォルトの当たり判定を使いたければ、単に「addHitbox(HitboxRectangle())」としておけばOKです。

コンポーネントを衝突検出可能にするには、下記のようにします:

class MyCollidable extends PositionComponent with HasHitboxes, Collidable {
  MyCollidable() {
    // This could also be done in onLoad instead of in the constructor
    final shape = HitboxPolygon([
      Vector2(0, 1),
      Vector2(1, 0),
      Vector2(0, -1),
      Vector2(-1, 0),
    ]);
    addHitbox(shape);
  }
}

ここではCollidableにダイヤモンド型のHitboxPolygonが加えられています。

HitboxShapeは好きなだけCollidableに加えられ、より複雑なヒットボックスの形状を作れます。たとえば帽子をかぶった雪だるまは3つのHitboxCircleと一つのHitboxPolygonで作ることができるでしょう。


衝突に対してリアクションするには、collisionCallbackをオーバーライドします:

class MyCollidable extends PositionComponent with HasHitboxes, Collidable {
  ...

  @override
  void onCollision(Set<Vector2> points, Collidable other) {
    if (other is CollidableScreen) {
      ...
    } else if (other is YourOtherCollidable) {
      ...
    }
  }

  @override
  void onCollisionEnd(Collidable other) {
    if (other is CollidableScreen) {
      ...
    } else if (other is YourOtherCollidable) {
      ...
    }
  }
}

この例では、他のCollidableがあなたのコンポーネントと衝突した場合を検出するとき、Dartの「is」キーワードをどうやって使うかが示されています。点の集合pointsはヒットボックスが衝突した場所を表します。onCollisionは衝突したコンポーネントの双方で呼び出されることに注意してください(双方がonCollisionをオーバーライドしている場合において)。同じことはonCollisionEndでも起こります。onCollisionEndは、直前まで衝突していたコンポーネントが今は離れた、というときに呼び出されます。

もし画面の端との接触を検知したい場合は、上の例でしているようにScreenCollidableを使います。これはCollidableなので、onCollisionでそのクラスを検知できます。

https://docs.flame-engine.org/1.0.0/collision_detection.html

重要なポイントはまず:

class MyCollidable extends PositionComponent with HasHitboxes, Collidable {
  ...
}

として、HasHitboxesとCollidableのミックスインをwithしておくこと。

コンポーネントのコンストラクタ、あるいはonLoadのときに、当たり判定の形状を与えて置くこと:

  MyCollidable() {
    // This could also be done in onLoad instead of in the constructor
    final shape = HitboxPolygon([
      Vector2(0, 1),
      Vector2(1, 0),
      Vector2(0, -1),
      Vector2(-1, 0),
    ]);
    addHitbox(shape);
  }

そしてonCollisionとonCollisionEndを実装して、衝突時の処理を加えること:

  @override
  void onCollision(Set<Vector2> points, Collidable other) {
  }

  @override
  void onCollisionEnd(Collidable other) {
  }

以上ですね。必要最小限でスッキリした作りですよね。やることは単純です。衝突してきたキャラクタが何なのかはotherにくるコンポーネントにidを持たせるなどをして判定していきます。ざっくり敵なのか、味方なのか、障害物なのか、などの分類はサンプルにあるように「is」によるクラスの区別でできますが、細かい話は自前で実装していく必要があります。


CollidableType

Collidableにおいて、デフォルトでCollidableTypeはactiveです。もし衝突検出でよりあなたの状況に最適化するのであればほかに二つの値があります。

列挙型CollidableTypeには以下の値があります:

  • activeは他のCollidableがactiveあるいはpassiveの場合衝突する
  • passiveは他のCollidableがactiveの場合衝突する
  • inactiveはどんなCollidableにも衝突しない

衝突のチェックをする必要のないCollidableについては、collidableType=CollidableType.passiveとすることでその判定を避けることができます。例えば地上物や当たり判定の必要がない敵キャラをpassiveに設定することが考えられます。

inactiveにするとあらゆる衝突判定の対象ではなくなります。たとえば画面外に出ているキャラクタが後で戻ってくる可能性があってゲームから排除されていないときなどに使えるでしょう。

いくつか使用例がありますが、もっとできることはたくさんあります。もしサンプルのなかにあなたの使いたい方法が見つからなくてもこれを活用することを検討してみてください。

https://docs.flame-engine.org/1.0.0/collision_detection.html

唐突にCollidableTypeの話が始まりますが、そもそもCollidableTypeとは衝突判定の相性表のようなもので、下記の表のようになります:

他active 他passive 他inactive
自active ×
自passive × ×
自inactive × × ×

○と×は、自分とされているコンポーネントにonCollisionが発生する場合○、しない場合×という意味です。

例えば次のシチュエーションを考えます:

  • 主人公キャラは敵キャラにぶつかる
  • 敵キャラ同士はぶつからない

このとき、主人公キャラのCollidableTypeをactive、敵キャラのCollidableTypeをpassiveにしておきます。そうすると敵キャラ同士はpassive同士なので、余計なonCollisionは発生させずにすみますよね。


HasCollidables

衝突判定をゲームの中で使いたければミックスインのHasCollidablesをあなたのゲームに加えます。こうすることでどのComponentが衝突したかを追跡します。

class MyGame extends FlameGame with HasCollidables {
  // ...
}

ここでCollidableなコンポーネントがゲームに加えられたら、後は自動的にその衝突がチェックされるというわけです。

https://docs.flame-engine.org/1.0.0/collision_detection.html

前節のコンポーネントごとのHasCollidableの設定に加えて、大本のFlameGameのところにHasCollidablesというミックスインをwithしておく必要があるということですね。


ScreenCollidable

ScreenCollidableはmixinではなく、予め用意されている衝突検出可能なコンポーネントで、ゲーム画面の端の衝突を判定するものです。

ScreenCollidableをゲームに加えると、画面端との衝突判定を行えます。これはなにか引数パラメタが必要というわけではなく、ゲームのサイズだけに依存します。

これを加えるには「add(ScreenCollidable())」を実行すればOKです。

https://docs.flame-engine.org/1.0.0/collision_detection.html

画面端を判定するにはゲームにScreenCollidable()を加えれば良いということですね。

こうすることで各コンポーネントが画面端にぶつかった場合にonCollidableが呼び出されますので、それぞれ画面端にあたった場合の処理を追加します。


形状

Shape

Shapeは伸縮可能な図形を表すクラスです。Shapeは様々な形でその形状を定義できます。Shapeはサイズや角度などのプロパティをもっていて、その値に従って伸縮や回転されます。

現在は3種類のShape、ポリゴンと四角形と円形があります。

https://docs.flame-engine.org/1.0.0/collision_detection.html

原文にストーリーが何も書かれていないので、読み手が読み解く必要がありますが、ここでは当たり判定の形状を定義する話がスタートします。

その大本となる形状を定義するクラスは、前々回のコンポーネントのところで登場したShapeというわけです。


HitboxShape

HitboxShapeは、アタッチされているコンポーネントの中心位置から定義されたShapeであり、コンポーネントと同じ境界サイズと角度を持っています。

localPositionを設定して、シェイプの位置がコンポーネントの中心から外れるようにすることもできます。

HitboxShapeは、HasHitboxまたはCollidableに追加するShapeのタイプです。

https://docs.flame-engine.org/1.0.0/collision_detection.html

前節で触れられているShapeが形状を定義するもの、そしてそこに当たり判定を実現するヒットボックスの機能をつけたのがHitboxShapeということですね。

そしてこのHitboxShapeが、次節に続くHitboxPolygon、HitboxRectangle、hitboxCircleの親クラスです。3つの共通点を担うのがHitboxShapeということです。


HitboxPolygon

衝突判定を行うとき、あるいはPolygon上でcontainsPointを使うとき、そのポリゴンは凸であることに注意してください。常にポリゴンを使う場合は凸のポリゴンを使用してください。そうしないと、非常に扱いの難しい問題に突き当たることになります。またポリゴンを定義するときは常に時計回りに定義するということに注意してください。

通常のポリゴンと比較すると、HitboxPolygonを作成する方法は1つしかありません。必須の引数は、ポリゴンの外観を定義するVector2のリストですが、ポリゴンのサイズや位置は定義されていません 。なぜならそれらはアタッチするコンポーネントによって決定されるからです。 たとえば、Collidableのところの例にあったように次のようなコードでダイヤモンド形を作成できます。

HitboxPolygon([
  Vector2(0, 1),  // Middle of top wall
  Vector2(1, 0),  // Middle of right wall
  Vector2(0, -1), // Middle of bottom wall
  Vector2(-1, 0), // Middle of left wall
]);

他のヒットボックスシェイプには必須のコンストラクターはありませんが、これは、アタッチされている衝突可能オブジェクトのサイズから計算された適切なデフォルトを持つことができるためです。しかしバウンディングボックスのなかでポリゴンを生成する方法は無数にあるので、この形状のためにはコンストラクタを定義する必要があります。

この例のベクトルは、x軸とy軸の両方で画面の中心から端までの長さのパーセンテージを定義しているため、リストの最初の項目( `Vector2(0、1)`)では、バウンディングボックスの上辺の中心を指しています。

f:id:linkedsort:20211210154206p:plain

これは紫の矢印で表すポリゴンの形状が赤い矢印から定義されている様子を表しています。

https://docs.flame-engine.org/1.0.0/collision_detection.html

ポリゴン形状が凸であるとは以下のような形状です:


ja.wikipedia.org

逆に凸ではない形状とは以下です:

ja.wikipedia.org

なので、例えば☆型の形状は凸ではありません。

形状が凸であることを前提とすると、非常に効率の良い当たり判定アルゴリズムが存在することが知られていて、基本的にこれを用いてゲームは作られるものですので(高速な処理が求められるので)、ゲームで登場するポリゴンは凸形状の集まりであることが基本になっています。星型など凸ではない形状を描画する場合は、いくつかに形状を分解して部分的には全て凸の図形にすることになります。

また「時計回り」という順番を意識する必要があるのは、ポリゴンに表裏の概念が存在するためです。下記などを参照してください:


area.autodesk.jp

「他のヒットボックスシェイプ…」の文章が分かりづらいですが、要するにHitboxPolygon以外のHitboxRectangleやHitboxCircleは単純なので細かい設定が必要ないが、HitboxPolygonは特別複雑なので形状をちゃんと与えてあげる必要があるということを意味しています。あまり気にしなくて大丈夫です。

ポリゴンの形状の定義を与えるときは、上の図にあるように上辺真ん中が(0,1)という座標系であることに注意しましょう。(通常の数学で使うおなじみの2次元座標系ですね)


HitboxRectangle

HitboxRectangleはシンプルにしたポリゴンで、より簡単に定義することができます。四角形を作るにはコンストラクタにrelationを加えます。relationは水平方向と垂直方向の長さの関係を表すものです。半分の幅とフルの高さを持つものを定義するにはこのようにします:

HitboxRectangle(relation: Vector2(0.5, 1.0));

HitboxRectangleを加えるとコンポーネントはこの四角形のサイズの当たり判定をもつことになります。もしコンポーネントがVector2(400, 200)のサイズである場合で、上と同じ引数でHitboxRectangleを生成すると、コンポーネントの真ん中に200、200のサイズで位置づけられることになります。
https://docs.flame-engine.org/1.0.0/collision_detection.html

最初の文で行っているのは、長方形のヒットボックス形状を与えるのですが、このときこの長方形の形状を表すには縦と横の比率だけを与えれば良いといっています。そしてサイズとか角度とかは、このHitboxRectangleが加えられるコンポーネントのサイズに左右されるのだということです。

二番目の文でその例を述べています。幅が0.5(半分)、高さが1(等倍)のHitboxRectangleを、幅400、高さ200のコンポーネントに貼り付けると、当たり判定のサイズは幅200(半分ですからね)、高さ200(こちらは等倍です)の長方形を、そのコンポーネントの中心に位置させたものになる、ということです。


HitboxCircle

Circleを作るときはバウンディングボックスの一番短い辺との比較でどれだけの長さの半径かをdefinitionパラメタで指定します。

あるコンポーネントがVector2(100, 400)のサイズであって、その頭のところに円を位置づけたいとします。その頭のところは幅の半分の大きさで、その中心を上4分の1にする場合は、このように定義します:
HitboxCircle(definition: 0.5)..relativePosition = Vector2(0, 0.5)

https://docs.flame-engine.org/1.0.0/collision_detection.html

HitboxCircleは形状が円なので、半径さえ決まれば形はもう定義できますよね。

その半径の与え方は、アタッチするコンポーネントの大きさの中で、短い辺の方と比較した割合だと言っています。

当たり判定というのはキャラクタの大きさより通常小さいので、短い方の辺と比較してピッタリ同じなら等倍の1、半分なら0.5というように設定するわけです。これは結構理屈にあっていますよね。

例示されているのはその半径が幅100のさらに半分で、それを中心から上に0.5の座標のところ(ポリゴンのところの座標系参照)にずらしておいている、というものになります。デカキャラの頭部にだけ当たり判定、というようなシチュエーションに便利ですね。


Normal Shapes

これらの(次に述べる)Shapeは衝突検出システムと共に使うというよりは、一般の幾何学形状を扱うために用意されているものです。

ポリゴン
点のリストをコンストラクタに与えてPolygonを生成することができます。この点のリストはあるサイズをもつポリゴンを生成することになりますが、これはスケールを変えたり回転させたりできます。

長方形
Dartはもともと素晴らしい四角形の記述クラスとしてRectを持っています。FlameでRectangleクラスを作るときに、Rectの定義があるならば名前付きコンストラクタのRectangle.fromRectを通じて作ることができます。Polygonと同様、Rectに応じて生成されたRectangleの大きさも決まります。位置、サイズ、角度を指定してデフォルトコンストラクタによってRectangleを作ることもできます。


円の位置の長さや半径が最初からどれくらいになるかがわかっている場合は、オプションの引数「radius」と「position」を使用して、これらを設定できます。「radius」が設定されればCircleのサイズは自動的に決まります。

https://docs.flame-engine.org/1.0.0/collision_detection.html

ここで言っているのは衝突検知やFlameの世界のShape以外にも一般にDartの世界でもポリゴンや長方形があるが、これらを利用してもFlameの世界の形状を生成できるということです。

Flutterの世界とFlameの世界を一緒に扱う必要がある、すでにFlutterでコードの資産がある、という場合に便利な橋渡しというわけですね。








おわりに

当たり判定の処理の仕方と、形状の定義の仕方ということで、文章自体は少し長めでしたが話は単純だったかと思います。

シンプルにやるべきことがこの大きく2つです。

それも最初はキャラクタを正方形の1定サイズのもの、当たり判定も中心からの円のみ、のように制限すれば非常にシンプルな世界です。

最初からポリゴンを使った複雑な形状を考える必要はありません。というか最初から最後まで、ほとんどないといっていいかもしれませんけれども。

なので、少し難しいと感じられた場合は、円形の当たり判定、正方形のコンポーネントというのを念頭にもう一度関連するところだけをピックアップしてみてください。そうすると、onCollisionの処理を定義すること以外、ほとんどやることが無いことがわかると思います。

f:id:linkedsort:20211218143528j:plain