ニコ生ゲームを作ろうと思ったら物理的に考える その4


今回は物体の運動と衝突の場合分けを紹介していきます。

 

以下がサンプルです。音は抜いています。

 

・コイン押し機 ファイル アツマール


■目次

  • 物体を運動させる
  • 衝突の有無を場合分けする
  • 衝突の場合分けを変更する
  • 衝突処理の重さについて

 

 

物体を運動させる

今回のゲームはコイン落としです。

コインを押す壁は物体としてコインを押しつつ、決められた動きが必要です。

 

この運動する物体の設定には box2d.createBodyDeftypeb2.BodyType.Kinematic を使います。サンプルコードの563行目に以下の記載があります。

 

            let bodydef = box2d.createBodyDef({
                type: b2.BodyType.Kinematic,
                linearDamping: 0,
                angularDamping: 0,
                userData: "table",
            });

 

以前出てきた設定のStaticだとSetLinearVelocityなどを使うことができません

SetPositionは使えますが、毎フレームSetPositionを使ってシームレスに動かしても、
衝突された時に静止した物体として衝突したことになり動きの勢いが加わりません。

例えばバットでボールを飛ばすゲームの場合は SetPosition以外の方法が必要です。

 

今回の動く壁はゆっくりコインを押し出すだけなのでStaticとSetPositionでも良かったのですが、587行目のようにSetLinearVelocityを使いました。

 

もう一つの設定DynamicはSetLinearVelocityを使えますが、他の物体に押されて動きがずれてしまいます。他の物体を強制的に動かすような物体には使えません。

 

 

SetLinearVelocity のデメリットとしては動きのイメージがしづらいところですね。SetPositionなら行きたいところを直接指定できます。

 

往復運動などの周期的な動きも注意が必要です。SetLinearVelocityの場合は周期を終えたときに元の場所に戻っていないと、繰り返すうちにどんどんずれていきます。

 

 

 

衝突の有無を場合分けする

 

今回のサンプルではコインが存在する段の違いを衝突処理の場合分けで表現しました。

1段目のコインは動く壁に接触し、2段目のコインは動く壁には接触しません。

 

公式の情報

残念ながらこの辺のことはこちらの基本紹介ページには書いてありません。

 

こちらのサンプルプログラムで使われていて、コードにコメントが有るのですが…
正直なところ記載内容とコメント内容と実際の挙動の対応がよくわかりません。

 

さらにBox2dの公式までさかのぼるとこちらに英語で説明があります。
がしかし実際にどう書けばどうなるかの例がありません。

 

というわけでいくつかのパターンでやってみます。

 

フィルターの基本

衝突の場合分けをするには box2d.createFixtureDeffilter の情報を記載します。

以下のような形です。

            let fixturedef = box2d.createFixtureDef({
                density: 1.0, // 密度
                friction: 0.5, // 摩擦係数
                restitution: 0.3, // 反発係数
                shape: box2d.createRectShape(width, height), // 形状
                filter: {
                    groupIndex: 1,
                    categoryBits: 1,
                    maskBits: 1
                }
            });

filterのなかに groupIndex categoryBits maskBits の3つを入れています。

categoryBits と maskBits は基本的にセットですが、常に3つが必要とは限りません。

 

大まかな説明をすると以下の通りになります。

  • groupIndex:ざっくりグループを分ける際の番号 プラスやマイナスの整数
  • categoryBits:細かく衝突対象を設定する際に自分につける番号 プラスの整数
  • maskBits:細かく衝突対象を設定する際の衝突対象の番号 プラスの整数

 

フィルターを使わない場合

filterの値がない物体同士は衝突判定を持ちます。これまでやってきたとおりです。

片方どちらかがDynamicの物体ならはじきあって以下のイメージになります。

f:id:nicorakku:20220126164837p:plain

 

ざっくりグループで分ける場合

出てくる物体をグループ1、グループ2のように分け、それぞれのグループ内だけで衝突すればいい場合、groupIndexを使うだけでできます

 

例として3つの物体を下の設定で作ります。

 四角:filter: { groupIndex: 1}

 丸 :filter: { groupIndex: 1}

 菱形:filter: { groupIndex: 2}

下のように、菱形だけが他と衝突しなくなります。

f:id:nicorakku:20220126164940p:plain

 

衝突しないようにする場合

groupIndexを0かマイナスにすると「衝突しない」になります。

filter: { groupIndex: -1} の物体が2つあってもそれ同士は衝突しません。

 

 四角:filter: { groupIndex: -1}

 丸 :filter: { groupIndex: -1}

 菱形:filter: { groupIndex: 1}

上のような場合では四角と丸は衝突しません。

菱形だけgroupIndexをプラスにしていますが、やはりgroupIndexが違うためどの組み合わせも衝突しません。

f:id:nicorakku:20220126165020p:plain

ここでちょっと混乱するんですが、公式のサンプルコードだとgroupIndexが-1の物体同士で衝突させています。上で書いたルールと異なりますが、公式のコードを再現するとやはり衝突しないので、仕様変更前のコードだったのかもしれません。

 

 

自分のコピーとは衝突させない場合

大量の弾をばらまいて壁に衝突させるが、弾同士は衝突してほしくないというような場合、groupIndexだけでは設定が困難です。

 

そこで categoryBits と maskBits を使って衝突対象をひも付けます。

 四角:filter: { categoryBits: 1, maskBits: 2 }

 丸1:filter: { categoryBits: 2, maskBits: 1 }

 丸2:filter: { categoryBits: 2, maskBits: 1 }

f:id:nicorakku:20220126165052p:plain

上のように丸同士は衝突しませんが、両方とも四角とは衝突します。

categoryBitsがそれぞれの物体のIDのようなものです。

maskBitsでそのIDを指定していると衝突します。

 

このフィルター設定には注意点がいくつかあります。

  • 互いにmaskBitsで指定し合う
    片方からmaskBitsで指定しただけでは衝突しません
    以下の例は衝突しません
     四角:filter: { categoryBits: 1, maskBits: 2 }
     丸 :filter: { categoryBits: 2, maskBits: 4 }

  • categoryBitsとmaskBitsは両方揃えて記載する
    どちらかがかけていたり両方ない場合は基本衝突しません。
    groupIndexがそろっている場合は衝突するケースもあります。

  • 値を0にしたものは衝突しない
    以下の例は衝突しません
     四角:filter: { categoryBits: 1, maskBits: 0 }
     丸 :filter: { categoryBits: 0, maskBits: 1 }

  • 値は 1, 2, 4, 8,… のように2の自乗のみ使うとわかりやすい
    上記以外を使うとややこしいので、これらの値を使うのがおすすめです。
    2の15乗の32768まではつかえるはずです。

    3や5も使えるのですが、複数の衝突対象が指定されて複雑な条件になります。
    複雑な指定は次の章で説明しています。

 

 

複数の衝突対象を設定する場合

ここからは進んだことがやりたい時に確認してください。

そうでなければ無視してください。

 

1, 2, 4, 8…だけで指定していると、複数のcategoryBitsに対して衝突を指定できません

複数の指定をする場合は2進数を使うとやりやすくなります。

 

2進数は最初に「0b」(ゼロビー)をつけて、その後に「0」「1」のどちらかだけを使って記載します。

 例 : 0b0001 、 0b001011011 、 0b11111111111111111

 

そして衝突先のcategoryBits衝突元のmaskBits の値の
同じ桁に両方「1」があれば衝突します。

 例1: 先 0b0001 元 0b0011 → 右から1桁目が両方1なので衝突する

 例2: 先 0b1100 元 0b0011 → どの桁も両方1になっていないので衝突しない

 

物体の例を作るとこのような形です。

 四角:filter: { categoryBits: 0b0001, maskBits: 0b0110 }

 丸 :filter: { categoryBits: 0b0010, maskBits: 0b0001 }

 菱形:filter: { categoryBits: 0b0100, maskBits: 0b0001 }

この例では四角が丸と菱形に衝突し、丸と菱形同士は衝突しません。

f:id:nicorakku:20220126165149p:plain

これだけだと複雑な例とは言えませんが、物体の種類が増えても対応できます。

 

桁数はたぶん16桁まで大丈夫です。

桁数が違うものが混ざっていても右端からの桁数で比較されます。

 

 

その他の注意点

ここは非常に細かい法則についての記載です。

なにか問題が発生したときだけ確認してください。

 

groupIndexがそろっているとmaskBItsよりも優先される

groupIndexが1の物体同士はmaskBItsがなんであれ衝突します。

groupIndexが-1の物体同士はmaskBitsがなんであれ衝突しません。

 

groupIndexがそろっていないとmaskBItsが優先される

groupIndexが-1の物体とgroupIndexが-2の物体の衝突はmaskBItsの値次第です。

 

groupIndexが0でそろっているとmaskBItsが優先される

groupIndexが0の物体同士の衝突はmaskBItsの値次第です。

0のときのみ例外としてmaskBitsが優先されます。maskBitsがなければ衝突しません。

 

filterを指定しないと既定の値が採用される

既定は以下のような値です。

      filter: {
          groupIndex: 0,
          categoryBits: 0b1,
          maskBits: 0b1111111111111111
      }

これはfilterの項目を全く記載せず省略した時の話です。

filter: {  } のように中が空のfliterを記載するのは別で、こちらは全く衝突しません。

 

 

衝突の場合分けを変更する

[2022/4/10追記]こちらの章にfixtureを削除する方法の記載を追加しました。

 

衝突の条件を変更する場合は衝突判定であるfixture削除して新しく追加します。

 .b2Body.CreateFixture() を使います。

 

fixtureの削除

fixtureの削除には.b2Body.DestroyFixture() を使いますが、どのfixtureなのかを指定する必要があります。

 

fixtureに名前をつけていればそれを指定してもいいのですが、うまく名前を使い回せないこともあるので、今回は単に物体についているfixture.b2Body.GetFixtureList()で指定します。

        body.b2Body.DestroyFixture(body.b2Body.GetFixtureList());

 

これでbodyのfixtureを削除できます。fixtureが一つだとどれということもありません。

 

.GetFixtureList()はfixtureのリストを取得できますがいわゆる配列とは違うようです。

.GetFixtureList()の記載だけでリストの最初のfixtureを指定したことになるそうです。

リストの最初というのは、色々検証してみたところ一番最後に登録したfixtureのことのようです。

 

fixtureの登録

新しくfixtureを物体に登録するには.b2Body.CreateFixtureを使います。

   body.b2Body.CreateFixture(fixturedef);

 

カッコの中に入れるのは「fixture」ではなく「fixturedef」です。

fixturedefはfixtureの設定データのようなものです。

 

ちなみに let newfixture = body.b2Body.CreateFixture(fixturedef) のように書けば、fixtureに newfixture という名前をつけることもできます。

 

filterの異なるfixturedefの準備

今回のサンプルコードでは段階に分けてコインのfixturedefを3つ用意しています。

 

投入するコインの移動中は衝突をなくし、上端に到達した段階で上の壁とコイン同士で衝突するように変更します。(673行目でfixturedef1に変更。)

 

そして、動く壁よりも下に移動した段階で、動く壁とも衝突するように変更します。

1015行目でfixturedef2に変更。)

 

投入中のコイン 596行目

        let fixturedef0 = box2d.createFixtureDef({
    ︙
            filter: {
                groupIndex: -1, //飛んでいるときはコイン同士は当たらない
                categoryBits: 0b100,
                maskBits: 0b000
            }
        });

動く壁の上のコイン 607行目

        let fixturedef1 = box2d.createFixtureDef({
    ︙
           filter: {
                groupIndex: 1,
                categoryBits: 0b100,
                maskBits: 0b001
            }
        });

動く壁の下のコイン 618行目

        let fixturedef2 = box2d.createFixtureDef({
    ︙
           filter: {
                groupIndex: 1,
                categoryBits: 0b100,
                maskBits: 0b011 //テーブルに当たるようになる
            }
        });

動く壁 569行目

        let fixturedef = box2d.createFixtureDef({
    ︙
            filter: {
                // groupIndex: 1,
                categoryBits: 0b010,
                maskBits: 0b100
            }
        });

上と左右の壁 545行目

            let fixturedef = box2d.createFixtureDef({
    ︙
            });

 filter記載なし

 

SetFilterDataというものもあるようなのですが使い方がいまいちわかりませんでした。

 

この方法を使えば物体を一時的にStaticにして静止させることもできそうですね。

 

衝突処理の重さ

 

物理の衝突は結構重く、動作がカクカクしてきます

 

今回のコインの数はもとはもう少し多かったのですが、重さを見て調整しました。にもかかわらず一部の方のプレイに支障をきたしており、ギリギリアウトの領域です。

 

台の上に配置される物体が60個程度。画面内に投入される物体で最大100個弱になります。

※[2022/4/10追記] 物体の判定が重複していたため実際には200個程度ありました。
 200個ぐらいまでは大丈夫かもしれません。

 

この状況でニコ生ゲーム中の処理の重さはどうなるかというと、PCは多少カクつくもののプレイにはまず問題ありません。
スマホでも問題なくプレイできる方がいる一方で、一部の方は重くてプレイできないようです。

 

このゲームのデザイン的に多少カクついても眺めていればいいのですが、完全に止まってしまうようだとプレイに支障をきたします。処理が追いつかなくて、ランキング用のスコア送信がスキップされることも起きている気がします。

 

ゲームによってぜんぜん違うと思いますが、100個の自由に動く物体というのは一つの基準になりそうです。エアリアルサッカーも動作が完全に止まるのを防ぐために100人までにプレイヤーを制限した記憶があります。

 

シビアなタイミングの操作が要求されるゲームの場合は更に物体または衝突の数を減らして動作を軽くする必要があるでしょう。
エアリアルサッカーはこれに該当しますが…マルチであることによりさらに重くなっているようで、物体が少なくてもカクツキを抑えきれていません。

 

また、akashic-sandboxでの動作確認よりも生放送中の方が処理が重くなる傾向があります。放送で重さを確認した後に修正することも考えたいところですが、スコアボードを運用していると根本的な修正は悩ましいですね。

 

 

今回はここまでです。

 

公式が出しているbox2dの情報が、逆引きリファレンスのこちらにありました。

シンプルになっていてわかりやすいです。以前はなかったと思うのですが、気づかなかっただけかもしれません。

 

サンプルコードもこちらにあります。

このタイプのサンプルコード紹介サイトはブラウザ上でコードを書き換えて、右上の更新マークを押せば反映させることができます。

コードの違いと実際の動きを確認したいときに便利です。

ただ、エンティティの位置の算出方法がちょっとよくわからないような…

 

こちらのAkashic Engine 逆引きリファレンスは順次記事が増えるようです

また、大きな変更事項はこちらでアナウンスされるとのことです。

どちらも公式のプルダウンメニューから飛べます。

 


この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。

 

ニコ生ゲームを作ろうと思ったら物理的に考える その3


今回は画像を物理演算の円や多角形に貼り付けていきます。

 

以下がサンプルです。音は抜いています。

 

・2Dゴルフ ファイル アツマール

 


■目次

  • 円を作る
  • 多角形を作る
  • 画像を物体に貼る
  • 正多角形を作る
  • 静止を待つ
  • 物理演算の予測
  • 接触イベント
  • 複数の物体との接触イベント
  • 接触
  • 絵文字

 

 

円を作る

これまで四角だけを使いましたが今回は円を使います。

 

物体の形の指定は box2d.createFixtureDefshape にあります。

四角では 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),
                ])
            });

上の例は下の画像のような下り坂を描画していて、上の頂点から書いています。

 

  f:id:nicorakku:20211127180036p:plain

 

多角形の書き方にはいくつかルールがあります。

  • 時計回りで指定する
    逆回りにすると判定がおかしくなってすり抜けたりします。

  • 物体の中心が (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))),
      ])

 

正五角形は中心がちょっとずれます。

f:id:nicorakku:20211127180040p:plain

 

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.fpsworld.scaleで予測しているところまではいいのですが、時間と関連する ii-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,
        });

 

ただし、環境によって表示される絵文字が違ったり表示されなかったりします。ブラウザと端末を変えたところ、白黒の時計、グレーの時計、赤い時計と変わりました。

 

万一表示されなくても大丈夫な所が良いですね。

色が変わって背景と同化する恐れもあります。ボタンに採用するのはまずそうです。

 

 

 

今回は以上です。

 


この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。

 

 

 

 

ニコ生ゲームを作ろうと思ったら物理的に考える その2


今回も物理演算を利用したゲームをやっていきます。

 

物体の操作などを追加していきます。

以下がサンプルです。音は抜いています。

 

・ニコニコ清掃 ちりとり編 ファイル アツマール

 


■目次

  • 重力の設定
  • 物体の操作 速度指定
  • スリープ状態
  • フリック操作の速度
  • 速度の減衰 damping
  • 反発係数 restitution
  • 物体生成位置の重なり
  • 物体のリストアップ
  • 親エンティティの乗り換え

 

 

重力の設定

 

今回は部屋を真上から見下ろして掃除をするゲームです。

見下ろしの視点では重力をゼロにします。

 

見たままですが、388行目重力gravityの設定があります。

 

        let b2 = require("@akashic-extension/akashic-box2d");

        let world = {
            gravity: [0, 0],
            scale: 50,
            sleep: true,
        };

        let box2d = new b2.Box2D(world);

 

 

物体の操作 速度指定

 

前回は物体を生成するだけで無理やりゲームにしていました。

今回は物体を操作していきます。

 

物体の操作は4種類あります。力を加える、衝撃を加える、速度を指定、位置を指定

 

それぞれのざっくりした説明をします。

 

  • 力を加える .ApplyForce()
    力を加えた後はずっと加速する。重力が追加されるようなイメージ。
    止めるには力をリセットする必要がある?
    使い所がわからない。レースゲームのアクセルとか?加速弾とか?

  • 衝撃を加える .ApplyImpulse()
    ある力を短い時間与えたことになる。一瞬で加速しその後は加速しない
    簡単にちょっと動かしたいのに向く。
    同じ衝撃でも動かす物の重さ(密度と面積)によって速度が変わってしまう。
    エアリアルサッカー、ドアラッシュのプレイヤーに使用。

  • 速度を指定する .SetLinearVelocity() 
    直後の速度が決まる。その後は加速しない。

    動きをイメージしやすい。
    重さが違っても同じ速度になるが、重さによって与えた衝撃の大きさが違うことになり、違和感が生まれることもある。
    エアリアルサッカーのキーパーとドアラッシュの壁とドアに使用。今回も使用。

  • 位置を指定する .SetPosition()
    ワープする。
    移動前の速度はそのまま。
    操作というより設置のイメージが強い。

 

こちらの方の記事にも少し比較が書かれています。

 

今回は速度の指定を使いますが、790行目の以下の書き方です。

 

     dust[i].b2Body.SetLinearVelocity(box2d.vec2(vx*g.game.fps, vy*g.game.fps));

 

まず b2Body.SetLinearVelocity() という書き方で速度を設定します。

カッコの中はX方向の速度とY方向の速度を指定しますが、ここで box2d.vec2(x, y) という書き方が出てきます。

 

エンティティの座標 x, y などは普段ピクセルを使っていますが、box2d.vec2を使うとbox2dで使える座標や速度に変換してくれます。

 

 

ただしこの b2Body.SetLinearVelocity() はこのままでは動きません。

次の章も合わせてお読みください。

 

 

スリープ状態

 

スリープの解除

物体にはスリープ状態があり、スリープ状態の物体は速度を設定しても動きません

 

スリープ状態を解除するのは .b2Body.SetAwake(true) と書きます。

前章の速度の設定と合わせると以下のセットになります。789行目です。

 

     dust[i].b2Body.SetAwake(true);
     dust[i].b2Body.SetLinearVelocity(box2d.vec2(vx*g.game.fps, vy*g.game.fps));

 

速度の .SetLinearVelocity() と同じく位置の .SetPosition() もスリープでは動きません。

逆に衝撃の .ApplyImpulse() は動きます。強制的にスリープが解除されます。

力の.ApplyForce()も多分動くほうだと思います。

 

スリープの意味

 

物体は動きがなくなると自動的にスリープ状態に移行します。
物体を生成した直後も動きがないのでスリープになってしまいます。

 

なぜスリープという面倒なものがあるのかは物理演算を大幅に省略するためです。
物体が数個と少なければ無くしてもいいですが、多いなら極力スリープにすべきです。

 

自動スリープの設定

 

自動でスリープにする設定は、重力と同じ場所にあります。

        let world = {
            gravity: [0, 0],
            scale: 50,
            sleep: true,
        };

sleep: true のところが、「動きがなければ自動でスリープに入る」という設定です。
物体の数が少ないのであれば false に変えても大丈夫でしょう。

 

もう一つ、物体を個別にスリープに入らないように設定することもできます。

box2d.createBodyDef allowSleep: false, を追加します。

       let bodydef = box2d.createBodyDef({
           type: b2.BodyType.Static, // 静止:Static 運動:Kinematic 動的:Dynamic
           linearDamping: 0, // 速度の減衰率 0~3ぐらい
           angularDamping: 0, // 回転速度の減衰率 0~3ぐらい
           allowSleep: false,
           userData: 0,//識別用
       });

あとからこの設定を追加・操作することも多分できます。こちらです。

     body.b2Body.SetSleepingAllowed(false);

 

 

ただ、 .SetLinearVelocity() と .SetPosition() のときだけなのであれば、
毎回 .b2Body.SetAwake(true); してあとは自動スリープに任せてもいい気もします。

 

 

フリック操作の速度

 

この項目は物理演算とは直接関係ありません。

フリック速度を算出してゲーム内で利用したのですが、意外とめんどくさかったです。

ドラッグ中の.onPointMove.addではev.point.x、ev.StartDelta.x; 、ev.prevDelta.xの3つが使えます。

 

その瞬間に動かした量は ev.prevDelta.x なので速度vxは以下のように行けそうですが、

 

            touch.onPointMove.add(function(ev) {
                vx = ev.prevDelta.x;
            });

 

これはデバイスによって大きく差が出たりして、うまくいきません。

おそらく.onPointMove.addの発生する頻度がデバイスによって違うのだと思います。

 

そもそも速度にするには1秒あたりに進んだ量や、1フレームあたりに進んだ量というように時間の幅を決める必要があります。

ev.startDelta.xの方を使って、以下のようなイメージの処理に変えます。

 

        touch.onPointMove.add(function(ev) {
            bx = ev.startDelta.x;
        });
        touch.onUpdate.add(function() {
            vx = bx - ax;
            ax = bx;
        });

        touch.onPointUp.add(function(ev) {
            ax = 0;
            by = 0;
        });

 

これであれば touch.onUpdate.add が1フレーム(1/30秒)ごとに処理されて時間の幅が定まります

 

ただこれでもスムーズさが足りません。XとYの速度に基づいて向きを示す四角いエンティティを表示させていますが、ガタガタしてしまいます。

このため、2フレーム分の速度で平均化するため以下のような処理も追加しています。
762行目に類似処理があります。

 

        let touchx = [0,0,0];
        let touchy = [0,0,0];
        touch.onPointMove.add(function(ev) {
            touchx[3] = ev.startDelta.x;
            touchy[3] = ev.startDelta.y;
        });
        touch.onUpdate.add(function() {
            if (touchx.length > 3) touchx.shift();
            if (touchy.length > 3) touchy.shift();
            let vx = (touchx[2] - touchx[0])/2;
            let vy = (touchy[2] - touchy[0])/2;
        });
        touch.onPointUp.add(function(ev) {
            touchx = [0,0,0];
            touchy = [0,0,0];
        });

 

最後に b2Body.SetLinearVelocity で使用するための補正をかけます。

上記で算出した vx は1フレームあたりに進むピクセル数です。
しかし、box2dで使う単位は秒速メートル[m/s]のようです。
ピクセル数をメートルに変換するのはbox2d.vec()に入れればOKです。
そして1フレームを1秒に変換するため g.game.fpsをかけておきます

 

結果、790行目のようになります。

     dust[i].b2Body.SetLinearVelocity(box2d.vec2(vx*g.game.fps, vy*g.game.fps));

 

時間の単位は公式などから明確な説明を見つけられなかったんですが、何となく合ってるのでこれで大丈夫だと思います。

 

 

速度の減衰 damping

 

物体に速度を設定すると、減衰率も設定しないといつまでも同じ速度で動きます

今回の物体は摩擦ですぐ止まっている感じにしたいので、減衰率 linearDamping を3に設定します。500行目にあります。

 

            let bodydef = box2d.createBodyDef({
                type: b2.BodyType.Dynamic, // 静止:Static 運動:Kinematic 動的:Dynamic
                linearDamping: 3, // 速度の減衰率 0~3ぐらい
                angularDamping: 3, // 回転速度の減衰率 0~3ぐらい
                userData: 1,//識別用
            });

 

ついでに回転速度の減衰率 angularDamping も設定しておきます。

これがないといつまでも回り続けるハンドスピナーのようになります。

 

 

今回は見下ろし型だったので、速度の減衰率で摩擦を表現しています。

重力があるタイプの横視点であれば、速度の減衰率で空気抵抗を表現できます。
エアリアルサッカーではボールをふわっとさせるのに若干減衰率を強くしていました。

 

また、box2d.createFixtureDef にも摩擦係数を設定できますが、こちらは物体と物体の摩擦なので今回はあまり影響がありません。

 

 

反発係数 restitution

 

反発係数はゴミが壁際に集まりやすいように、低めに0.2にしました。

 

            let fixturedef = box2d.createFixtureDef({
                density: 1.0, // 密度
                friction: 0.5, // 摩擦係数
                restitution: 0.2, // 反発係数
                shape: box2d.createRectShape(width, height) // 形状
            });

 

この値は現実世界では基本的に1より小さくなりますが、あえて2とかにすると動きが加速して面白くなります。ジャンプ台のようなこともできます。

 

逆に0にすれば張り付くように跳ねないようにすることもできます。
ただし、衝突する両方の物体の反発係数を0にしないとだめのようです。

 

 

物体生成位置の重なり

 

物体は同じ位置に生成させることができますが、
時が動いた次の瞬間にどちらかまたは両方が弾かれます。

 

今回のゲームでは中央にいくつか障害物の物体を配置し、多数のゴミの物体をランダムに配置します。

ランダムだと位置が重なってしまうので障害物を避けて配置する必要があるかなと思ったんですが、結論から言うとうまい具合に障害物の外に弾かれてなんとかなりました。

 

ただし、外周の壁の中に埋め込まれると外側に弾かれてしまって操作できなくなる可能性はあります。このため壁よりは内側に配置するようにしています。

 

また、複数の物体に挟まれて埋め込まれた場合は埋め込まれたまま止まってしまうこともあります。

 

 

物体のリストアップ

 

物理世界にいくつの物体があるかbox2d.bodies.length で簡単に取得できます。

711行目にあります。

今回はちりとりに入れたゴミの物理的な実体を削除していたので、物体全体の数から壁と障害物の数を引いてすべてのゴミが無くなったか判定しました。

 

box2d.bodies物体のリストなので以下のような使い方もできます。

 

       for (let i = 0; i < box2d.bodies.length; i++) {
           box2d.bodies[i].b2Body.SetAwake(true);
       }

 

 

親エンティティの乗り換え

 

物理エンジンとは直接関係ありませんが、今回ゴミの物理的な実体を削除したあとにエンティティの方は残しました。

 

ちりとりに収納された感じにするために、ちりとりのエンティティに付いて動くように

親エンティティを変更しました。691行目がそれです。

 

        chiritori.append(dust[i].entity);

 

初期の説明にあったエンティティを生成する際に記載していたscene.append(entity) と同じですね。
親エンティティを変えるというより、親エンティティに追加する感じです。

 

 

 

今回は以上です。

 


この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。

 

 

ニコ生ゲームを作ろうと思ったら物理的に考える その1


今回から物理演算を利用したゲームをやっていきます。

 

サンプルとして下のものを作りました。音は抜いています。

 

・箱詰め ファイル アツマール

 


■目次

  • 物理演算のメリット
  • akashic-box2dの情報
  • akashic-box2dのインストール
  • akashic-box2dの利用方法
  • 世界を創造する
  • 物体を出す
  • 物体の位置を指定する
  • 固定された箱を作る
  • 自由に動く物体を出す
  • 物体の情報を取る
  • 物体を消去する
  • 世界をリセットする
  • 時を止める
  • 立体的な文字
  • 直感的な色指定方法

 

 

物理演算のメリット

 

物理演算のメリットは「物理がわからなくてもできる」ところです

 

一見矛盾しているようですが、
物理演算では、地形と物体を配置してやればあとの動きは勝手に演算してくれます。

 

これを物理演算無しでやろうとすると、
「この物体が今この速度と角度だから、この壁にこの角度でぶつかる予定で、ぶつかったあとはこの速度と角度になって、画像の位置をここに変更して…」

という一つ一つを物理法則に従って書いていくことになります。
さらに、複数の物理現象がある場合にどれを優先するかなど、正直やってられません。

 

物理演算があると、

「壁くんと床くんはここでスタンバっといて。床くんは気持ち弾む感じでお願い。箱くんがここに入ってくるからあとは流れで。」

という感じで場所と物理パラメーターを指定するだけで勝手にやってくれます。

 

確かに反発係数・摩擦係数・衝撃・速度・重力などの概念はでてきますが、Akashic Engine向けの物理演算ではせいぜい10個程度の物理的要素しかありません
物理用語がわからなくても適当に指定して動かしてみればどうなるのかわかります。

 

1から書くと期待通りの動きにならず、何度も書き直しますが、
物理演算ならある程度物理っぽい動きに収まるのもいいところですね。

 

 

akashic-box2dの情報

 

以下の場所にあります

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 の typeb2.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年近くかかってしまいました。

 

考えようによっては物理演算のほうが簡単に作れるんじゃないかと思います。

まだ何種類か作る予定なので、どなたかが参考にしていただけるのを願うばかりです。

ちょっとパラメータを変えただけのゲームでも大歓迎です。

 

この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。

 

 

ニコ生ゲームを作ろうと思ったらやっぱりマルチ! その4


第4回はアクションマルチゲームのマップを広くしたものをやっていきます。
追加で気をつけるところを紹介します。


サンプルとして下のものを作りました。音は抜いています。

・財宝掘り ファイル アツマール

・ぴょんぴょんクライマー ファイル アツマール

ぴょんクラの方は結構特殊な感じになったので参考にするのにはおすすめできません。

 


■目次

  • 前々回の訂正 resolvePlayerInfo
  • 匿名希望かどうかを参照する 
  • 非表示にする hide()
  • カメラで表示する場所を変える camera
  • 観戦するプレイヤーを切り替える
  • 画面外を非表示にする
  • ゲーム展開がズレるエラーについて

 

 

前々回の訂正 resolvePlayerInfo

訂正後の記載方法

マルチゲームの名前取得について前々回の記事が良くなかったので訂正します。

        gamejoin.onPointDown.add(function(ev) {
            if (g.game.isSkipping) return;
            resolvePlayerInfo({limitSeconds: 30}, (err, info) => {
                if(!info) return;
                if(!info.name) return;
                g.game.raiseEvent(new g.MessageEvent({ msg : "join", id : ev.player.id, name : info.name, eye : localeye, color : localcolor }));
            });
        });

今回の財宝堀りの方のサンプルだと603行目です。

resolvePlayerInfoを使うのは変わりませんが、g.game.onPlayerInfoは使ってません。

resolvePlayerInfoの次の行からの3行(1行が長いので一見4行)がカッコに入っています。ここは名前利用確認の画面に回答した後に処理されます。以前使っていたg.game.onPlayerInfoと同じ役割です。

 

そして、resolvePlayerInfoの中では info にプレイヤーの情報が入っています。

infoの中身は複数あり、 info.name とすればプレイヤーの名前が得られます。

もちろん名前利用をOKしなければ、info.nameが「ゲスト000」などになります。

 

ただし、エラーなどでデータが正しく入っていないこともあるので最初の2行はinfoとinfo.nameがおかしな値であれば処理を中断させています。

そして3行目のraiseEventのなかでinfo.nameをデータに添えて全体に告知しています。

 

エラー時の処理

ここの内容は細かいので基本的に読み飛ばしてください。

上で紹介している処理ではエラー時は処理を中断させていますが、本来なら名前確認にうまく回答できなくてもゲームを進められたほうが良いです。

上の処理ではエラーが出たプレイヤーは参加できなくなります。

 

エラーが出ても適当に情報を埋めて進めればよく、例えばinfo.nameが使えなくてもMath.random()で「ゲストXXX」と名付けて全体に告知すればよいはずです。

err がエラーが発生したかどうかの情報なので、if (err){ … } のようにしてエラー時の対応を書けばいけます。

 

しかし、スペックの低いスマホで名前確認回答を時間切れさせる、という方法だとev.player.idが使えないのかうまくいきませんでした。

いろいろ試しましたが現状の最善の方式としてエラーが出たら参加できないようにしています。

 

この問題は、なんらかの変更でなんの問題もなくなるかもしれません。

実際、他の方や公式のゲームではこのエラー誘発方法でも不具合になりません。

 

訂正前の問題点

ここの内容も細かいので読み飛ばしてください。

前々回の処理がなにが問題だったのかというと、色々あるのですが
一番問題なのは、上記のエラー誘発方法を使うとゲームが強制終了することでした。

 

ぴょんぴょんクライマーが最初よく強制終了していたのはおそらくこれが原因で、どうも resolvePlayerInfo({ raises: true }); と上のエラー誘発方法が相性が悪いようです。

スマホでもちゃんと回答できれば問題ないのですが、スペックが低いと30秒の制限時間でも回答が間に合わず、確実に全員を巻き込んで強制終了しました。

 

私の最近のマルチゲームは動作が重いのも問題で、よりスマホでの制限時間切れを誘発させていたのではないかと思います。

 

 

匿名希望かどうかを参照する

resolvePlayerInfoで取得できる情報はアカウントのユーザー名以外もあります。

こちらこちらに詳細が乗っていますが、主に匿名を希望したかどうかプレミアム会員かどうかの2つですね。

名前の場合は info.name でしたが、匿名情報は info.userData.accepted で取得できます。

この値は名前利用を受け入れた(accepted)かどうかの値なので、名前利用時はtrue、匿名時はfalseになります。

 

info.nameと同じようにraiseEventを使ってプレイヤーIDとセットで全体に告知すれば全員が使えるようになります

 

例として、ぴょんぴょんクライマーの方のコードでは257行目で使用しました。

用途としては割とどうでもいい機能だったのですが、このゲームではカメラの視点を放送者に追従させる機能があります。

放送者が匿名で参加しているときにカメラで追従できてしまっては匿名の意味がないので、放送者追従機能のONOFFを上記の匿名情報で場合分けしていました。

 

非表示にする hide()

次は非常に今更で基礎的な書き方の紹介です。

エンティティを非表示にする際は hide() が使いやすいです。

 

これは公式のチュートリアルで紹介されていなかったので個人的にあまり使っていなかったのですが、少し前に公開された逆引きリファレンスの方に載っていました。

使ってみると実際使いやすかったので、紹介していきます。

 

基本的な使い方と効果

下のように書けばエンティティを非表示にできます。

 

     let entity = new g.Sprite({
         scene: scene, src: scene.assets["asset"], parent: scene, x: 0, y: 0,
     });
     entity.hide();

 

非表示にすると以下の効果が現れます。

  • 表示に映らなくなる opacityが0になるのと同等
  • クリックが無効になる touchableがfalseの状態と同等

g.Spriteだけでなくg.Eやg.Labelなど、エンティティであればたぶんどれでも使えます。

.hide()はその後に.modified()を加える必要はありません。それだけで機能します。

 

使い方の例

たとえば一度押したら消えるボタンに使えます。

     let button = new g.Sprite({
         scene: scene, src: scene.assets["button"], parent: scene, x: 200, y: 200,
         touchable: true,
     });
     button.onPointDown.add(function(ev) {
          button.hide();
     });

この場合onPointDownのイベントは起動することができないだけで残っています。

後で非表示を解除して再表示すればこのイベントも使えるようになります。

 

イベントがonUpdateの場合は、hide()しても変わらずイベントが動き続けます。

あくまでtouchableがfalseになっているのと同等の効果があるだけです。

 


他にも場面転換時に前の場面のエンティティを一つの親エンティティにまとめておいて、親エンティティをhide()して場面を切り替えるといった使い方もあります。

 

hide()のメリット

  • 書き方がシンプルになる

    touchableとopacityを使って書くと3行になってしまいます。

       entity.touchable = false;
       entity.opacity = 0;
       entity.modified();

    これが entity.hide(); の1行だけで済みます。

  • destroy()と比べてエラーになりにくい
    これまではdestroy()をよく使っていたのですが、すでにdestroyしたものを呼び出して操作しようするとエラーになってゲームが強制終了します
    個人的にこれが理由の不具合が多かったのですがこの心配が無くなります。

  • 描画の処理が軽くなる
    destroyでも同じですが、非表示にしていると描画の処理自体も省略されます。
    多数のエンティティ(数百や数千個)がある場合は、必要なもの以外は非表示にすると動作が軽くなります。
    opacityが0になっていれば同じように処理が省略されるかは…検証したことがないのでわかりません。

一応destroyの方のメリットも上げると、destoryしたときにentity.onUpdateなどのイベントも一緒に削除することができます。

hideの場合は、何らかのトリガーによってentity..onUpdateの中でreturn trueを処理する必要があります。

発射した弾丸を消去するときなど、あとから参照する可能性が低い場合は destroy() でもいいと思います。

 

再表示させる .show()

一度非表示にしたエンティティを再度表示させる場合は .show() を使います。

エンティティが表示されるようになりクリックなどができるようになります。

 

opacityが1になりtouchableがtrueになるのと同等ですが、実際にこの2つの値を変更するわけではありません。

もともとopacityが0.5の場合は、hide()をしてもshow()をしても0.5のままです。

touchableももともとfalseであればshow()をしてもfalseのままです。

 

「opacityとtouchable」の値と、「hide状態かshow状態か」は別々に存在していて、
touchableがtrue」 かつ 「showの状態」であればクリックができる感じです。

 

非表示かどうか確認する .visible()

現在hide状態なのかshow状態なのかは .visible() とすれば確認できます。

entity.visible()のように書きます。hideとshowと同じく後ろのカッコは必須です。

 

visibleは見ることが可能という意味なので、

hide()のあとなら.visible()はfalseshow()のあとなら.visible()はtrueです。

 

最初から非表示にする hidden:

エンティティを作る際に最初からhide状態にすることもできます。

hiddenという値にtrueを入れておきます。

     let entity = new g.Sprite({
         scene: scene, src: scene.assets["asset"], parent: scene, x: 0, y: 0,
         hidden: true
     });

 

エンティティを作ったあとにentity.hide()と書いても同じですが、hiddenにしたほうが書き方がコンパクトになっているような気がします。

 

ただし、hide状態かどうかを確認する際に entity.hidden の書き方は機能しません。

entity.visible()を使いましょう。

 

 

カメラで表示する場所を変える camera

アクションゲームを大人数のマルチで遊ぼうとすると画面が狭く感じます。

これを避けるために移動できるエリアを画面より広くし、各プレイヤーに自分のキャラ周辺だけを見せるようにします。

 

メリットはアクションに集中できるのでゲームとして面白くなるところです。

デメリットは全体を見れないので他のプレイヤーのプレイを見逃すところです。

 

方法は2つあります。

  • 自分のキャラクター以外の他人のキャラクターと背景を動かす
  • 自分のキャラクターを動かして見る場所(カメラ)を動かす

 

どちらの方法もできることには代わりありません。

 

前者はマルチゲームの場合、自分のプレイヤーをplayer1とすると、
player1.xやplayer1.yは常にg.game.width/2やg.game.height/2のままになります。

player1と背景の位置関係や、他のプレイヤーとの位置関係を別途管理していくことになり、あまり直感的ではないと思います

シングルゲームでプレイヤーが一人ならまだ使えます

 

後者の場合は、x = 100 y = 200 の位置にいるのであれば、
player1.x = 100 player1.y = 200 であり、客観的に捉えられてわかりやすいです。

 

カメラの宣言

カメラを使って見る場所を変えるには、以下の2文でカメラを宣言します。

        let camera = new g.Camera2D({ game: g.game, local: true });
        g.game.focusingCamera = camera;

 

一つだけパラメータを追加してあって、local: true にしてあります。

これはプレイヤーごとに見る場所を変えるためなのでマルチゲームではほぼ必須です。
シングルゲームだと不要ですが、書いてあっても特に不具合にはなりません。

 

ローカルのパラメータを入れられるものとしてエンティティがありましたが、
カメラはエンティティではありません。
エンティティ以外でローカルにできる数少ない例外がカメラです。

 

カメラの動かし方

カメラの宣言が終わったら必要に応じて camera.x camera.y の値を更新します。

常にプレイヤーを画面の中心に捉えるのであれば以下のような書き方です。


        let camera = new g.Camera2D({ game: g.game, local: true });
        g.game.focusingCamera = camera;
        scene.onUpdate.add(function () {
            camera.x = player1.x -g.game.width/2;
            camera.y = player1.y - g.game.height/2;
            camera.modified();
        });

 

--ここでちょっとマルチゲーム以外に脱線します。[2022/4/10]--

シングルゲームでカメラを使おうとするとテスト環境でカメラがうまく動きません。ゲーム中は問題なく動くのですが、akashic-sandboxではカメラが機能しないようです。

 

代替案としてシングルのテストをakashic serve -s nicoliveで行います

ただこの方法も軽く問題があって、起動時に以下のエラーが出ます。このエラーは左上のリセットボタンを押せば一定確率で消えます

  f:id:nicorakku:20220410181746p:plain

エラーの意味は不明ですが、出るときと出ないときがあります。

--脱線終わり。--

 

時間表示などのUIを追従させる

カメラは一点問題があって時間表示などのUIを追従させる必要があります。

画面上部に残り時間バーtimebarが有れば、timebar.y = 40 のように設定しますが、

カメラがcamera.y = 100 のように下に移動すれば見えなくなってしまいます。

 

追従の方法は3つあります。3つ目がおすすめです。
xの処理は省略してyの処理だけ記載しています。

  • 位置を計算して動かす
    カメラの位置と時間バーの元々の位置から動かすべき場所を計算します。
            scene.onUpdate.add(function () {
                camera.y = player1.y - g.game.height/2;
                camera.modified();
                timebar.y = camera.y + 40;
                timebar.modified();
            });
    カメラではなくプレイヤーの位置から時間バーの設定を計算してもいいですね。
    ただし追従させたいエンティティの数だけ設定が必要なので非現実的です。

  • 動かす量を同じにする
    カメラを動かす量を算出して、同じ量だけ時間バーを動かします。
            scene.onUpdate.add(function () {
                let movey = player1.y - g.game.height/2 - camera.y;
                camera.y += movey;
                camera.modified();
                timebar.y += movey;
                timebar.modified();
            });
    これも追従させる数だけ設定が必要です。

  • カメラと同じ位置に動かす親エンティティを作る(おすすめ)
    まず時間バーや様々なエンティティを乗せる親エンティティを作っておきます。

            let uilayer = new g.E({ scene: scene, parent: scene, local: true });

    追従させたいエンティティを作る際は parent: uilayer にしておきます。

    そして、カメラを動かす際にカメラと同じ位置にuilayerを動かします
            let camera = new g.Camera2D({ game: g.game, local: true });
            g.game.focusingCamera = camera;
            scene.onUpdate.add(function () {
                camera.y = player1.y - g.game.height/2;
                camera.modified();
                uilayer.y = camera.y;
                uilayer.modified();
            });
    この方法だとまとめて動かすことができます。

    初期状態のカメラと親エンティティは両方 x = 0, y = 0 で同じ位置にあるので、それを常に同じ位置に保てば良いわけです。
    timebar は uilayerの中の座標として timebar.y = 40 のままで残っています。
    普段と同じように位置も動かせます。

 

 

観戦するプレイヤーを変える

ゲームに参加してない視聴者やゲームオーバーになってしまったプレイヤーが、
他のプレイヤーを観戦する機能がよく搭載されています。

 

やり方としては観戦にするプレイヤーのIDを変えられるようにしておいて、
画面をクリックしたときにIDを切り替えていきます。

 

感染するプレイヤーを変更できるようにする

財宝堀りの方のコードの986行目のあたりに以下の処理があります。

        let camera = new g.Camera2D({ game: g.game, local: true });
        g.game.focusingCamera = camera;

        let cameraid = null; //フォーカスするプレイヤー
        // カメラ更新
        scene.onUpdate.add(function () {
            if (cameraid == null) return;
            if (player[cameraid] == null) return;
            let x = (player[cameraid].box.x - g.game.width/2);
            let y = (player[cameraid].box.y - g.game.height/2);
            camera.x = x;
            camera.y = y;
            camera.modified();
            uilayer.x = x;
            uilayer.y = y;
            uilayer.modified();
            buttonlayer.x = x;
            buttonlayer.y = y;
            buttonlayer.modified();
                            ︙

        });

ゲーム開始時などのプレイヤーがまだ誰もいないときは  if (cameraid == null) return;のところで処理が中断され、カメラも動きません。

 

自分のプレイヤーが追加された時

自分のプレイヤーが追加されたときは cameraidに自分のidを入れます
1119行目にあります。

 

ゲームが開始した時

自分のプレイヤーが追加されたときは 適当にプレイヤーのidを入れます
780行目にあります。

        if (cameraid == null) cameraid = Object.keys(player)[0];

cameraidにすでに自分のidが入っている場合は変更しないようになっています。

 

Object.keys(player)で参加済みのプレイヤーのidをリスト化しています。

このゲームでは参加人数が0人では始まらないようになっているので[0]なら何がしかのidが入っているはずで、これを観戦するidにします。

 

もし0人でも始まるようなゲームなら

         if (Object.keys(player).length == 0) return;

などを直前に入れて処理を中断します。 

 

画面をクリックしたらプレイヤーを変更する

1190行目touch.onPointDown次のプレイヤーを選ぶ処理があります。

 

        touch.onPointDown.add(function(ev) { //操作押し込み
                           ︙
            if (cameraid == null) return;
            if (cameraid == g.game.selfId) return;
            let list = Object.keys(player);
            let index = list.indexOf(cameraid);
            cameraid = list[(index+1)%list.length];
        });

 

自分のidがcameraidに入っている場合は処理しません。

こちらもまずidをリスト化して、list = Object.keys(player) にしています。

そして現在のcameraidがlistの何番目を確認しています。index = list.indexOf(cameraid)

最後に次のidの順番(index+1)を特定し、listの最後だったことを考慮して(index+1)%list.length 番目のidをcameraidにしています。

 

画面外を非表示にする

エリアを広げると多数のエンティティを配置したくなります。

財宝探しの場合は掘るごとに穴の画像が増えるので、20人で15回掘れば300個のエンティティが追加されます。人数が増えれば更に増えます。
ぴょんぴょんクライマーは250個ぐらいの足場があります。

 

この多数のエンティティがあると体感でわかるレベルで重くなります。

 

しかも画面外にあって見えていないエンティティにもかかわらず描画に時間がかかるので、かなりの無駄が生まれます。

 

数十人以上に人数を想定しているゲームの場合は、画面外にあるエンティティは非表示にしたほうが良いです。

 

ここで大きく分けて2つの非表示方法があります。

  • 画面外のエンティティを自動的に描画しない
  • 画面外のエンティティを一つ一つhide()する

 

画面外のエンティティを自動的に描画しない

この方法は公式のこちらにあるのですが… 使い方がわかりません!

説明を抜粋すると、


「例として、以下のようなメソッドを g.E の派生クラスに定義する(オーバーライド)ことで、その派生クラスのエンティティの画面外での描画を省略することができます。」

メソッド…? オーバーライド…? これはmain.jsに書けばいいんでしょうか???

 

多分プログラミングをやってる方には簡単なことなんでしょうが、私にはさっぱりわかりません。わかってる方がやり方を公開してくれるのを待ちましょう。

もしくは、どうしても知りたかったら公式に質問してみるといいです。

 

画面外のエンティティを一つ一つhide()する

というわけで私はこちらでやっています。

ぴょんぴょんクライマーはちょっとごちゃごちゃしているので、
財宝掘りの方で説明します。

 

画面外に行ったときに非表示にしているのは、
プレイヤーキャラクター穴画像の2つがメインです。

 

プレイヤーキャラクターは移動させるための処理が常に行われています。

1048行目の box.onUpdate が常に処理される箇所です。

ここに、カメラの位置camera.x, camera.yとキャラクターの位置box.x, box.yから計算して表示非表示を決める記載を追加しています。

if (Math.abs(box.x - camera.x - g.game.width/2)< 700 && Math.abs(box.y - camera.y - g.game.height/2)< 500) {
    box.show();
} else {
    box.hide();
}

 

一方の穴画像の方は、こちらも同じようにonUpdateがあれば良かったのですが、
新たにonUpdateを大量に用意するのは重くなりそうです。

 

代わりの方法として以下の手順をやりました。

  1. マップ全体の縦横を2~8分割した場所にfieldというエンティティを置く 936行目
  2. hole画像をそれぞれの場所のfieldを親エンティティにして配置する 1416行目
  3. カメラ位置に応じて遠くのfieldを非表示にする 1001行目

穴画像エンティティの座標を一つ一つ計算するのは重いのではと思いこの方法にしています。これがベストかどうかは全くわかりませんが、少なくとも非表示にすることでかなり軽くなってるのは確かです。

 

プレイ人数を20ぐらいまでに絞っていればあまり気にしなくてもいいのですが、
大人数プレイを視野に入れている場合は、Chrome等の開発者ツールを使いながら
処理の重さを確認していくといいと思います。

 

ゲーム展開がズレるエラーについて

マルチゲームを作っているとプレイヤーごとにゲームの展開が異なる不具合がよく発生します。

財宝掘りでも発生しているのですがまだ対策できていません

 

発生する原因としてはこれまでの経験から以下のものがあります。

  • ローカル処理でグローバルのエンティティや情報を操作する
  • ローカル処理でグローバルエンティティを作る
  • ローカル処理でg.game.randomを使う
  • グローバル処理でローカルの情報をもとに処理を変える
  • 物理エンジンで複雑な現象を起こす

 

今回の財宝掘りは上記の点に十分注意していたものの、バグはなくなっていません。

プレイヤーが同じタイミングで掘り当てたときの所有権に違いが出るのかなと思い、結果が収束するように変更したものの効果はありませんでした。

現時点ではちょっとお手上げ状態です。

 

 

 

今回はここまです。

 

これまでマルチの記事を続けてきましたが、前述の通りバグが中々なくなりません。
バグ出しが終われば安定するかなと思ったんですが、毎回違うバグが出てきます。

ゲームが変わるとキャラクターのアニメーションの有無や動きが変わるのもあり、テンプレどころではないですね。

一応記事に載せているそれぞれの説明は特に間違っていないと思います。たぶん。

 

あと私のマルチのコードは他の方と比べて動作が重めで転用するには不向きかもしれません。PCなら問題ありませんが、スペックが低めのスマホでは結構重いです。

そもそもニコ生ゲームにおいてどれぐらいの旧機種のスマホまで対応させるべきなのかは難しいところですが、ニコ生アプリを使って起動すると結構重くなります。

 

サバイバルゲームや、バトロワ、チーム戦あたりもやりたいんですが、ちょっと時間をおいてまた挑戦したいと思います。そのときにコードの公開だけはやるつもりです。

公式のニコニコスネークを転用しやすいように使い込んで記事にする、という方向性も検討すべきかもしれませんね。

 

 

次からは気分を変えて情報が少ない物理ゲームをやろうと思います。

 

この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。

ニコ生ゲームを作ろうと思ったらやっぱりマルチ! その3


第3回は前回のアクションマルチゲームの動きを変えたものをやっていきます。
追加で気をつけるところを紹介します。

サンプルとして下のものを作りました。音は抜いています。

・キャノンウォーズ ファイル アツマール 

・ダックジャンプ ファイル アツマール 

[2021/10/8追記]バグ修正しファイルを更新しました


■目次

  • ジャンプ
  • 当たり判定
  • レーザーの当たり判定と表示
  • 分割画像の外周映り込み
  • 効果音の重複再生防止
  • 生ゲームプレイ中ページへの掲出
  • 開発者ツールによるエラー箇所の特定
  • アツマールマルチ対応 [2021/6/21追記]

 



ジャンプ

ダックジャンプではジャンプをすることで障害物を飛び越えますが、
道路のどこに立っているか画像の位置はずれることになります。
それを考慮したy座標の管理が必要です。

ジャンプによって一時的に画像のy座標は上に移動しますが、道路の奥の方に移動したわけではないので、道路のどこの位置に立っているかは別の数値を用意します。

この道路のどこの位置に立っているかを body.tag.y にしました。
画像の位置は body.y ですね。
ジャンプの高さも用意して body.tag.jump にしました。

body.tag.yはジャンプ中は移動分しか変えず、715行目で影をこの位置においています。
body.tag.jump642行目で経過時間body.tag.frameで放物線になるように計算して、
686行目で道路の位置とジャンプの高さからbody.yを決めて操作しています。

if (body.tag.state == "jump" || body.tag.state == "sjump"){
  if (body.tag.frame < jumpframe) { //ジャンプ時間のカウント
    body.tag.jump = ( (jumpframe/2) **2 - (body.tag.frame - jumpframe/2) **2)*playerjump;
  } else { //終われば停止状態に戻す
    body.tag.jump = 0;
    body.tag.state = "stop";

      ︙

}
body.y = body.tag.y - body.tag.jump; //足場の位置からジャンプの高さ分上にずらして表示する

 

当たり判定

今回の2ゲームでは円形と四角形の別々の当たり判定を使用しました。


キャノンウォーズ

弾もキャラクターも丸い当たり判定を想定して、以下のような式で計算しています。

 hit = ( (shot.x-player[hitid].body.x)**2 + (shot.y-player[hitid].body.y)**2) < (size/2 + playersize/2)**2;

shotとbodyの距離をx座標y座標から算出し、それぞれの当たり判定の大きさsizeの合計と比較しています。二乗で比較していて、平方根の計算は不要なのでやってません。

この方法だと細長い弾はうまくいきません。レーザーは後述する方法で計算していますが、リップルショットは外見とは違ってただの円形にしてごまかしています。

 

ダックジャンプ
障害物に四角い箱や細長いものがあるので、四角い当たり判定にしています。

if (Math.abs(enemy.x - player[id].body.x) < (sizex/2 + playersizex/2)){
if (Math.abs(enemy.y + sizeh/2 - player[id].body.tag.y - playersizeh/2) <= (sizey/2 + playersizey/2 + 4)){


細かい補正が入っていますが、プレイヤーと障害物のx座標y座標の差を各々計算しています。
当たり判定の大きさにもx方向の大きさsizexと、y方向の大きさsizeyがあります。



その他の方法
当たり判定については最近こちらで公開された逆引きリファレンスにシンプルな記載方法がのっていました。今回のサンプルコードではまだ使っていませんが、紹介しておきます。

3種類あるようです。

エンティティが回転や拡大をする場合

   g.Collision.intersectEntities(entity1, entity2)

 当たり判定の大きさはエンティティの大きさになります。
 画像を使う場合は見た目と当たり判定が合わなくなりそうです。
 当たり判定用のg.Rectとそれに追随する画像を使うと良いと思います。

 

エンティティが回転や拡大をしない場合

   g.Collision.intersectAreas(entity1, entity2)

 処理が軽量になるので、大量のエンティティにはこちらが推奨されています。
 ただし条件として、エンティティ同士が同じ親を保つ必要があります。

 あと、回転だけでなくanchorX,anchorYを0以外にしても位置がずれるようです。
 自分は0.5で統一してるので、ちょっと相性が悪いですね。
 entity1とentity2の大きさが同じだと影響が出なくて問題ないのですが。

 

円形の当たり判定でいい場合

   g.Collision.withinAreas(entity1, entity2, dist)

 こちらがさらに軽いそうです。distにはエンティティ同士の距離のしきい値を入力します。
 大量に弾をばらまくような場合はこちらがいいでしょう。

 当たり判定と画像の中心が合うように、anchorXとanchorYの調整が必要かもしれません。


使い方

 これらの文字列が接触していれば true接触していなければfalseになるので、

   if (g.Collision.withinAreas(entity1, entity2, dist)) { … }

 または、

   let hit = g.Collision.withinAreas(entity1, entity2, dist);
   if (hit) { … }

 のようにして使いましょう。

 

レーザーの当たり判定と表示

レーザーの当たり判定は非常に細長くなっています

一般的にゲーム業界ではどうやって計算するのか知りませんが、以下の4つを考えました。

  1. レーザーの先端に丸い当たり判定を作る
  2. 直線状に丸い当たり判定をたくさん配置する
  3. g.Collision.intersectEntitiesをつかう
  4. 直線からどれくらい離れているかと直線上の位置を計算する


1つ目
は問題があって、レーザーの速度が早すぎてプレイヤーを飛び越す恐れがあります。
レーザーは1フレームあたり100ピクセル進むので、これより小さいものには当たらない可能性が出てきます。

今回別途用意したバウンドショットは壁に当たるときに加速するようになっていますが、最大速度でもプレイヤーを飛び越さないように調整しています。

 

2つ目方式としては可能だと思いますが今回は採用しませんでした。
しかし、今回のレーザーは細めなので細かく当たり判定を配置する必要があり、処理が重くなる可能性があります。太いレーザーや弾が連なって見えるようなビームならうまくいくかもしれません。

 

3つ目が一番簡単にできたかもしれません。

四角の形を伸ばしていくことで細長い当たり判定を配置できたはずです。
ただg.Collision.intersectEntities()の場合、処理の重さがどうなるか気になりますね。

 

最終的に4つ目を採用しましたが、中高の数学のような座標変換をやりました。

与えられる変数は以下の5つです。

  • プレイヤーの座標 body.xbody.y
  • レーザーの先端の弾の座標 shot.xshot.y
  • レーザーの方向 angle

これらから、原点からの距離 dist と レーザー上の位置 pos に変換します。
略称は勝手につけてますのでゲーム的にも数学的にも一般的ではないはずです。

822行目から計算しています。

let laserd;
let laserp;
let inip;
if (type == 8) laserd = shot.x * Math.sin(angle/180*Math.PI) - shot.y * Math.cos(angle/180*Math.PI);
if (type == 8) laserp = shot.x * Math.cos(angle/180*Math.PI) + shot.y * Math.sin(angle/180*Math.PI);
if (type == 8) inip = x * Math.cos(angle/180*Math.PI) + y * Math.sin(angle/180*Math.PI);
Object.keys(player).forEach( hitid => { //全プレイヤーが攻撃が当たる状態にあるかチェック
 if (player[hitid].body.tag.state != "hit") {
  if (player[hitid].body.tag.mutekiframe < 0){
   let hit = false;
   if (type != 8){
    hit = ( (shot.x-player[hitid].body.x)**2 + (shot.y-player[hitid].body.y)**2) < (size/2 + playersize/2)**2;
   } else {
    let dist = player[hitid].body.x * Math.sin(angle/180*Math.PI) - player[hitid].body.y *Math.cos(angle/180*Math.PI);
    let online = (Math.abs(laserd-dist)) < (size/2 + playersize/2);
    if (online){
     let pos = player[hitid].body.x * Math.cos(angle/180*Math.PI) + player[hitid].body.y * Math.sin(angle/180*Math.PI);
     hit = (Math.abs(pos - (laserp+inip)/2)) < (Math.abs(laserp-inip)/2 - (size + playersize*(frame > 3)));
    }
   }


distとposがわかったら、839行目からそれぞれ適正範囲に入っているか判定しています。

角度は右回りで大きくなるのか、レーザー位置はどっちがプラスなのか、といったことはあまり気にせずにまずプログラムを書いています。
角度の数値などを一時的にg.labelで表示するようにし、動作させながら調整しています。

 

分割画像の外周映り込み

レーザーの外見には、先端根本本体の3つの画像を配置しています。
819行目本体のX倍率を変えて伸びているように見せています

このときにちょっと問題があったのが、画像の分割の仕方でした。
最初は以下のような画像を用意して、先端・根本・本体に分割して配置していました。




しかしこれだと本体と根本・先端の境目に透明な線ができてうまくいきませんでした。

これは公式のこちらでも説明されていますが、分割画像を拡大すると隣の領域の画像が混ざるためです。

公式のケースでは画像が境界線ギリギリまで存在しているため、隣の画像を拡大したときに写り込んでしまうケースです。謎の線が写り込んでいるキャラクターはたまに見かけますね。

今回の場合は一番右の本体の画像を拡大すると一つ左のエリアの透明が混ざっていました。


対処方法として両サイドに同じ画像を配置して、真ん中の画像を使うことにしました。
画像を縦に並べ直すか、最初から別々の画像にしても良かったかもしれません。




効果音の重複再生防止

最初参加していなかったゲームにあとからクリックして参加しようとすると、
爆音がなってびっくりすることがあります。

これはChromeブラウザ特有の症状ですが、割とユーザーが多いブラウザです。
結構前から気になっていたものの、対処法がわからなくて放置していました。

今回、音を鳴らす直前に音を止めると抑えられそうなことがわかりました。
公式のニコニコスネークでも似たような処理がありました。

 scene.assets["se_bound"].stop();
 if (soundstate) scene.assets["se_bound"].play().changeVolume(id == g.game.selfId ? 0.6 : 0.24);

 

通常であれば音と音の間隔が十分あるので無駄な処理ですが、
Chromeの場合はゲームの起動から最初にクリックしたときまでの音が一瞬で鳴ります

上記のように毎回止めてから鳴らすようにすれば実質1回しかならないようにできます。

ゲーム開始のSEなどの1回しかならないものは不要ですが、射撃音や破壊音などの複数回なるものは止めたほうが良いでしょう。
マルチゲームの場合、プレイ時間が長いので特に蓄積されます。

 

この方法の欠点は音を重ねて派手な演出にすることができないところですね。
例えば爆発が2~3重程度に重なったときに音が大きくなるのは本来ならありのはずです。

アセットを複数用意して順番に鳴らすようにすれば重なる最大数を決められそうですが、ちょっと面倒ですね。

 

生ゲームプレイ中ページへの掲出

ニコ生ゲームのモードはランキング”ranking”とマルチ”multi”がありましたが、
新しく”multi_admission”が追加されました。

game.jsonsupportedModes"multi_admission”にすると、こちらのページに掲載されるようになります。

 

こちらの公式の説明では、人数が足りなくて人を募集しているときに、
上の目立つところに放送が掲載されますと書いてあります。
ランキングゲームや公式のマルチではすでにあった機能ですね。

人数が足りている場合は自動でゲームが起動するようなので、
放送者の操作が増えるというデメリットは少なそうです。


また、生ゲームプレイ中のページの下側の「ニコ生ゲームをプレイ中の番組」にも掲載される様になっています。

もしかしたら”multi”のままでも掲載される仕様になったのかもしれませんが、
以前は自分のマルチゲームがどこでプレイされてるかわからず、動作確認に苦労しました。
ゲームを楽しむのに人数が必要なマルチだからこそ掲載したほうがいいでしょう。
ただ、途中参加できないゲームだとあとから来ても見てるだけになるのは難しいところです。

一方でネタ貼り付けシステムの「協力・対戦」の絞り込みには入らなくなっているようです。
そのうち改善されることを期待しましょう。

 

開発者ツールによるエラー箇所の特定

テスト動作中にプログラムに問題があると右下にエラーメッセージが出ます。



このメッセージだけで問題がわかるときもありますが、F12で開発者ツールを起動して詳細を確認することができます。

chromeだと下のような画面が出てきますが、下の欄にエラーがある場所が出てきます。
出ていない場合はConsoleのタブを探したり下の欄が格納されていないか確認してください。
更に、例えば main.js : 1229 をクリックすると問題のあるプログラムの行を上側に表示してくれます。




このケースでは表示されたこの行ではなく、realtimerankの定義をするときに、
let realtimerank = []; とすべき所を let realtimerank; にしたためエラーになっています。
問題の行そのものではありませんが、realtimerankの行であることがヒントになります。

残念ながらよくわからない行しか出ないこともありますが、7割ぐらいは役に立つ印象です。

エラーのせいで表示が重くなっている時は、左上の一時停止ボタンで一旦止めましょう。

[2022/12/20加筆]
以下の記事は、アツマールがサービス提供中の時点の内容です。
アツマールがサービス終了したあとの手順はこの一連の記事では説明していません。
別途、公式チュートリアルや解説記事を探してください。


アツマールマルチ対応

[2021/6/21追記]
つい先日、ゲームアツマールのマルチゲーム投稿が全体へ解禁されました。
公式の説明はこちら

やり方は簡単で、ゲームアツマールのマルチゲームとして投稿する場合は、
game.json"envinronment" に以下の文字を追加しましょう。

 "atsumaru": {
  "supportedModes": [
   "multi"
  ]
 }


他の文字と合わせると下のような形になるかもしれません。

 "environment": {
  "sandbox-runtime": "3",
  "atsumaru": {
   "supportedModes": [
    "multi"
   ]
  },
  "niconico": {
   "supportedModes": [
    "multi_admission"
   ]
  },
  "external": {
   "coeLimited": "0",
   "atsumaru": "0"
  }
 },

"envinronment"の中に"sandbox-runtime""atsumaru""niconico""external" の4つがある形ですね。それぞれがカンマ,で区切られているかどうかも気をつけましょう。



名前取得の拡張機能もアツマールで使えますが、確認画面無しで自動で承認されます。

私の場合は拡張機能のバージョンが古くてつまづきました。
バージョンが古いとアツマールマルチでアカウント名が登録されず、ランダムな名前で登録されてしまいます。
うまく行かない方は拡張機能を更新しましょう。
上の文字の"external"に"atsumaru"の値がないと怪しいです。


アツマールにおけるg.game.selfId

一点だけ、生放送とアツマールマルチで仕様が異なるところがありました。

プレイヤーのIDとして使うg.game.selfIdです。
公式の情報はなさそうなのですが、自分で確認したところ以下のような違いがあります。

  • 生放送の放送者 : ニコニコのアカウントID 1~100000000ぐらいの整数(の文字列)
  • 生放送の参加者 : ニコニコのアカウントID 1~100000000ぐらいの整数(の文字列)
  • アツマールマルチのホスト : "owner"という文字列
  • アツマールマルチの参加者 : 0~1のランダムな少数
  • アツマールシングル : 9999


異彩を放っているのが、"owner"という文字列ですね。
もし、ID番号を数字として扱ってプレイヤーの情報に使っている場合は、ちゃんと"owner"の場合も考慮しましょう。

放送で数字として扱う場合はNumber(g.game.selfId)と書かないとだめな気がします。
でもアツマールマルチだとそうでもないような…ちょっと挙動がよくわかりません。
多分プログラミング的には基礎的なことなんだと思いますが、個人的にはよくわからないので深く説明しません。できません。

アツマールマルチ参加者の0~1のランダムな数字というのも、g.game.random.generate()の事例を考えると、0以上1未満の数字ではないかと思います。

このため、アツマールマルチなのか生放送なのかを見分けるのには、
g.game.selfId == "owner" と Number(g.game.selfId < 1) でできそうです。


 



今回の説明は以上です。

似たようなシステムのゲームはこの記事に追加していきます。

マルチのゲームもテンプレとして改造しやすくしたいと思っていたのですが、
キャラクターのアニメーション有無や退場するタイミングなどの細かい仕様の違いあって、なかなかそのまま使えなさそうです。
その調整方法を説明できればいいんですが、変更箇所が色んな所に散らばっていて大変なので諦めました。

もちろん画像や数値を変えるだけで改造になりそうならやってもらって構いません。


あと、akashicのマルチの考え方について、記事を作成された方がいました。
こちらです。私の素人説明とはちがい安心感のある文章です。


この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。

ニコ生ゲームを作ろうと思ったらやっぱりマルチ! その2


第2回は全員のキャラクターがスコアを競うゲームです。
公式の拡張機能を利用して名前を取得し、キャラクターに名前を表示させます。

サンプルとしてこちらを作りました。音は抜いています。
[2021/6/20追記]アツマールマルチ向けにファイル更新しました

[2021/10/8]名前取得の記載に重大なバグが有ることがわかったため修正しました

アツマールはこちら。絵はこちら

■目次

  • 拡張機能の利用
  • 名前取得の拡張機能
  • 放送者専用のボタンを用意する
  • 監視処理を中止する
  • 参加方式のバリエーション
  • いつでも参加できるようにする
  • 参加者ごとのキャラクターの作成する
  • キャラクターにパーツを追加する
  • キャラクターに名前をつける
  • 参加者のリストを作る
  • アイテムとの接触
  • キャラクター同士の接触
  • リスタート処理
  • グローバルとローカルの確認
  • 状態遷移時のraiseEventの確認

 




拡張機能の利用

マルチゲームに必要な情報というとやはりプレイヤーの名前でしょう。
対戦相手や協力相手が誰なのか、良いプレイをしたのが誰なのか、が伝わるのは重要です。

以前はユーザーの名前を取得できなかったので、ゲーム内キーボードで入力でしたが、
現在はユーザーの名前を取得する拡張機能こちらで紹介されています。


拡張機能のインストール

ここで拡張機能というのは、標準では搭載されていない便利な機能のことです。
公式のTOPの下の方にも物理や3Dなどのいろいろな拡張ライブラリが紹介されています。

各のページで説明がありますが、拡張機能コマンドプロンプトから使用します。
akashic initや既存ゲームのコピーでファイルが揃ったフォルダで、以下を入力します。

  akashic install @akashic-extension/resolve-player-info

これによって、node_modules というフォルダが増え、game.jsonも書き換えられます。


拡張機能の利用

インストールした拡張機能は、main.js などで以下のように宣言します。

let resolvePlayerInfo = require("@akashic-extension/resolve-player-info").resolvePlayerInfo;

公式の説明では2行だったりletではなくvarだったりしますが、これでも大丈夫のはずです。

場所は比較的上の方で function main(param) {…} の外に置いています。
今回のサンプルコードでは82行目です。


名前取得の拡張機能

 

この項目に記載した処理にはゲームが強制終了するバグが有りました

詳細な説明は新しい記事でやりますので、修正した処理だけ先に載せておきます。

ソースコードはすでに更新してあります。247行目からに相当します。

ー修正後ここからー

 gamejoin.onPointDown.add(function(ev) {
            if (!g.game.isSkipping) {
                resolvePlayerInfo({limitSeconds: 15}, (err, info) => {
                    if(!info) return;
                    if(!info.name) return;
                    g.game.raiseEvent(new g.MessageEvent({ msg : "join", id : ev.player.id, name : info.name, eye : localeye, color : localcolor }));
                });
            }
        });

ー修正後ここまでー


確認画面の呼び出し

使うのは簡単で、まず resolvePlayerInfo({ raises: true }); と記述します。

サンプルコードでは247行目にあります。

  gamejoin.onPointDown.add(function(ev) {
    if (!g.game.isSkipping) resolvePlayerInfo({ raises: true });
  });

これによりgamejoinというボタンを押したときに、匿名にするかの確認画面が出ます。
gamejoinはローカルエンティティにしているので、押した人にだけでます。

resolvePlayerInfoをローカルに置くべきか、グローバルに置くべきかですが、
公式のコードだとグローバルに置いているのでどちらでもいいように思います。


確認画面応答後の処理

そして次に g.game.onPlayerInfo のイベントを配置します。
これはユーザーが確認画面に応答したあとに処理される内容です。

こちらはとりあえず250行目resolvePlayerInfoの近くに置きました。

そしてこの中では、ev.player.nameユーザーの名前またはゲスト名になります。

例えば、ユーザーのIDと名前をひもづけた以下のような名前リストを作りたい場合、

 let namelist = {
  1000001 : "1人目の名前",
  1000002 : "2人目の名前",
  1000003 : "3人目の名前",
 }


g.game.onPlayerInfoの中にこのように書きます。

 g.game.onPlayerInfo.add(function(ev) {
  namelist[ev.player.id] = ev.player.name;
 });

この中はグローバル処理になります。


プレイヤーの情報を追加して共有する

今回のサンプルでは、プレイヤーキャラクターの目と色を設定するようになっています。
初回なのでシンプルにするべきでしたが、名前送信と同時の上記の情報も送ります

247行目から以下のように書いています。

 gamejoin.onPointDown.add(function(ev) {
  if (!g.game.isSkipping) resolvePlayerInfo({ raises: true });
 });
 g.game.onPlayerInfo.add(function(ev) {
  if (player[ev.player.id] == null && !g.game.isSkipping && !finishstate){
   if (ev.player.id == g.game.selfId){
    g.game.raiseEvent(new g.MessageEvent({ msg : "join", id : ev.player.id, name : ev.player.name, eye : localeye, color : localcolor }));
   }
  }
 });
 scene.onMessage.add( (msg) => {
  if (msg.data.msg === "join") {
   if (player[msg.data.id] == null){
    makeplayer(msg.data.id, msg.data.name, msg.data.color, msg.data.eye);
    if (msg.data.id == g.game.selfId){
     gamejoin.destroy();
     if (!startstate){
      logo.destroy();
      joinnumlabel.y += 100;
      joinnumlabel.modified();
     } else {
      settinglayer.destroy();
     }
    }
   }
  }
 });

ev.player.id == g.game.selfId で一旦ローカル処理にし、
g.game.raiseEventでグローバルにID、名前、目、色の情報を共有しています。

厳密には ev.player.id == g.game.selfId はローカル処理ではないので注意点もあります。
以下で説明しますが、ややこしいと思ったらシンプルにするか、
公式か既存のコードをそのまま使ってください。

IDが全く同じ場合、例えば「リロードした時」と
「同じアカウントから2デバイスを使っている時」は処理が重複してしまいます。

このためすでに、名前が登録されている場合は処理を実施しないように
g.game.onPlayerInfo の中にplayer[ev.player.id] == null という条件を追加しています。

しかしこれだけでも不十分で、今回はシーンを再読込してリスタートする機能があります。
1回目のゲームに参加したプレイヤーが、リスタートして2回目のゲームで観戦する際、
誤ってってリロードすると、ゲームの追いかけ再生の中で読み込んだ1回目の参加申請が
2回目のゲームに反映されてしまう現象が起きました。
このため、!g.game.isSkipping の条件も追加して、スキップ中の処理を制限しています。

resolvePlayerInfo({ raises: true });raises: trueを消せばいいのかなとも思いますが、
公式の使用例はなく、検証もできてないのでよくわかりません。


放送者専用のボタンを用意する

ゲームの開始を放送者だけができる放送者専用のボタンを配置するため、
前回と同様101行目でstreameridに放送者のIDを格納しています。

 let streamerid;
 g.game.onJoin.add(function(ev) {
  streamerid = ev.player.id;
 });

あとはscene.loadの下に以下のようにゲーム開始ボタンを配置すれば良さそうです。

 if (streamerid == g.game.selfId){
  let gamestart = new g.Sprite({
   scene: scene, src: scene.assets["gamestart"], parent: warmuplayer, local: true,
   x: g.game.width*0.83, y: g.game.height-80,
   anchorX: 0.5, anchorY: 0.5, opacity: 0.3, touchable: false,
  });
  gamestart.onPointDown.add(function(ev) {
   g.game.raiseEvent(new g.MessageEvent({ msg : "start" }));
  });
 }

しかしこれだとうまくいきません。放送者が放送者でないとされてボタンが配置されません。

どうもscene.loadg.game.onJoinの処理順があるようで、onJoinが後になります。

このためload時に放送者だけ表示を変えることは何らかの回避方法が必要です。
316行目のように放送者IDが決定したかを監視し、決定していればボタンを配置します。

 scene.onUpdate.add(function () {//joinした後なら処理する 1度処理したら破壊
  if (streamerid != undefined || isAtsumaru){
   if (streamerid == g.game.selfId || isAtsumaru){
    let gamestart = new g.Sprite({
     scene: scene, src: scene.assets["gamestart"], parent: warmuplayer, local: true,
     x: g.game.width*0.83, y: g.game.height-80,
     anchorX: 0.5, anchorY: 0.5, opacity: 0.3, touchable: false,
    });
    gamestart.onPointDown.add(function(ev) {
     g.game.raiseEvent(new g.MessageEvent({ msg : "start" }));
    });
    let startjoinlabel = new g.Label({
     scene: scene, text: "開始後も参加できます", parent: warmuplayer, local: true,
     font: font, fontSize: 30,
     x: g.game.width*0.83, y: g.game.height-180,
     anchorX: 0.5, anchorY: 0.5, opacity: 1,
    });
    gamestart.onUpdate.add(function () {
     if (Object.keys(player).length > 0) {
      gamestart.opacity = 1;
      gamestart.touchable = true;
      gamestart.modified();
      return true;
     }
    });
   } else {
    countlabel.text = "開始待ち中…";
    countlabel.invalidate();
   }
   return true;
  }
 });

streamerid != undefined でstreameridが決定しているか判断してから処理しています。


よりシンプルな方法としてはg.game.onJoinの処理の中で放送者IDを決定した後、
そのままボタンを配置することもできます。

しかしこの方法だとゲーム起動直後しか処理されません。
今回のゲームではシーン切り替えでゲームをリスタートし、何度も遊べるようにしています。
リスタート時にg.game.onJoinは処理されないので、上記の方法をとっています。


監視処理を中止する

上記のボタンを配置する処理は1度きりで十分です。
scene.onUpdateなどの常に処理するイベントだと無限にボタンが配置されてしまいます。

一度処理したら以後処理しないようにするには変数を操作して判断していたのですが、
より簡単な方法がありました。

一通り処理が終わったら return true;と入力すればそのイベントは解除されます。

これはかなり基礎的な方法のはずですが、公式のチュートリアルに載っていない気がします。初期設定で生成されるサンプルコードに注釈付きで記載されていました。


参加方式のバリエーション

ゲームに参加する方式はいくつかバリエーションがあります。

  • いつでも参加できる (ボスネコ)
  • 放送者が参加募集開始ボタンを押す(よくある方式)
  • 放送者が参加を締め切る(よくある方式)
  • 参加後に抽選で出番が回ってくる (カーリング、タワー、お絵かきしりとり)
  • 参加を締め切ったあとに抽選をする(だるまとボスネコ以外でよくある方式)


今回のサンプルはいつでも参加できる方式でやっています。
本当は参加は締め切ったほうが色々と楽なのですが、最近はじまったアツマールのマルチが
チャットがなくて人が集まるまで待つのが辛い仕様だったので
いつでも参加できる方式を検討する試みです。

放送者が募集開始ボタンを押す仕様は割とよくありますが、
私は一手間を要求するのが嫌なので基本的に採用してません
ただ、ゲームの読み込みが安定してから募集したほうがなんとなくプレイヤーリスト作成が
うまく行きそうな気もします。適宜検討してみてください。

全員参加したあとに抽選で出番があるタイプは、待ち時間が長いのがネックです。
全員が固唾を飲んで何が起こるか見守るようなゲームなら向いてますね。

最初に抽選するかどうかはゲームのルール上大人数でも遊べるか、
大人数によるラグを許容できるかだと思います。

ニコ生では人数が多い放送では100~300人でプレイされることがあります。
記録によると1000人で餃子の皮サバイバルが遊ばれたこともあるようです。
単純なプレイの面白さで行くと少人数のほうが面白い傾向がありますが、
私は放送コンテンツとして割と大人数にこだわる方です。

が、大人数でキャラクターを完全に自由に動かすのはまだ達成したことがありません

  • 餃子の皮サバイバル 30人ぐらいからきつい。100人だと1秒のラグもありえる
        ラグで序盤に大量に退場してもらい、残ったプレイヤーに快適に遊んでもらう
  • サスオブ2 大人数だと出番を5分割して同時プレイ数を減らしている
            動きが遅いのでラグに気づかれにくい
  • エアリアルサッカー ジャンプする瞬間しかraiseEventしない
                物理が重いので100人制限あり
  • ドアラッシュ ダッシュする瞬間しかraiseEventしない 序盤に大量退場してもらう

100人以上でゲームの終わりまで自由にキャラクターを動かしてもらうのは、
まだ挑戦したことすらありません。

今回のサンプルゲームは人数無制限で最後までプレイできますが、
攻撃を受けると短時間退場してもらいます。
その間に同時プレイ数が減るのが狙いで、大人数では復活時間を20秒に長くしています。

また、公式のニコニコスネークでも採用されていたraiseEventを減らす方法を採用しました。
880,919行目の通り、移動方向を5度刻みで四捨五入し同じ方向ならraiseEventしません

これらの方式で何人まで行けるか確認したいところですが、
ゲームがあんまり面白くないみたいでそもそもプレイされないかもしれません。


いつでも参加できるようにする

話を少し戻して、いつでも参加できるようにしていく方法についてです。

といっても参加ボタンを残して、いつプレイヤーが増えても大丈夫にするだけです。
あとは、プレイヤーキャラクターの設定をしてから参加できるようにし、
観戦する人が参加ボタンを消せるようにします。

参加ボタン

参加ボタンを押すといつでもキャラクターが生成され、プレイヤーリストにも登録されます。
ゲーム開始前のキャラクターはアイテムが出てこないだけでゲーム中のものと同じ状態です。

参加ボタンは押したときに破壊するようになっていますが、
261行目で一旦グローバルにraiseEventしてから破壊しています

参加ボタンはローカルエンティティなのでローカル処理だけで破壊することもできますが、
参加後にリロードしたときに問題が発生します。
リロードするとグローバル処理は早送りで実行されますが、
以前の自分がやったローカル処理は実行されません。
結果、参加完了し自分のキャラクターもいるのに、参加ボタンが表示される状態になります。


キャラクター設定ボタン

これが一番めんどくさかったので搭載しなければよかったんですが、
キャラクターの目と色を変更するボタンはsettinglayerにまとめていました。

このsettinglayerはゲーム中は必要ないので削除します。
これをいつ削除するかがプレイヤーによって異なり、それぞれ場合分けして削除しました。

  • ゲーム開始ボタンが押される前に参加する人
  • ゲーム開始ボタンが押されたあとのカウントダウン中に参加する人
  • ゲーム中に参加する人
  • ゲーム中に参加せずに観戦することにする人
  • ゲーム終了まで参加も観戦も決めない人

例えば、ゲーム開始ボタンを押したときにsettinglayerを削除するのは、
参加リストに名前がある人という条件をつけて処理します。

終了したときにsettinglayerを削除するのは、まだ参加も観戦もしていない人だけれど、
このタイミングでは全員削除して構わないのでsettinglayer.onUpdateで削除する、等です。

それぞれの状況でリロードしたときのことも考える必要があるかもしれません。

実際にはこんな風に整理するわけではなくトライ&エラーで修正をかさねていくだけですが、
根気を発揮したくなければシンプルにすべきですね。


参加者ごとのキャラクターの作成する

参加者のキャラクターは499行目makeplayer関数で作成します。
キャラクターの動きは515行目body.onUpdateでほとんどを記載しています。
キャラクターは状態によって動きが変わり、状態は複数あります。

  • ボディの移動 ”stop”、”walk”、”dash”、”hit”
  • 無敵状態のONOFF body.tag.mutekiframe が -1 か 0以上か
  • 炎のONOFF fire.opacity =0 または 1

3つは独立していて様々な組み合わせが起こりえます。

そしてキャラクターに使うエンティティ body eye fireはあとあと色んな所で参照するので、655行目のようにプレイヤーデータとしてリストに登録しておきます。

 let playerdata = { //プレイヤーデータリストに名前情報と各種エンティティを登録
  name: name, //ユーザーの名前
  colortype: colortype, //色番号
  eyetype: eyetype, //目番号
  body: body, //bodyのエンティティ
  eye: eye, //eyeのエンティティ
  fire: fire, //fireのエンティティ
  score: player[id] == null ? 0 : player[id].score, //復活時はスコアを引き継ぐ
 }
 player[id] = playerdata; //プレイヤーのid

これで player[1000001].fire.opacity のように書くと、
IDが1000001のプレイヤーの炎の不透明度を参照できます。


キャラクターにパーツを追加する

キャラクターの胴体は505行目body というエンティティですが、
これに追加する目と炎は body を親エンティティにしてみようと思います。

やり方はレイヤーに乗せるのと同じ方法で、parent: body と記載して
612行目の eye と623行目の fire のエンティティを作っています。

この方法のメリットはbodyを動かせば同じように子エンティティも動いてくれるところです。
それ以外でもまとめて透明にしたり反転させたりもできます。

逆に最初戸惑うのが子エンティティの座標の指定方法です。

bodyとfireはほぼ同じ位置に配置しますが、bodyはゲーム全体の座標で指定します。
一方でfireはbodyの中の座標で指定します。そしてこのbodyの中の座標は左上が0です。

私は大体の画像を anchorX: 0.5, anchorY: 0.5, にしていますが、
これとは関係なくbodyの中の座標は左上からです。

このためfireをbodyのほぼ真ん中に置きたい場合は、fireの座標を

 x: body.width/2, y: body.height/2-12,

のようにしています。


キャラクターに名前をつける

キャラクターの名前は648行目でnamelabelというラベルを作り、
これもbodyを親エンティティにしてくっつけています。

650行目652行目で自分のものかどうかでフォントサイズと透過度を変えています。
サイズが1.5倍ぐらい違いますがこれでもよく見失うと言われます。

また、ニコ生ゲーム放送ではユーザー名を最大の16文字まで長くする人が多いですね。
長いと画面を専有するので、646行目で8文字より長い場合に最小0.6倍まで縮小しています。


名前のラベルがキャラクターの目などと違うのは、左右を反転してはいけない所です。

キャラクターのbodyは、594行目で移動方向body.tag.dirに応じて反転させています。

namelabelは独自にも反転させて、結果として反転していないようにしています。

キャラクター同士の接触

キャラクター同士の接触670行目scene.onUpdateでやっています。

キャラクターごとに処理するのでいつものようにfor文で複数回処理してもいいのですが、
今回はObject.keys(player).forEach( id => { … } );という形でやっています。

今回のプレイヤーデータ player は以下のような形で格納されています。

 let player = {
  1000001 : { name: n1, colortype: ct1, eyetype: et1, body: b1, eye: e1, fire: f1, score: s1, power: p1) ,
  1000002 : { name: n2, colortype: ct2, eyetype: et2, body: b2, eye: e2, fire: f2, score: s2, power: p2) ,
  1000003 : { name: n3, colortype: ct3, eyetype: et3, body: b3, eye: e3, fire: f3, score: s3, power: p3) ,
 }

こういった連想配列Object.keys(player)とすると、左の要素が入った配列を作ることができるそうです。この場合 [1000001 , 1000002 , 1000003] です。

そして配列を頭にして .forEach( id => { … }); とすると,
配列の数だけカッコの中の処理を繰り返します。

さらに中の処理では配列の値をidという変数で使うことができます。
別の文字列にしてもいいです。

あとはこれを攻撃する側と受ける側で処理し、各々の状態が無敵かどうかなどを照合します。

照合で該当したプレイヤーを、attackplayerhitplayerというリストに追加し、
そのリストをもとに691行目からそれぞれの処理をします。

繰り返しの中で直接ヒット処理をしても良さそうですが、処理順が変わると結果が変わってしまいそうです。しかもこのObject.keysは順番が保証されないなどと書いてあるサイトもあり、言葉の真意はさっぱりわかりませんがとりあえず順序の影響を受けないようにします。


アイテムとの接触

アイテムの接触もほぼ同じ考え方です。761行目にあります。


リスタート処理

「ニコ生ゲームを作ろうと思ったらすぐコピペしよう その1」にやり方がありますが、
105~109行目function mainfunction makescene
1471行目return scene を配置し、
1401行目g.game.replaceSceneでやっています。

リスタートしても変更しない変数として、
音量ONOFFの状態に加えて、選択した色情報と目の情報
関数の外側にあたる93行目に記載しています。


グローバルとローカルの確認

最後にローカルとグローバルが正しく設定されているか確認しましょう。

ローカルイベントのつもりがグローバルイベントになっていないか

ローカルイベントで作られるエンティティがすべてローカルになっているか

私は今回もやらかしました。
[2021/5/19追記] 次回作でもやらかしました。


状態遷移時のraiseEventの確認

raiseEventを多用しているときに強制終了の原因となるのが、
状態遷移と同時に共有されたraiseEventです。

raiseEventは呼び出した瞬間から遅れて、想定外のタイミングで処理されることがあります。

例えば今回は、ゲーム開始ボタンを押すとキャラクター設定用のsettinglayerを削除します。

この状態でsettinglayerにある画像を操作するとそんなものは無いのでエラーになります。

もちろんそうならないようにsettinglayerのボタンを削除しているのですが、
ゲーム開始ボタンとsettinglayerのボタンが同時に押されると削除後に処理が来ます。

対応策として今回は457行目478行目player[msg.data.id] != null を条件に入れて、
参加者リストに既に名前があったら処理しないようにしました。


同様のケースでゲームのリスタート時も気をつける必要があります。

リスタートボタンが押されるのと同時に誰かがキャラを操作しても、
そのキャラがいなくなっていれば操作をキャンセルする必要があります。

リスタートはシーンをリセットしているので、シーンの中のエンティティやイベントは
きれいに入れ替えができます。がしかしraiseEventは残るようです。


これまで強制終了の原因は大半がこれだった気がします。

こちらも今回もやらかしました。
[2021/5/20追記] 次回作でもやらかしました。

 



今回の説明は以上です。

今回のゲームの元となっているダックダッシュがそもそも人気がなかったのですが、
マルチならなんとかなるかなと思っていたもののやっぱりさっぱりでした。
もう少し差し合いのところをちゃんと設計しないとだめだった様に思います。

ただし、基本となるいつでも参加システムと復活システムができているので、
キャラクターの動作や得点方法、攻撃方法?を変えてみたバージョンを作ります。

それが終われば、参加を締め切る方式やチーム戦、フィールドが画面より大きいタイプ
などを検討中です。


この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。