ニコ生ゲームを作ろうと思ったら物理的に考える その3
今回は画像を物理演算の円や多角形に貼り付けていきます。
以下がサンプルです。音は抜いています。
■目次
円を作る
これまで四角だけを使いましたが今回は円を使います。
物体の形の指定は box2d.createFixtureDef の shape にあります。
四角では box2d.createRectShape() でしたが、円は box2d.createCircleShape() です。
let fixturedef = box2d.createFixtureDef({
density: 1.0, // 密度
friction: 0.25, // 摩擦係数
restitution: 0.3, // 反発係数
shape: box2d.createCircleShape(d) // 形状
});
カッコの中には直径をいれます。半径ではありません。
多角形を作る
次は三角形を使いたいのですが、これは多角形の一種になります。
box2d.createPolygonShape() を使いますが、カッコの中には頂点の座標をいれます。
let fixturedef = box2d.createFixtureDef({
density: 1.0, // 密度
friction: 0.5, // 摩擦係数
restitution: 0, // 反発係数
shape: box2d.createPolygonShape([
box2d.vec2(-10 , -10),
box2d.vec2(10, 10),
box2d.vec2(-10, 10),
])
});
上の例は下の画像のような下り坂を描画していて、上の頂点から書いています。
多角形の書き方にはいくつかルールがあります。
- 時計回りで指定する
逆回りにすると判定がおかしくなってすり抜けたりします。 - 物体の中心が (0, 0) になるようにする
これも公式で必ずと書かれています。
この中心とは重心ではなく上下左右の端の中心のことです。
上の例だと 上:-10 下:10 左:-10 右:10 で中心が (0, 0) になります。
中心をずらしても動くように思いますが、エンティティと物体を合わせる際に問題になります。
createBodyで貼り付けたエンティティはanchorXなどでずらすことはできません。
後述する画像素材を用意するときも端の中心で揃えるほうが作りやすいと思います。(0, 0)にしておいたほうが無難でしょう。 - 凹形状は不可
☆型などはできないようです。
しかしニコニコタワーのコの字型のブロックは凹型になっています。
今回は使いませんが、物体を結合するJointというものを使えばできそうです。
画像を物体に貼る
今回は円や多角形を描画したいのですが、g.FilledRectのようなものはありません。
画像をエンティティとして採用します。
物体のサイズと画像のサイズを合わせましょう。
直径25ピクセルの円なら、円の描画サイズを25にします。
画像自体を25×25ピクセルにして目一杯円を書いてもいいですが画像の端が切れたりするので、少し大きい画像にして余白を設けても大丈夫です。
box2D.createBodyにエンティティとして指定する方法はこれまでと同じです。
589行目にゴルフボールを生成する以下の関数があります。
function makeball(x, y, d, angle, asset) {
let entity = new g.Sprite({
scene: scene, src: scene.assets[asset], parent: movelayer,
x: x, y: y, anchorX: 0.5, anchorY: 0.5, opacity: 1, touchable: false,
});
let bodydef = box2d.createBodyDef({
type: b2.BodyType.Dynamic, // 静止:Static 運動:Kinematic 動的:Dynamic
linearDamping: 0, // 速度の減衰率 0~3ぐらい
angularDamping: 2.5, // 回転速度の減衰率 0~3ぐらい
userData: "ball",//識別用
});
let fixturedef = box2d.createFixtureDef({
density: 1.0, // 密度
friction: 0.25, // 摩擦係数
restitution: 0.3, // 反発係数
shape: box2d.createCircleShape(d) // 形状
});
let body = box2d.createBody(entity, bodydef, fixturedef);
body.b2Body.SetPosition(box2d.vec2(x, y));
body.b2Body.SetAngle(box2d.radian(angle));
posx = x;
posy = y;
return body;
}
エンティティのx , y , anchorX , anchorY, angle あたりはこれまで同様無効になります。
しかし、scaleX , scaleY は機能します。
画像は高解像度で用意して、entityとして貼り付けるときに縮小する事はできます。
556行目の下の例は、坂の落差の種類 delta によって左右反転、高さの倍率、頂点の位置を変更しています。
function makeslope(x, y, width, height, angle, asset, delta) {
let entity = new g.Sprite({
scene: scene, src: scene.assets[asset], parent: movelayer,
angle: angle, scaleX: delta > 0 ? 1 : -1, scaleY: Math.abs(delta/4),
x: x, y: y, anchorX: 0.5, anchorY: 0.5,
});
let bodydef = box2d.createBodyDef({
type: b2.BodyType.Static, // 静止:Static 運動:Kinematic 動的:Dynamic
linearDamping: 0, // 速度の減衰率 0~3ぐらい
angularDamping: 0, // 回転速度の減衰率 0~3ぐらい
userData: "floor",//識別用
});
let fixturedef = box2d.createFixtureDef({
density: 1.0, // 密度
friction: 0.5, // 摩擦係数
restitution: 0, // 反発係数
shape: box2d.createPolygonShape([
box2d.vec2(delta > 0 ? -width/2 : width/2, -height/2 * Math.abs(delta)),
box2d.vec2(width/2, height/2 * Math.abs(delta)),
box2d.vec2(-width/2, height/2 * Math.abs(delta)),
])
});
let body = box2d.createBody(entity, bodydef, fixturedef);
body.b2Body.SetPosition(box2d.vec2(x, y));
body.b2Body.SetAngle(box2d.radian(angle));
new g.Sprite({
scene: scene, src: scene.assets["slopeg"], parent: movelayer,
angle: angle, scaleX: delta > 0 ? 1 : -1, scaleY: Math.abs(delta/4),
x: x, y: y+grassh, anchorX: 0.5, anchorY: 0.5,
});
return body;
}
正多角形を作る
正三角形を作る機会は割とあるかもしれません。
shape: box2d.createPolygonShape([
box2d.vec2(0, - 20 * Math.sqrt(3) / 2),
box2d.vec2(20, 20 * Math.sqrt(3) / 2),
box2d.vec2(-20, 20 * Math.sqrt(3) / 2),
])
こういう感じですね
正六角形は下のように三角関数を使って書くことができます。
shape: box2d.createPolygonShape([
box2d.vec2(20*Math.cos(Math.PI*2 * 0/6), 20*Math.sin(Math.PI*2 * 0/6)),
box2d.vec2(20*Math.cos(Math.PI*2 * 1/6), 20*Math.sin(Math.PI*2 * 1/6)),
box2d.vec2(20*Math.cos(Math.PI*2 * 2/6), 20*Math.sin(Math.PI*2 * 2/6)),
box2d.vec2(20*Math.cos(Math.PI*2 * 3/6), 20*Math.sin(Math.PI*2 * 3/6)),
box2d.vec2(20*Math.cos(Math.PI*2 * 4/6), 20*Math.sin(Math.PI*2 * 4/6)),
box2d.vec2(20*Math.cos(Math.PI*2 * 5/6), 20*Math.sin(Math.PI*2 * 5/6)),
])
for文を使うともっとシンプルにかけそうです。
正五角形は、5分割に変更しつつ頂点を上にすると以下のように行けそうですが、
shape: box2d.createPolygonShape([
box2d.vec2(20*Math.cos(Math.PI*2*(0/5-1/4)), 20*Math.sin(Math.PI*2*(0/5-1/4))),
box2d.vec2(20*Math.cos(Math.PI*2*(1/5-1/4)), 20*Math.sin(Math.PI*2*(1/5-1/4))),
box2d.vec2(20*Math.cos(Math.PI*2*(2/5-1/4)), 20*Math.sin(Math.PI*2*(2/5-1/4))),
box2d.vec2(20*Math.cos(Math.PI*2*(3/5-1/4)), 20*Math.sin(Math.PI*2*(3/5-1/4))),
box2d.vec2(20*Math.cos(Math.PI*2*(4/5-1/4)), 20*Math.sin(Math.PI*2*(4/5-1/4))),
])
正五角形は中心がちょっとずれます。
20*( 1 - ( Math.sin(Math.PI*2*(2/5-1/4)) - Math.sin(Math.PI*2*(0/5-1/4)) ) /2 ) を各y座標に加えれば補正できるはずです。
もしくは、画像の方の中心を三角関数で書くときの中心にになるようにずらします。
公式に書かれている「多角形の中心を(0, 0)にする」ルールには反しますが、意外と動く気がします。ただし、想定外の不具合がでてくる可能性はあります。
どちらもめんどくさかったら奇数の正多角形をあきらめましょう。
静止を待つ
今回はゴルフゲームなので、一打ごとに停止するのを待って次の操作にうつります。
止まる前に撃てるのもありっちゃありですが、今回は止めることにしました。
物理ゲームではよくある方式で、カーリングもある意味そうですね。
止める方法
まず、ボールがちゃんと止まる世界にしておく必要があります。
止める方法は3つあります。
- 摩擦 friction
円以外ならこれでだいたい止まりますが、今回のボールの場合は転がり始めるとあまり摩擦が効きません。
とはいっても摩擦がゼロにしてしまうとそもそも転がらずに滑ってしまうので、
今回は 床 を 0.5 、ボール を 0.25 にしました。 - 速度減衰 linearDamping
速度減衰を入れておくと、空気抵抗で減衰する感じで勢いが止まります。
ゴルフゲームの場合は、グリーンの上に打ち上げるといい感じにピンに寄せやすくなるので割と適任です。
しかし、今回は後述する予測をしやすくするために 0 にしました。
これを無くすと左右対称な放物線になり、予測しやすくなります。 - 回転速度減衰 angularDamping
今回主に活用したのがこちらで、ボールの angularDamping を 2.5 にしました。
芝に擦れることで回転が止まっていくイメージですね。
止まった判定
次に、いつ止まったと判定して次のショットに移るかです。
物体が止まると自動的にスリープ状態に入るので、最初は ball.b2Body.IsAwake() を使おうとしましたが、止まるまでの時間が3秒ぐらいと長くなってしまいました。
対処法として回転速度減衰 angularDamping を大きくすれば時間を短縮できます。
しかし、坂道を転がるのがすごく遅くなってしまったためこの方式は断念しました。
もう一つ、box2d.step()の中の値を大きくして時間を加速させる方法も試しました。
この方式も動きが早くなりすぎるのをショットパワーなどで調整できず諦めました。
というわけでボールの速度を確認してスリープよりも早めに止めることにしました。
826行目から以下の処理があります。
let stoplog = [false, false, false, false, false, false, false, false, false, false];
scene.onUpdate.add(function () {//ボール停止処理
if (cupstate) return;
if (!shotstate) return;
if (obstate) return;
stoplog.shift();
stoplog.push(Math.abs(ball.b2Body.GetLinearVelocity().x) + Math.abs(ball.b2Body.GetLinearVelocity().y) < 0.2);
let stop = true;
for (let i = 0; i < stoplog.length; i++) {
stop = stop && stoplog[i];
}
if (stop) {
ball.b2Body.SetAwake(false);
shotstate = false;
calcvelocity();
movelabel.hide();
shot ++;
shotlabelback.text = "第" + (shot) + "打";
shotlabelback.invalidate();
shotlabel.text = "第" + (shot) + "打";
shotlabel.invalidate();
posx = ball.entity.x;
posy = ball.entity.y;
// if (soundstate) scene.assets["se_ready"].play().changeVolume(0.9);
}
});
832行目でXとY速度の絶対値の和が 0.2 以下かどうかで停止を判定しています。
0.2という値は一時的に画面に値を映して、動かしながら決めました。
そして停止したかどうかの反対はstoplogという配列に10フレーム分保存し、すべて停止していれば次のショットに移ります。
10フレーム確認しているのは一時的に低速になる事があるためです。
坂を登って勢いが殺されたときや、坂に垂直に着地したときに、坂の途中なのに止まったと判定されることがありました。
これも色々動かしながらフレーム数を決めています。
判定にかかる時間は0.33秒程度なので許容範囲です。
また、停止と判定してからゆっくり動くと都合が悪いので完全に停止させています。
ball.b2Body.SetAwake(false) とすると速度ゼロになるようなのでこれを使いました。
[21/12/2追記]
SetAwake(false)だけでは完全に停止しないケースもあることがわかりました。
これは予測ですが、物体が他の物体に少しめり込んだ状態でスリープにすると、衝突の処理が入ることでスリープが解除されてしまうのではないかと思います。
かわりにショット準備中は時間の動きbox2d.step()を止めて動かないようにしました。
今回は動く物体が一つですが、この方法だと物体を個別に止めることはできません。
軽く調べた感じではdynamicの物体を強制的に止める方法は見つかりませんでした。
一時的にfixtureを削除する方法はありそうでしたが、ちょっと大げさかなと思い試しませんでした。一時的にdynamicからstaticにする方法はあるかもしれませんが、こっちは調べていません。
[22/4/30追記]
別の作品でdynamicからstaticに変える機会がありました。.SetTypeを使います。
停止させる物体がballの場合、下のように書きます。
ball.b2Body.SetType(0);
カッコの中にはstaticなどの文字を入れるのではなく数字を入れます。
おそらく staticが0 、Kinematicが1、Dynamicが2 ですね。
2に戻せば再度動かせそうです。
物理演算の予測
今回はショットの軌跡を表示して予測できるようにしました。
この予測なんですが、結論から言うとあまりおすすめできません。
995行目のショット後の速度を決める処理に、planという予測表示用の画像の位置を決める処理があります。
function calcvelocity() {
leng = Math.sqrt(pointx**2 + pointy**2);
if (leng > 310) leng = 310;
angle = Math.PI/2 - Math.atan2(pointx, pointy);
vx = Math.cos(angle) * leng/15;
vy = Math.sin(angle) * leng/15;
for (let i = 1; i < 16; i++) {
if (i < Math.floor(leng/20) + 2) {
plan[i].x = ball.entity.x + vx*(i-0.25)*2;
plan[i].y = ball.entity.y + vy*(i-0.25)*2 + world.gravity[1]*( (i/g.game.fps*2)**2)*world.scale/2;
plan[i].modified();
plan[i].show();
} else {
plan[i].hide();
}
}
}
ここで、Y方向の重力加速度world.gravity[1]、g.game.fps、world.scaleで予測しているところまではいいのですが、時間と関連する i に i-0.25 という補正を加えています。
この0.25という値には全く理論的な根拠がありません。適当に合わせただけです。
これを参考にしていい感じの予測になりそうならそれでもいいんですが、
物理演算がどうなっているかはわかっていないので、あまり深入りしたくないですね。
接触イベント
これまでイベントは scene.onUpdate.add で毎フレーム処理するか、onPointDown.addでタッチ操作をしたときに起動していました。
新しいタイミングとして、物理演算内で物体の接触があったときに処理を始めることができます。
コンタクトリスナーの宣言
まずコンタクトリスナーというものを宣言します。
let contactListener = new b2.Box2DWeb.Dynamics.b2ContactListener;
接触時の処理
次に接触したときの処理を記載します。
contactListener.BeginContact = function (contact) {
if (box2d.isContact(ball, floor, contact)) {
︙
処理内容
︙
}
}
さきほどのコンタクトリスナーに.BeginContactというのをつけます。
これはその名前の通り接触が開始したときに処理されます。
他にも.EndContactというのもあり、これは接触が終わったときです。
さらに PostSolveと PreSolveというのもありたぶん接触の計算の前後に処理が入るんだと思いますが、よくわからないのでこちらを参考にしてください。
そして.BeginContactのカッコの中に if (box2d.isContact(ball, floor, contact)) { … } をいれて、接触する物体である ball と floor を指定します。
ここで指定するために、物体には名前をつけておく必要があります。
コンタクトリスナーの登録
最後に物理世界box2dにコンタクトリスナーを登録します。
box2d.world.SetContactListener(contactListener);
接触処理の例
処理の例は510行目にありますが、カップにボールが直接入ったかどうかを判定するための若干複雑な処理になっています。
シンプルな例として、ボールと床が接触しているかどうかをcontactstateという値に保存する想定の処理を記載してみます。
let contactListener = new b2.Box2DWeb.Dynamics.b2ContactListener;
contactListener.BeginContact = function (contact) {
if (box2d.isContact(ball, floor, contact)) {
contactstate = true;
}
}
contactListener.EndContact = function (contact) {
if (box2d.isContact(ball, floor, contact)) {
contactstate = false;
}
}
box2d.world.SetContactListener(contactListener);
あとは画面をタッチしたときにcontactstateで上向きの速度を与えるかどうか場合分けすると、簡単なジャンプアクションにすることができます。
床に横や下からあたったときもcontactstateがtrueになってしまいますが、使いようはあると思います。
リセット時の注意点
一つ注意点があるのが、物理世界をリセットした際に再度コンタクトリスナーの登録をする必要があるところです。
手っ取り早く物理をリセットするため新しく物理世界を新しく作り直すことがあるのですが、コンタクトリスナーを使い回す場合は以下のように登録し直します。
box2d.destroy();
box2d = new b2.Box2D(world);
box2d.world.SetContactListener(contactListener);
複数の物体との接触イベント
今回のゴルフゲームはコースにバリエーションを与えるため、床を9分割して高低差に違いを与えています。
物体として9個が別々になってしまうので、box2d.isContact(ball, floor, contact) を記載するのが面倒ですが、一括で指定する方法があります。
562行目にありますが、box2d.createBodyDef の userData を同じものにするとbox2d.isContactの接触対象としてみなされます。
let bodydef = box2d.createBodyDef({
type: b2.BodyType.Static, // 静止:Static 運動:Kinematic 動的:Dynamic
linearDamping: 0, // 速度の減衰率 0~3ぐらい
angularDamping: 0, // 回転速度の減衰率 0~3ぐらい
userData: "floor",//識別用
});
box2d.isContact(ball, floor, contact)と書いた場合、floorという物体しか指定していないように見えますが、同じuserDataを持つ物体すべてを指定していることになります。
逆に他の物体を接触対象に含めない場合は、別のuserDataにしなければなりません。
userDataを指定せずに行ごと消しておけば自動で番号が振られます。
物体のIDがuserDataになるという話で、0から始まる整数になった記憶があります。
このためuserDataを個別に指定するときは数字はやめておいたほうが良さそうです。
接触音
BeginContactの中に音を鳴らす処理を入れるだけでとりあえず音はなります。
しかし、ふわっと着地しただけでも音がなったりするので、速度によって音の大きさを決めると自然になります。
サンプルでは音の処理は無効にしていますが514行目に以下に似た処理があります。
let contactListener = new b2.Box2DWeb.Dynamics.b2ContactListener;
contactListener.BeginContact = function (contact) {
if (box2d.isContact(ball, floor, contact)) {
let move = Math.abs(ball.b2Body.GetLinearVelocity().x) + Math.abs(ball.b2Body.GetLinearVelocity().y);
if (soundstate && !cupstate) scene.assets["se_contact"].play().changeVolume(move/20);
}
}
box2d.world.SetContactListener(contactListener);
速度はMath.sqrt(x**2+y**2)がいいかもしれませんがさぼって絶対値の和にしてますね。
絵文字
細かい小技ですが今回293行目でDynamicFontのラベルに絵文字を使ってみました。
ちょっとした画像を用意するのがめんどくさいときに使えるのではないかと思います。
let jikanlabelback = new g.Label({
scene: scene, text: "⏰", parent: uilayer,
font: font0b, fontSize: 48,
x: 20, y: 42,
anchorX: 0, anchorY: 0.5, opacity: 1,
});
ただし、環境によって表示される絵文字が違ったり表示されなかったりします。ブラウザと端末を変えたところ、白黒の時計、グレーの時計、赤い時計と変わりました。
万一表示されなくても大丈夫な所が良いですね。
色が変わって背景と同化する恐れもあります。ボタンに採用するのはまずそうです。
今回は以上です。
この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。