ニコ生ゲームを作ろうと思ったらやっぱりマルチ! その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追記] 次回作でもやらかしました。

 



今回の説明は以上です。

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

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

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


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