ニコ生ゲームを作ろうと思ったら物理的に考える その1
今回から物理演算を利用したゲームをやっていきます。
サンプルとして下のものを作りました。音は抜いています。
■目次
- 物理演算のメリット
- akashic-box2dの情報
- akashic-box2dのインストール
- akashic-box2dの利用方法
- 世界を創造する
- 物体を出す
- 物体の位置を指定する
- 固定された箱を作る
- 自由に動く物体を出す
- 物体の情報を取る
- 物体を消去する
- 世界をリセットする
- 時を止める
- 立体的な文字
- 直感的な色指定方法
物理演算のメリット
物理演算のメリットは「物理がわからなくてもできる」ところです
一見矛盾しているようですが、
物理演算では、地形と物体を配置してやればあとの動きは勝手に演算してくれます。
これを物理演算無しでやろうとすると、
「この物体が今この速度と角度だから、この壁にこの角度でぶつかる予定で、ぶつかったあとはこの速度と角度になって、画像の位置をここに変更して…」
という一つ一つを物理法則に従って書いていくことになります。
さらに、複数の物理現象がある場合にどれを優先するかなど、正直やってられません。
物理演算があると、
「壁くんと床くんはここでスタンバっといて。床くんは気持ち弾む感じでお願い。箱くんがここに入ってくるからあとは流れで。」
という感じで場所と物理パラメーターを指定するだけで勝手にやってくれます。
確かに反発係数・摩擦係数・衝撃・速度・重力などの概念はでてきますが、Akashic Engine向けの物理演算ではせいぜい10個程度の物理的要素しかありません
物理用語がわからなくても適当に指定して動かしてみればどうなるのかわかります。
1から書くと期待通りの動きにならず、何度も書き直しますが、
物理演算ならある程度物理っぽい動きに収まるのもいいところですね。
akashic-box2dの情報
以下の場所にあります
- akashic-box2dの始め方 公式の基本的な情報
- akashic-box2dリファレンス 右のメニューから詳細が見れる
- サンプルコード 最後に物理演算のサンプルがある
- 本家box2d akashicとは関係ない本家の情報 そのまま使えるものもある 英語
Akashic Engineと比べても少ないですね。
しかも書き方がバラバラで、古い書き方のままで動かないものもあります。
akashic-box2dの作例を公開して解説までしてる人は見たことがないです。
[22/1/27追記]
公式のbox2dの情報について新しくシンプルなものを見つけました。
このタイプのサンプルコード紹介サイトはブラウザ上でコードを書き換えて、右上の更新マークを押せば反映させることができます。
コードの違いと実際の動きを確認したいときに便利です。
akashic-box2dではなくてbox2dの制作記はあったりしますが、
どこまで参考にしていいのかわからないので載せるのはやめておきます。
akashic-box2dのインストール
元のゲームを用意する
まず元となるゲームを用意します。
これまでにakashicでゲームを使ったことがあればそれでもいいです。
制限時間表示や終了演出などはakashic-box2dを導入してもそのまま使えます。
なければakashic initで空のゲームを作るといいでしょう。
冒頭にある今回のサンプルゲームから改造してもいいです。
その場合は次の章のインストールもいらないですね。
インストール
akashic-box2dのインストールはコマンドプロンプトからやります。
コマンドプロンプトでゲームのフォルダに移動して、以下の呪文を唱えます。
akashic install @akashic-extension/akashic-box2d
そうするとnode_modulesフォルダの中にbox2dwebというフォルダができます。
game.jsonの中にも2箇所記載が増えているはずです。
main.jsの記載
main.jsに以下の記載を追加します。
let b2 = require("@akashic-extension/akashic-box2d");
サンプルコードでは418行目です。
場所としてはscene.onLoad.addの中ですね。外でも動きますが。
世界を創造する
まず物理の世界を定義して生成します。
以下のように3つの数字を指定してb2.Box2Dというのをbox2dの名前で作ります。
let box2d = new b2.Box2D({
gravity: [0, 9.8],
scale: 50,
sleep: true
});
数字で変更する可能性があるのは重力gravityですかね。
ゆっくりした動きにしたり無重力にしたりできます。
[0, 9.8]で数字が2つあるのは横向きの重力もありえるからです。
横から風が吹いている表現に使えるかもしれません。
残りの2つは進んだことがやりたくなったときにこちらの説明を読んでください。
この記載ですが、今回のサンプルでは後で再利用するため分けて書いています。
let world = {
gravity: [0, 9.8],
scale: 50,
sleep: true,
};
let box2d = new b2.Box2D(world);
420行目ですね。
もう一つ、時を進めるために以下の処理を追加します。
scene.update.add(function() { // 物理エンジンの世界を進める
box2d.step(1/g.game.fps);
});
これで世界が動き始めます。
物体を出す
では物体を出していきましょう。
createBodyを使います。
box2d.createBody(entity, bodydef, fixturedef);
Bodyは「物理的な実体」みたいな意味です。
createBodyは3つのパラメータが必要です
- entity 表示用のエンティティ
- bodydef 物体のパラメータ
- fixturedef 物体のパラメータ
entityにはakashic engineのエンティティを使います。
g.FilledRect や g.Sprite などのエンティティなら大体なんでも使えて、
エンティティが物体に張り付いて回転も含めて自動で表示を合わせてくれます。
bodydefにはbox2d.createBodyDef、fixturedefはbox2d.createFixtureDefを使います。
bodydefとfixturedefが何故分かれているのかはわかりません。fixturedefは接触関係?
まとめて書くと以下のような流れです
let entity = new g.FilledRect({
scene: scene, cssColor: "red", parent: scene,
width: 100, height: 100,
});
let bodydef = box2d.createBodyDef({
type: b2.BodyType.Static, // 静止:Static キネマティック:Kinematic 動的:Dynamic
linearDamping: 0, // 速度の減衰率 0~3ぐらい
angularDamping: 0, // 回転速度の減衰率 0~3ぐらい
userData: 0,//識別用
});
let fixturedef = box2d.createFixtureDef({
density: 1.0, // 密度
friction: 0.5, // 摩擦係数
restitution: 0.3, // 反発係数
shape: box2d.createRectShape(100, 100) // 形状
});
let body = box2d.createBody(entity, bodydef, fixturedef);
いちばん重要な「大きさ」の情報は3行目と15行目にあります。
前者が見た目の四角の大きさで、後者が物理的な実体としての大きさですね。
あえて見た目と物体をずらす必要があることはあまりないでしょう。
他に細かいパラメータもありますが、最初は特に考えずにそのままでいいと思います。
物体の位置を指定する
物体を配置しても現状は 0, 0 の左上の位置に置かれてしまいます。
物体の位置を指定したいところですが、box2d.createBodyの中では指定できません。
物体を作ったあとにbody.b2Body.SetPositionを使って以下のように書きます。
body.b2Body.SetPosition(box2d.vec2(g.game.width/2, g.game.height/2));
ついでに角度も指定すると以下のようになります。
body.b2Body.SetAngle(box2d.radian(45));
さっきの let bodyのあとに記載するだけです。
エンティティのように.modified()みたいなものはありません。
この表現について、こちらの公式の記事だとb2bodyという小文字の表現があります。
昔はこれでもいけたんですが変わったようで、現在はb2Bodyです。
これらの指定方法は、基本的に物体生成直後でなくても使えます。
ただし、ちょっと設定が必要かもしれないので次回以降にまとめて説明します。
固定された箱を作る
今回のゲームでは真ん中に箱を置きます。
この箱を作るには2つの壁用の四角と1つの床用の四角が必要です。
さきほどのコードを3セット書いてもいいですがやたら長くなります。
更に、同じ名前は使えないのでlet entityの名前を毎回変える必要もあります。
というわけで以下のような関数にしました。432行目にあります。
function makestaticbox(x, y, width, height, angle, color) {
let entity = new g.FilledRect({
scene: scene, cssColor: color, parent: movelayer,
width: width, height: height,
x: x, y: y, anchorX: 0.5, anchorY: 0.5, angle: angle,
});
let bodydef = box2d.createBodyDef({
type: b2.BodyType.Static, // 静止:Static キネマティック:Kinematic 動的:Dynamic
linearDamping: 0, // 速度の減衰率 0~3ぐらい
angularDamping: 0, // 回転速度の減衰率 0~3ぐらい
userData: 0,//識別用
});
let fixturedef = box2d.createFixtureDef({
density: 1.0, // 密度
friction: 0.5, // 摩擦係数
restitution: 0.2, // 反発係数
shape: box2d.createRectShape(width, height) // 形状
});
let body = box2d.createBody(entity, bodydef, fixturedef);
body.b2Body.SetPosition(box2d.vec2(x, y));
body.b2Body.SetAngle(box2d.radian(angle));
return body;
}
よく変更する数値5つ「X座標、Y座標、幅、高さ、色」は指定できるようにします。
これを使って609行目で3つ作成して箱を作っています。
makerect(g.game.width/2-rectw/2-thick/2, wally, thick, recth, 0, rectcolor);
makerect(g.game.width/2, g.game.height-bottom, rectw+thick*2, thick, 0, rectcolor);
makerect(g.game.width/2+rectw/2+thick/2, wally, thick, recth, 0, rectcolor);
ここでちょっと細かい話をしますが、makestaticboxの中のエンティティlet entityの値に以下の5つを追加しました。
x: x, y: y, anchorX: 0.5, anchorY: 0.5, angle: angle,
この5つはbox2d.createBodyでエンティティに採用した時点で無効になります。
エンティティがどの場所にあるかは物体の場所によって決まるので、最初の値はすぐに置き換えられますし、後から entity.x = 1000; のようにして指定することもできません。
ではなぜあえて今回指定したかというと、生成した瞬間だけ表示がずれるからです。
一瞬だけ(0,0)の位置に表示されてすぐに本来の位置に移動します。個人的にこれが気になるので最初から本来の位置にエンティティを配置しています。
ゲーム動作上はほぼ問題ないので無視しても大丈夫です。
エアリアルサッカーとドアラッシュはこれをサボってるので見てみてください。
…と思ったんですがアツマールだとわからないですね。生放送だとまれに見えるはず。
ちなみに次に説明する動く四角の場合はこの現象が起きないので省略しています。
[追記] 動く物体でもずれることがあるようです。
自由に動く物体を出す
動かない物体だけがあっても物理演算の意味がありません。
動く物体を生成するのは固定した物体とほぼ同じですがbodydefのみ変更します。
let bodydef = box2d.createBodyDef({
type: b2.BodyType.Dynamic, // 静止:Static 運動:Kinematic 動的:Dynamic
linearDamping: 0, // 速度の減衰率 0~3ぐらい
angularDamping: 0, // 回転速度の減衰率 0~3ぐらい
userData: 0,//識別用
});
box2d.createBodyDef の type を b2.BodyType.Dynamic に変更しています。
こうすることで生成された物体は物理法則に従って自由に動いてくれます。
サンプルコードでは457行目にmakedrop関数があります。
そして、画面をクリックした際に物体を生成するため736行目で呼び出しています。
makedrop関数は最後の行に return body; と入れてあります。
こうするとこの関数を呼び出すときに let rect = makedrop(~); と書くと、生成したbody に rect という名前をつけて後々参照することができます。
サンプルでは let rect = [] という配列に格納するため、rect.push(makedrop(~)) と書いています。これにより rect[0]、rect[1]、… が各bodyになります。
物体の情報を取る
物体が自由に動いているだけだとゲームではなくシミュレーターになってしまいます。
物体の動きに応じて得点を与えたり、新しい動きのトリガーにするには
物体の情報の取得が必要です。
物体の情報はエンティティと物理的な実体の2つから情報を取得できます。
let body = box2d.createBody(entity, bodydef, fixturedef); のような形でbodyという名前をつけていた場合、 body.entity と body.b2Body がこれにあたります。
.entityになっているのは紐付けるエンティティにlet entityという名前をつけていたからではなく、常に.entityを使います。
body.entityは基本的にこれまでのエンティティと同じように使えるので使いやすいと思います。例えば、body.entity.xとすればX座標を取得できます。
654行目ではこれを使って落とした物体が箱の中に入っているか判定しています。
ちょっとややこしいのですが、これまでのエンティティでは anchorXの値によって画像の位置とX座標の関係が異なりました。
しかしBox2DではanchorX、anchorYは無効になっていて、エンティティの真ん中のX座標がbody.entity.xになります。実質どちらも0.5になっている形です。
また、エンティティから情報を取るだけでなく操作することもできます。
これまでと同じく不透明度は以下のように操作します。
body.entity.opacity = 0.5;
body.entity.modified();
touchableやcssColorもおそらくできます。
前述の通りx、y、anchorX、anchorY、angle あたりはできません。
一応body.b2Bodyの方を使うこともできます。X座標の場合は
body.b2Body.GetPosition().x * world.scale あたりでできます。
他にも.entityでは読み取れない「速度」などもありますが、今回は使わないので省略します。
物体を消去する
物体を消去する場合は、エンティティと物理的な実体の両方を消す必要があります。
エンティティはbody.entity.destroy()でこれまで通り消せます。
.hide()でもいいかもしれません。
物理的な実体を消すには、box2d.removeBody(body); で消せるはずです。
名前が必要なので物体全てに名前をつけたほうがいいですね。
存在する物体をリストアップする方法もあるようですがまだやったことがありません。
世界をリセットする
実は今回はremoveBodyは使いませんでした。
ゲームがステージ制で毎回すべて入れ替えるため、世界ごとリセットしたためです。
リセットは605行目の以下の2行でできます。
box2d.destroy();
box2d = new b2.Box2D(world);
box2d自体を破壊し、新しく同じ設定で世界を作っています。
新世界は旧世界と干渉しないのでわざわざ破壊しなくても良さそうですが、
旧世界の処理が残っていて重くなるかもしれないので消したほうが良さそうです。
エンティティの方もまとめてリセットしました。
603行目の2行で、レイヤー用のエンティティを使って似たようなことをしています。
movelayer.destroy();
movelayer = new g.E({ scene: scene, parent: backlayer });
時を止める
時を進める処理を見ていて思ったんですが、
box2d.step()の実行に条件を加えるだけで簡単に時を止めることができました。
428行目にあります。
scene.update.add(function() { // 物理エンジンの世界を進める
if (!syukkastate) box2d.step(1/g.game.fps);
});
出荷中はsyukkastateをtrueにするのでその間はbox2d.stepを実行しません。
これを見ると最後のカッコを変えればスローモーションもできそうですね。
立体的な文字
ついでに物理とは関係ない小技も紹介していきます。
DynamicFontを使う場合、strokeWidthとstrokeColorを設定することで縁取り文字ができますが、今回は立体的な厚みが出るようにしてみました。
まず以下のようなフォントを2種類用意します。
1つ目はfontColorとstrokeColorを同じにしてstrokeWidthを太くしています。
let font0b = new g.DynamicFont({
game: g.game,
fontFamily: "sans-serif",
fontWeight: "bold",
size: 96, fontColor: "black", strokeWidth: 12, strokeColor: "black",
});
let font0 = new g.DynamicFont({
game: g.game,
fontFamily: "sans-serif",
fontWeight: "bold",
size: 96, fontColor: "white", strokeWidth: 6, strokeColor: "black",
});
そして、shiftという値を設定します。
let shift = -2;
利用する際は背景用のラベルtimelabelbackと前面用のラベルtimelabelを用意し、
利用するフォントを変えます。
前面ラベルの親エンティティを背景ラベルにし、表示位置を左上に少しずらします。
let timelabelback = new g.Label({
scene: scene, text: "" + warmup, parent: uilayer,
font: font0b, fontSize: 72,
x: g.game.width-150, y: g.game.height*0.82,
anchorX: 0.5, anchorY: 0.5, opacity: 1,
});
let timelabel = new g.Label({
scene: scene, text: "" + warmup, parent: timelabelback,
font: font0, fontSize: 72, x: shift, y: shift,
});
このときの前面ラベルのanchorXとanchorYは0がいいですね。
上では記載を省略することで0にしています。
そしてちょっとめんどくさいのですが、文字を変更する場合は両方のラベルの.textを変更して.invalidate()します。
.opacityの変更や.hide()の場合は親エンティティの背景ラベルの方だけでいいですね。
出来具合はゲームの方を見てください。
直感的な色指定方法
これまで色の指定には"#FFFFFF"や"rgb(255,255,255)"を使っていたんですが、
今回はhsl()を使ってみました。
hsl(0, 100%, 50%)のように書き、順に色相、彩度、輝度です。
- 色相 : 0~360 0が赤 90が黄緑 180がシアン 270青紫
- 彩度 : 0~100% 0%だと灰色 100%だと鮮やか
- 輝度 : 0~100% 0%だと黒色 50%だと鮮やか 100%だと白色
これはAkashic Engineとは関係ないのでもともと知ってる方もいそうです。
今回投下する物体の色をカラフルにしたのですが、ある程度鮮やかさと明るさを固定して色を変えないと、背景や箱と同化して見にくくなる恐れがあります。
ここで、hslだと色相だけ変えるのが楽でした。
randomという値に0~1のランダムな値を入れた上で、以下のようにしています。
let color = "hsl(" + Math.floor(random*360) +",100%,60%)";
今回は以上です。
このブログを書き始めるきっかけがakashic-box2dの情報の少なさだったのですが、
ここまで来るのに1年近くかかってしまいました。
考えようによっては物理演算のほうが簡単に作れるんじゃないかと思います。
まだ何種類か作る予定なので、どなたかが参考にしていただけるのを願うばかりです。
ちょっとパラメータを変えただけのゲームでも大歓迎です。
この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。