ブラウザゲーム開発に使えるJavaScript製物理エンジンp2.jsを使ってみる

こんにちは。フロントエンドエンジニアの鈴木です。

最近は、「楽天ゲームズ」や「Yahoo!ゲーム ゲームプラス」といったブラウザでできるゲームプラットフォームが多く出てきている印象があります。これはWebGLなどのテクノロジーに対応するブラウザが増えてきたことと、デバイスの性能が上がってきて実行環境が整ってきていることなどが、要因のひとつかと思います。

ブラウザでも、offline対応やpush通知などができるServiceWorkerといったテクノロジーなどを利用していけば、ブラウザベースとネイティブとの差がどんどん縮まって行くと思いますので、今後のブラウザベースゲームの動向が気になるところです。

 

今回はそんなブラウザベースでゲーム開発をする上で使用することができる、p2.jsというJavaScript製 2D physicsエンジンを使ってみたいと思います。

https://jsfiddle.net/1e0ma052/5/

 

p2.jsとは

Javascript製の2D Physicsエンジンです。

Umeå UniversityのVisual Interactive Simulationコースの文献を元に開発されているそうです。ただ、残念ながら該当する文献のリンクが切れてしまっており、現在は見ることができない模様です。

ライセンスはMITです。

最近Facebookのライセンスの問題が話題に上がっていますので、ライセンスは改めてしっかりと確認しておきたいですね。

 

Phaserというブラウザ向け2Dゲームエンジンでもp2.jsをサポートしており、簡単にPhysicsをp2.jsに切り替えられるようになっています。

 

導入

オフィシャルのドキュメントにも記載のある、ダウンロードしたjsをscriptタグで読み込む方法が一番シンプルかと思います。

<script src="p2.js" type="text/javascript"></script>

CDNでの提供もあるのでそちらを利用する方法もあります。

https://cdnjs.com/libraries/p2.js

 

webpackなどで取り込む場合は

npm install p2

で読み込んだあとに使用するjsファイルで

 

var p2 = require(“p2”)

もしくは

 

import p2 from “p2”

で使用することができます。

 

オフィシャルサイトはこちら

https://schteppe.github.io/p2.js/

 

基本

p2.jsの物理空間では、単位はメートルを使用しています。ピクセルではないので注意が必要です。

角度はラジアンで指定します。

y軸は上向きがプラスになるため、通常のモニターの座標軸とは逆になっている点も注意が必要です。

重さの設定はキログラムになります。

 

p2.jsを実装するにあたって、まず重要になってくる概念がWorld、Body、Shapeの3つです。

まずはじめに、Worldと呼ばれるp2.jsの物理演算空間を作成します。

Bodyは入れ物的なもので形状の情報等はなく、Shapeが形状を保持しています。BodyにShapeを紐づけることで、Bodyがどういう形になるかが決まります。

 

この辺りの内容は、Box2Dを使用したことがある方であれば馴染みのある構成かと思います。

 

p2.jsはあくまで物理演算をしてくれるだけで、画面に描画する機能はありませんので、別途演算結果をcanvasなりに描画してあげる必要があります。

 

大まかな処理の流れは

1:p2.jsのWorldの作成

2:形、大きさ、重さなどを設定したBodyを作成する

3:作成したBodyをWorldに追加する

4:物理演算

5:演算結果をcanvasなどに描画する

 

1~4がp2.jsの領域で、5は別途別のライブラリーを用意するか自前で実装する領域になります。

 

Hello World

とりあえず最小構成のコードです。これだけですと画面には何も表示されませんが。

 

// 物理計算空間の作成。重力は下向き
const world = new p2.World({
  gravity : [0,-9.82]
});
// bodyを生成
const body = new p2.Body({
  position: [0, 50],
  mass: 5
});
// 円形のshapeを生成
const shape = new p2.Circle({ 
  radius: 1 
});
// bodyにshapeを追加
body.addShape(shape);
// worldにbodyを追加
world.addBody(body);
// requestAnimationフレームなどのループ処理でworldのstepを実行する
const fixedTimeStep = 1 / 60;
animate(timestamp) {
  requestAnimationFrame((t)=>this.animate(t));
  this.world.step(fixedTimeStep);
}
// ループ処理開始
animate(0);

 

World

セクションタイトルがHello Worldの後ということもあって紛らわしいですが、p2.jsにあるWorldクラスのことです。これはp2.js上の物理演算空間になります。

物理演算の対象となる要素は、すべてこのWorldに登録する形になります。

 

gravityはこの物理演算空間の初期重力値を設定します。

ここでも上向きプラスなので、下向きの重力にしたい場合はマイナスになります。

 

物理計算をするために、requestAnimationFrameなどのloop処理の中で

world.step(timestep)

を実行する必要があります。

これを実行することで、Worldに登録してある要素の座標などの計算がされます。

 

 

const world = new p2.World({
  gravity : [0,-9.82]
});
const fixedTimeStep = 1 / 60;
animate(timestamp) {
  requestAnimationFrame((t)=>this.animate(t));
  this.world.step(fixedTimeStep);
}
animate(0);

 

Body

形状オブジェクト(Shape)の入れ物的存在でWorldに直接追加されるものになり、位置、角度、速度などの情報を持っています。

このBodyに力を与えたり、他のBodyなどから影響を受けることで、位置や角度が計算されます。

positionの設定は配列で行い、1つ目がx座標で2つ目がy座標になります。この設定方法は、jsで行列演算などするライブラリーで有名なglMatrixと同じ方式です。それもそのはずで、vec2クラスなどはglMatrixから機能を省いたものを使用しているそうです。p2.jsにはベクトルを扱うクラスがvec2しかありませんが、行列演算系の処理をしたいときなど、glMatrixとは相性が良さそうです。

 

Bodyには以下3つのtypeがあり、それぞれ特性があります。

 

Body.DYNAMIC

すべてのBodyと作用し合うBodyです。

衝突し合えばお互いに作用し位置や角度が計算されます。

massの設定を0以上にすることでDynamic Bodyにすることができます。

massの設定は初期値0です。

 

const body = new p2.Body({
  mass: 1,
  position: [0, 0]
})

 

Body.KINEMATIC

これが少し分かりにくいのですが、他のBodyに影響されて動くことはありませんが、力を加えることで動かすことができるBodyです。アクションゲームの動く床を想像するとわかりやすいかと思います。

Kinematicにするにはtype = Body.KINEMATICと明示的に設定が必要になります。

 

const body = new p2.Body({
  position: [0, 0],
  type: Body.KINEMATIC
})

 

Body.STATIC

動かないBodyですが存在はするので、衝突の対象等にはなります。

床や壁といった類のものが、主な使用用途になるかと思います。

STATIC Bodyにするにはmassプロパティを0にすることでできます。

massプロパティの初期値は0になりますので、Bodyの初期状態はSTATIC Bodyということになります。

 

const body = new p2.Body({
  mass: 0,
  position: [0, 0]
})

 

Bodyの移動

Bodyの位置を移動させるには、BodyにあるapplyForceというメソッドで力を加えてあげます。

 

// 右向きに4の力
let force = [4, 0];
body.applyForce(force)

 

applyImpulseというメソッドもあるのですが、これは短い時間力を加えつづけるものです。大砲の玉のように、瞬発的に飛んで行くようなものなどが使い所かと思います。

 

回転の固定

DYNAMIC Bodyは力を加えたり、他のBodyからの影響で位置や回転が決まりますが、この時位置は変更したいが、回転はさせたくないケースなどがあると思います。

その場合、回転を固定するパラメータがBodyクラスには備わっています。

fixedRotationというパラメータでこの値をtrueにしてあげることで回転が固定されます。

 

this.body = new p2.Body({
  mass: 1,
  position: [0, 0],
  fixedRotation: true
});

 

Shape

Shapeは形状を管理する雛形のクラスになります。

Shapeクラスを継承した以下のクラスがありますので、それらを使用します。

名前からどういった形状かはなんとなく想像がつくと思います。

・Circle

・Plane

・Box

・Convex

・Particle

・Line

・Capsule

・HeightField

・Ray

Bodyには複数のShapeを紐づけることができますので、組み合わせによって様々な形を作ることができます。

 

const shape = new p2.Circle({
  radius: 1
})

 

pixi.jsとの連携

WebGL系のライブラリーですと、three.jsが有名だと思うのですが、2Dで使用するには少し機能過多な部分があります。そこで、今回は2D系のWebGLライブラリであるpixi.jsとの連携方法について紹介します。

余談ですが、pixi.jsはFlash(現Adobe Animate CC)の影響を受けて設計されているため、元々Flashエンジニアとしてやっていた自分にはとても馴染みやすい作りになっている点が気に入っています。

少し前にFlashPlayerのサポートが2020年に終了してしまうニュースがあったのは、なんとも寂しい限りです。これも時代の流れですね。

 

話が少し脱線してしまいました。元に戻しますと。

pixi.jsでは単位がピクセルで計算されていますので、p2.jsの値をそのままでは使用することができません。

そのため、p2.jsの値を描画用の値に合うよう変換する必要が出てくるのですが、p2.jsの値に100をかけることで、pixi.js側の単位に合わせることができます。

また、忘れてはいけないのが、基本部分でも記載したp2.jsではy軸が上向きプラスで回転方向が左回りにプラスになっているところです。そのため、上下反転してあげる必要があります。

角度に関しては、p2.jsもpixi.jsもどちらもラジアンですので符号のみ逆にします。

これらの値を反映するたびに計算式を記述するのはちょっとした手間になるので、変換するユーティリティー系のメソッドを作成しておくと便利です。

ex)

 

  class Util {
    constructor() {
    }
    // p2→pixi
    static p2ToPixiX(p2X) {
        return p2X * 100;
    }
    static p2ToPixiY(p2Y) {
        return -(p2Y * 100);
    }
    static p2ToPixiValue(p2Value) {
        return p2Value * 100;
    }
    // pixi→p2
    static pixiToP2X(pixiX) {
        return pixiX / 100;
    }
    static pixiToP2Y(pixiY) {
        return -(pixiY / 100);
    }
    static pixiToP2Value(pixiValue) {
        return pixiValue / 100;
    }
  }

 

デモ

https://jsfiddle.net/sad5ztmw/2/

 

 

衝突判定

衝突判定のイベント

衝突検知のイベントはWorldクラスにあります。

“beginContact”で衝突し始めのタイミングを取得することができますので、このイベントを監視して、どのオブジェクトとどのオブジェクトが衝突したかをフックにし、何らかの処理を実行することが可能です。

 

world.on('beginContact', (evt)=>{
  // 衝突
})

衝突判定の取り方

次に、どのオブジェクトとどのオブジェクトが衝突したかを判断する方法ですが、イベントのコールバック関数の引数から、衝突したBodyとShapeを取得することができます。

これらのBodyとShapeを元に、どのオブジェクト同士が衝突したかを判別し、処理を振り分けることができます。オブジェクトAとオブジェクトBが衝突したなら、爆発エフェクトを表示する、といった感じです。

 

world.on('beginContact', (evt)=>{
  // 衝突
  const bodyA = evt.bodyA;
  const bodyB = evt.bodyB;
  const shapeA = evt.shapeA;
  const shapeB = evt.shapeB;
  if (shapeA == ‘判別したい対象’ || shapeB == ‘判別したい対象’) {
    // 衝突アクション実行
  }
})

 

センサーとして使用するには

デフォルトでは衝突すると設定した形状が重ならず跳ね返ってしまいます。

ビリヤードのような、重なることが無いようなゲームなら良いのですが、ある特定の領域に入ったら、何かしたい場合などにはこのままですと少し不都合です。

そういった時は、Shapeをセンサーとして動作させ、跳ね返りなどをなくすことで対応することができます。

Shapeクラスにsensorというパラメータがありますので、これをtrueにしてあげることで、重なるようになります。

 

const shape = new p2.Circle({
  radius: 1,
  sensor: true
});

Collision Filtering

オブジェクトAとオブジェクトBは衝突判定させたいけど、オブジェクトAとオブジェクトCは衝突判定させたくない場合などはShapeクラスのcollisionGroupとcollisionMaskを使用することで実現することができます。

まずはShapeがどのcollisionGroupに所属しているのかを設定します。

p2.jsはbit maskで判定を行う仕様になっていますので、collisionGroupにはMath.pow(2,0)といった形で2のn乗の数値を設定します。

 

const PLAYER = Math.pow(2.0);
const ENEMY = Math.pow(2.1);
const FRIEND = Math.pow(2.2);

といった形で定数としてしておくと扱いやすいと思います。

オフィシャルドキュメントのサンプルでも、そのようになっています。

 

次にそのShapeがどのcollisionGroupと衝突する関係にあるかを設定します。

もしplayerがenemyとfriendと衝突ターゲットの関係にある場合は、以下のように設定します。

 

shapeA.collisionGroup = PLAYER;
shapeA.collisionMask = ENEMY | FRIEND;

これで特定のオブジェクト同士の衝突判定をするしないを設定することが可能です。

 

 

// playerとenemy、friendとenemyは衝突関係にあるがplayerとfriendは衝突関係ではない場合
const PLAYER = Math.pow(2,0);
const ENEMY = Math.pow(2,1);
const FRIEND = Math.pow(2,2);
const shapeA = new p2.Circle({
  radius: 1,
  collisionGroup: PLAYER,
  collisionMask: ENEMY
});
const shapeB = new p2.Circle({
  radius: 1,
  collisionGroup: ENEMY,
  collisionMask: PLAYER | FRIEND
});
const shapeC = new p2.Circle({
  radius: 1,
  collisionGroup: FRIEND,
  collisionMask: ENEMY
});

 

デモ(同じ色の場合は通過する)

https://jsfiddle.net/47LLf3n2/5/

 

瞬時に座標を移動する方法

あるオブジェクトが画面の左から見切れた時に、逆側となる右から表示させたい場合などがあると思います。

そういった場合、Bodyのpositionの値を変えただけでは意図したように移動しません。

これはp2.jsが現在のpositionと一つ前のpositionを基に計算されているためです。

そのため、これら両方の値を同時に変更することで、瞬時に特定の位置に移動させることができます。

BodyにはpositionとpreviousPositionという2つのパラメータがありますので、この2つの値を同時に変更します。

 

const body = new p2.Body({
  mass: 0,
  position: [0, 0]
});
// 瞬時に移動する
let targetX = 10;
let targetY = 20;
body.position[0] = targetX;
body.position[1] = targetY;
body.previousPosition[0] = targetX;
body.previousPosition[1] = targetY;

 

Materialを使用した摩擦の設定

各オブジェクト間の摩擦を設定するには、Materialを使用することで可能です。

まず始めにMaterialクラスで素材の定義をします。定義したMaterialをshape.materialに設定します。

 

const materialA = new p2.Material()
const materialB = new p2.Material()
shapeA.material = materialA;
shapeB.material = materialB;

次にContactMaterialというクラスを使用し、MaterialAとMaterialB間の摩擦力を設定します。

ContactMaterialはWorldに追加する必要があります。

 

const abContactMaterial = new p2.ContactMaterial(materialA, materialB, {
  friction: 0.2
});
world.addContactMaterial(abContactMaterial);

これでshapeAとshapeB間の摩擦が設定されました。

この時shapeC.material = materialBと設定することで、shapeAとshapeC も同じ摩擦力が設定されることになります。

ContactMaterialが設定されていない場合はworld.defaultContactMaterialのデフォルト値が設定されます。

 

まとめ

matter.jsなどp2.js以外のPhysicsエンジンはまだまだありますので、今後別のエンジンのお話、またはp2.jsの今回お話することができなかった部分や、エンジン別のパフォーマンス検証などもお届けできたらと思います。

 

ブラウザでもネイティブに劣らない表現ができるようになってきていることは、なんだかワクワクしますね!

しかもオープンなWebというプラットフォームというのも魅力です。

 

とはいっても、テクノロジーによっては環境を限定するものもあるため、まだまだこれからだったりする部分はありますが、Webでできることが増えることで、新しいアイデアのコンテンツが生まれてきたらいいなと思います。

 

おまけDEMO(ちょっと重いです)

https://jsfiddle.net/gny3h46v/28/

おまけのおまけDEMO(Webcam連動)

https://jsfiddle.net/h172e1pb/29/