ニコ生ゲームを作ろうと思ったらやっぱりマルチ! その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()はfalse。show()のあとなら.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で行います。
ただこの方法も軽く問題があって、起動時に以下のエラーが出ます。このエラーは左上のリセットボタンを押せば一定確率で消えます。
エラーの意味は不明ですが、出るときと出ないときがあります。
--脱線終わり。--
時間表示などの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を大量に用意するのは重くなりそうです。
代わりの方法として以下の手順をやりました。
- マップ全体の縦横を2~8分割した場所にfieldというエンティティを置く 936行目
- hole画像をそれぞれの場所のfieldを親エンティティにして配置する 1416行目
- カメラ位置に応じて遠くのfieldを非表示にする 1001行目
穴画像エンティティの座標を一つ一つ計算するのは重いのではと思いこの方法にしています。これがベストかどうかは全くわかりませんが、少なくとも非表示にすることでかなり軽くなってるのは確かです。
プレイ人数を20ぐらいまでに絞っていればあまり気にしなくてもいいのですが、
大人数プレイを視野に入れている場合は、Chrome等の開発者ツールを使いながら
処理の重さを確認していくといいと思います。
ゲーム展開がズレるエラーについて
マルチゲームを作っているとプレイヤーごとにゲームの展開が異なる不具合がよく発生します。
財宝掘りでも発生しているのですがまだ対策できていません。
発生する原因としてはこれまでの経験から以下のものがあります。
- ローカル処理でグローバルのエンティティや情報を操作する
- ローカル処理でグローバルエンティティを作る
- ローカル処理でg.game.randomを使う
- グローバル処理でローカルの情報をもとに処理を変える
- 物理エンジンで複雑な現象を起こす
今回の財宝掘りは上記の点に十分注意していたものの、バグはなくなっていません。
プレイヤーが同じタイミングで掘り当てたときの所有権に違いが出るのかなと思い、結果が収束するように変更したものの効果はありませんでした。
現時点ではちょっとお手上げ状態です。
今回はここまです。
これまでマルチの記事を続けてきましたが、前述の通りバグが中々なくなりません。
バグ出しが終われば安定するかなと思ったんですが、毎回違うバグが出てきます。
ゲームが変わるとキャラクターのアニメーションの有無や動きが変わるのもあり、テンプレどころではないですね。
一応記事に載せているそれぞれの説明は特に間違っていないと思います。たぶん。
あと私のマルチのコードは他の方と比べて動作が重めで転用するには不向きかもしれません。PCなら問題ありませんが、スペックが低めのスマホでは結構重いです。
そもそもニコ生ゲームにおいてどれぐらいの旧機種のスマホまで対応させるべきなのかは難しいところですが、ニコ生アプリを使って起動すると結構重くなります。
サバイバルゲームや、バトロワ、チーム戦あたりもやりたいんですが、ちょっと時間をおいてまた挑戦したいと思います。そのときにコードの公開だけはやるつもりです。
公式のニコニコスネークを転用しやすいように使い込んで記事にする、という方向性も検討すべきかもしれませんね。
次からは気分を変えて情報が少ない物理ゲームをやろうと思います。
この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。