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


今回からニコニコ生放送で遊べる、マルチゲームを作っていきます。
公式の説明ページで行くとこちらが近いです。

第1回はサンプルとしてこちらを作りました。音は抜いています。

各プレイヤーの操作でエサが投入される育成ゲーム、もしくは育成ツールになっています。
今回は初回なので、名前入力や個人スコアはなしのシンプルなゲームにしました。

アツマールだとまだマルチプレイはできませんがこちらです。


■目次

  • マルチゲーム作成に必要なこと
  • マルチゲームの動作確認
  • ローカルとグローバル
  • ローカルエンティティとローカルイベント
  • ローカル処理の注意事項
  • 操作の全体への共有 raiseEvent と scene.message
  • 放送者だけ特別な操作を可能にする
  • スキップ中の処理
  • もっとシンプルにグローバルだけで処理する

 




マルチゲームを作成するのに必要なこと


以下の6つが必要になります。

  1. akashic initをやり直す
  2. マルチ関係なくゲームを作る
  3. game.jsonのゲームモードをmultiにする
  4. 操作する箇所をローカルにする
  5. 操作内容を送信する処理を追加する (raiseEvent)
  6. 受信後の処理を追加する (scene.message)


akashic initをやり直す

まず、フォルダを用意してakashic initでファイルを作り直します。
コマンドプロンプト等で以下を入力します。

  akashic init -t javascript

シングルのランキングモードのときは最後がrankingのものを使いましたが別のものです。
ランキングモードのファイルを使い回すと若干エラーが発生します。


これによって以下の2つの値が使えなくなります。

  • アツマールでのプレイかどうかを判定する param.isAtsumaru
  • 共通乱数生成器 param.random

共通乱数生成器のほうは後述する方法を使うので問題ありませんが、
アツマール判定の方はmain.jsに類似の変数 isAtsumaru を用意しておきます。

70行目に以下の記載を追加しています。

  let isAtsumaru = false;
  if (typeof window !== "undefined" && window.RPGAtsumaru) {
    isAtsumaru = true;
  }


ここで使われている if文はアツマール環境のときしか実行されないようになっています。
このif文のまま使ってもいいのですが、長いのでisAtsumaruを使ってます。


マルチ関係なくゲームを作る

そして、マルチは気にせずゲームを作ってしまいます

Akashic Engineはマルチでも処理の記載に大きな違いがなく、似たような書き方をします。
シングルゲーム用に書いたものをそのままマルチとして動かしても割とそれらしく動きます。

シングルゲームをそもそも作ったことがない方はまずそちらをやってみてください。
この記事の最後にあるリンクやこちらこちらを参考に。


game.jsonのゲームモード設定

一つだけ必ず変えないといけないところがあります。
game.jsonに "supportedModes" 情報を追加し、値を ["multi"] にします。


"moduleMainScripts": {},

"environment": {

"sandbox-runtime": "3",
"niconico": {

"supportedModes": [

"multi"

]

}

}

}

 

 

sandbox-runtimeの行の最後のカンマを忘れないようにしてください。

ランキング用のシングルゲームだと "ranking" だったかと思います。

私はあまりやりませんが、カンマで区切ると複数のモードを設定できるようです。
ただし、間違えてニコ生ゲームとして登録すると消すのが難しいので気をつけましょう


[2021/6/9追記]
新しいモードとして"multi_admission"が追加されました。
これにすると「生ゲームプレイ中」のページに放送が表示されるようになります。


"multi"と"multi_admission"を両方記載するのではなく、後者だけを入れています。



マルチゲームの動作確認方法

複数のプレイヤーの視点がどうなっているかは、いつもとは違うテスト動作確認をします。
コマンドプロンプト等で以下のように入力します。

  akashic serve -s nicolive

そして、Chrome等のブラウザで以下のURLにアクセスします。
最近のバージョンでは上のコマンドで自動でURLが開かれるようです。

  http://localhost:3300/

以前は akashic-sandbox と http://localhost:3000/ だったのでどちらも違います。
このあたりのことは、こちらこちらに書かれています。


こういった画面が出てきたはずです。

 

左上と右上にボタンがあります。
ゲームサイズを1280x720とかにしているとボタンが小さくなるのがネックですね…


メニューを拡大します。



左上の電源マークが、ゲームのリセットです。

マルチの場合はブラウザをリロードしてもゲームはリセットされません。
プレイヤーがゲーム中にブラウザをリロードした時と同じ状態になります。
最初から処理を再開したい場合はリセットしましょう。


次は一時停止ボタンです。あまり使わないですね。

そして+と人が重なったボタンプレイヤーを追加するボタンです。
追加した分だけ人が増えて別ウィンドウで開きますので、表示が正しいか確認します。

selfIdがそれぞれのプレイヤーのIDです。動作テストでは1から順に番号が振られますが、
実際の生放送ではニコニコのアカウントID番号が使われます。
また、最初に起動した画面が放送者と同じ状態になります。

下段のシークバーは右上の設定ボタンを押すと出てきます。
さかのぼって再現することができます。

設定ボタンを押すと下側にも情報が増えます。
よくわかってないのであまり活用していませんが参加プレイヤーのリスト等があります。



ローカルとグローバル

今回はプレイヤーがタップするとメニューが表示され、フリックでエサを選んで投入します。

  

しかし、他のプレイヤーがエサを選んでいるところは見る必要がありません。

プレイヤーの操作に関することは全員に同じ処理や表示をさせず
そのプレイヤーだけが処理し表示などもされるようにします。

これをローカルと呼ぶようです。


一方でプレイヤーが操作をし終わった際に投入されるエサは
全員に同じものが表示され同じ処理が行われます

こちらはグローバルになります。


表示するものや処理がどちらなのかを意識しながら作っていく必要があります。


ローカルエンティティとローカルイベント


ローカルエンティティ

ではどうやってローカルにしていくかというと、ローカルエンティティをつくります

エンティティとは?となると思いますが、特に難しくはありません。
日本語だと「物」「実体」なのですが、画像・ラベルなどの配置するもの全般のことです。

列挙すると以下のものがあります。

  • 四角 g.FilledRect
  • 画像 g.Sprite g.FrameSprite
  • ラベル g.Label
  • 切り抜き g.Pane
  • E g.E (勝手にレイヤーと読んでいるもの)

new g.~ と書かれているところを検索すると割と見つかります。

ただし g.DynamicFont などの違うものも多々あります。
フォントは実体として配置されるわけではなく、ラベルなどの中で使うものです。


ローカルエンティティの作り方

これらのエンティティをローカルにするには、local: true を追加します。

今回のサンプルでは touch という透明な四角を配置してタッチパネルのように使います。
下の例ではtouchをローカルにしています。

 
 
 let touch = new g.FilledRect({// タッチ画面生成
  scene: scene, cssColor: "black", parent: touchlayer, local: true,
  x: g.game.width/2, y: g.game.height/2,
  width: g.game.width, height: g.game.height,
  anchorX: 0.5, anchorY: 0.5, opacity: 0, touchable: true,
 });
 

これによってtouchはローカルになり、プレイヤーごとに別々の処理ができます。
プレイヤーそれぞれが別々のタッチパネルを持っているイメージですね。


ローカルイベント

次に、プレイヤーが自分だけ実行し、他の人は実行しない処理を作ります。

例えばフリックの方向から計算してどのエサを選択するかは、本人にしか処理させません。
結果としてどのエサを選んだかだけを全員に教えればよく、そこまでの計算は不要です。

この、本人しか処理させないようにするのにはローカルイベントを起こします

この場合のイベントは、~.onUpdate.add~ や ~.onPointDown.add~ のように書かれて始まる処理のことを指します。ただの関数は含まれません。


ローカルイベントの起こし方

そして、ローカルイベントを起こす方法は一つしかありません。
ローカルエンティティを頭につけたイベントを起こすことです。

例えば先程ローカルにしたtouchは1044行目から以下の4つのローカルイベントがあります。



 touch.onPointDown.add(function(ev) {
 touch.onPointMove.add(function(ev) {
 touch.onPointUp.add(function(ev) {
 touch.onUpdate.add(function() {
 

これらのイベントは他のプレイヤーと共有しません。

また、このローカルイベントの中の処理をローカル処理と呼びます。



ローカル処理の注意事項

ローカル処理は基本的にシングルの処理と同じですが、禁止事項が3つあります。

  • グローバルエンティティを操作する
  • グローバルエンティティを生成する
  • 乱数生成器g.game.randomの使用


ローカル処理のグローバルエンティティ操作

ローカル処理で全員で共有するグローバルエンティティを操作すると、
処理を実行した人とそれ以外で状況にズレが生じてしまいます。

例えばエサを投入すると同時に、時間が立つとエサを消す処理を仕込むとします。
このエサがグローバルエンティティで、消す処理がローカルだったとすると
エサを投入した人の中でだけ消えて、他の人には残ったように見えます。

この場合は、エサを消す処理もグローバル処理にする必要があります。
esa.onUpdate.add~ のように監視して esa.destroy(); と破壊すれば問題ありません。

また、逆にグローバルな処理からローカルエンティティを操作するのは問題ありません。


ローカル処理のグローバルエンティティ生成

ローカル処理でグローバルエンティティを生成するのもだめです。

ローカル処理の中で他人に共有しないつもりでエンティティを配置しているのであれば、
ローカルだろうがグローバルだろうか良さそうなものです。

しかし、他のグローバルエンティティの認識が他のプレイヤーとズレる問題が発生します。

仕様を詳細に把握してるわけではないので憶測になりますが、
グローバルエンティティが生成される際に通し番号が付与されていて、ローカル処理で個別にグローバルエンティティを生成すると、その後のグローバルエンティティの番号がずれる、
と考えると辻褄が合うような現象を確認しています。

公式もこちらこちらではっきりと明言しているので、やらないようにしましょう。


この制約により、やるべきことが一つあります。

ローカル処理の中で生成するエンティティは、全てローカルエンティティにすることです。

今回の作品でも、フリック操作の円や多数の設定ボタンを表示させていますが、
全てローカルエンティティにするため local: true をつけています。


乱数生成器g.game.randomの使用

これまで乱数を使用する場合、g.game.randomparam.random の2つがありました。

シングルのゲームでは、全員が同じ乱数を使用したい場合は、param.randomを使いました。
しかし、マルチゲームで乱数を使用する場合はg.game.randomの方を使います。

g.game.randomは使用するごとに値が変わりますが、その使用が何回目なのかが同じであれば同じ値になります。
グローバル処理では使用するタイミングをプレイヤー全体で共有しているので、
常に使用回数がそろって同じ結果が得られます。


しかし、ローカル処理ではg.game.randomを使うべきではありません

ローカル処理で個別に使用するプレイヤーがいると、
それ以降の使用が何回目なのかがプレイヤーによって異なってしまいます。

もし、ローカル処理でランダムな結果が欲しい場合は、Math.random()を使いましょう。



操作の全体への共有 raiseEvent と scene.message


raiseEvent ローカル処理からの共有

ローカルでの処理を説明しましたが、これだけでは同時にプレイしているだけです。
個別の操作が全体へ共有されて初めてマルチと言えます。

ローカル処理の中ではグローバルエンティティへ影響を与えられませんが、
一つだけ操作を全体に共有する方法があります。

それがraiseEventです。

1118行目を見ていきます。

 touch.onPointUp.add(function(ev) {//操作リリース
  if (touchstate == true && !g.game.isSkipping){
   touchstate = false;
   let leng = Math.sqrt(controlx**2+controly**2);
   if (leng >= 80 && stage == pointstage){
    let angle = (-Math.atan2(controlx, controly)*180/Math.PI + 315)%360;
    let type = Math.floor(angle/90);
    if (type != 3) {
     let price = food[chara.tag.food[Math.floor(angle/90)]].price;
     if (price <= localcash) {
      localcash -= price;
      //
      g.game.raiseEvent(new g.MessageEvent({ msg : "food", x : ev.point.x, type : type }));
     }
    } else {
     makemenu();
    }
   }
  }
 });


raiseEventの行以外はエサを投入するかどうか、どのエサにするかの計算をしています。
そこまではローカルで処理し、必要な情報が揃った段階でraiseEventを呼び出しています。


raiseEventは、全体に共有する情報を追加することができます。
後半のカッコの中に3つの値を格納しています。

  • msg : イベントの種類の情報。公式がmsgを使っているのでそのまま使う
  • x : どの場所にエサを投入するかの情報。フリックを開始した地点のX座標を使う
  • type : どのタイプのエサを投入するかの情報


X座標は入れていますが、Y座標は不要なので入れていません。
処理が重くならないようにできる限り少なくしたほうがいいはずですが、
必要なら必要な分だけ変数を追加するべきです。
参加者全員のIDのリストを送信したこともあります。ゲーム開始時の一回だけですが。


このraiseEventには注意点が一つあります。

raiseEventはグローバル処理の中で呼び出してはいけません。
あくまでローカル処理の中からグローバルへイベント情報を受け渡すものです。

もしグローバル処理の中で呼び出してしまうと、プレイヤー全員が全体へイベントを共有し、
プレイヤーの数だけイベントが発生してしまいます。
1人ならエサは1個しか投入されませんが、100人いたら100個投入されます。


scene.message グローバル処理での受け取り

全体に共有されたイベントはscene.messageで受け取ります。1182行目にあります。

 scene.message.add( (msg) => {
  if (msg.data.msg === "food") {
   switch (msg.data.type) {
    case 0: makefood(msg.data.x, chara.tag.food[msg.data.type], chara); break;
    case 1: makefood(msg.data.x, chara.tag.food[msg.data.type], chara); break;
    case 2: makefood(msg.data.x, chara.tag.food[msg.data.type], chara); break;
   }
   foodnum[msg.data.type] ++;
  }
 });

ここで、全体に共有されたイベントがその後どう処理されるかを記載します。
そしてここはグローバル処理です。

まず最初にmsg.data.msgという値を確認しています。
これはraiseEventで格納していたイベントの種類msgのことです。

そして次にmsg.data.typeによって処理を切り替えています。
こちらはraiseEventに格納したエサの種類typeのことです。

その後の処理はmakefoodという関数に任せていますが、エサを投入する場所の情報は
msg.data.x として関数に引き継いでいます。

このように、raiseEventの情報に応じてグローバル処理を開始するのがscene.messageです。


放送者だけ特別な操作を許可する

この作品では、都合によりキャラクターの場所を動かしたいときのために、
放送者だけキャラクターをドラッグして動かせるようになっています。

放送者専用の操作を追加するには2つの段階があります。

  1. 放送者のID(アカウントID番号)を記録する
  2. 操作が放送者のものか識別する


放送者IDの記録方法

放送者の記録は143行目で実施しています。

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

g.game.onJoinという特殊なイベントを使用します。

これはJoinがあったときにだけ処理されるイベントです。
Akashic Engineでは放送者しかJoinしないという仕様になっています。

そして、ev.player.idそのイベントを起こしたプレイヤーのIDの値になります。
streameridという値に格納すれば記録は完了です。


処理は短いですが、割とわかりにくいです。もし興味あればこちらこちらも確認ください。
ここで言うJoinとはいわゆるゲームへの参加とは全く別物です。
akashic engineの開始時に放送者しか「Joinというもの」をしないことになっています。
そしてJoinするとg.game.onJoinのトリガーになる、単にそれだけです。

さらに、g.game.onJoinはグローバル処理です。
放送者だけ、と言うとローカル処理なのかなと勘違いしそうですが、
Joinは放送者だけな一方、放送者のJoinがあったというイベントは全員が共有しています。
そして、そのイベントを起こしたのは放送者だということがev.player.idで共有されます。


操作が放送者のものか識別する

キャラクターの画像を動かせるようにするため、
551行目の画像imageのtouchableとlocalをtrueにし、
touchよりも手前のレイヤーに配置して個別に触れるようにしています。

そして、570行目でonPointMoveイベントを追加しています。

 image.onPointMove.add(function(ev) {
  if (streamerid === ev.player.id && !g.game.isSkipping && !param.isAtsumaru){
   g.game.raiseEvent(new g.MessageEvent({ msg : "move", x : ev.prevDelta.x, y : ev.prevDelta.y }));
  }
 });

ここでraiseEventを処理するかどうかの判定に放送者IDを使用しています。

 streamerid === ev.player.id

ここでのev.player.idはキャラクター画像を動かそうとした人なので、
それが放送者であれば動かすことができます。


スキップ中の処理


ここまでで必要なことはほぼ出てきているのですが、
プレイヤーあいだの状況がずれるのを防ぐ設定が一つあります。

マルチゲームを生放送中にあとから参加したり、途中でリロードしたりすると
最初からスキップで再生し直すようになっています。

これは仕様なのでどうしようもないのですが、
スキップ中にプレイヤーが操作すると操作できてしまうことがあります。

この操作が早送り中の過去の状態を操作しているのか、
早送りが完了した現在の状態を操作しているのか、よくわからないのですが、
想定外の動作のもとなので操作できないようにします。

g.game.isSkippingという値があり、早送り中はこれが true になります。
今回のコードでは、操作に関わるところでこれがfalseであることを確認しています。

[追記]こちらはうまく機能しませんでした
また、早送り中のキャラクターが画面を駆け回るのは鬱陶しいと思いますので、
早送りが終わるまではキャラクターを薄く透過することにしました。

209行目でスキップ状態によって最初の透過度を決定し、
スキップが終了して状態が変わったときに発生するイベント onSkipChange を
221行目に配置しています。

  g.game.onSkipChange.add(function(ev) {
    localtransp = 3;
    chara.opacity = 1 - transptable[localtransp];
    chara.modified();
  });

 



もっとシンプルにグローバルだけで処理する

最後によりシンプルな記載方法を補足しておきます。

ここまでで紹介した方法は、ローカルエンティティを配置し、
raiseEventとscene.messageローカルからグローバルに操作を共有する方式でした。


しかし、Akashic Engineはグローバルエンティティだけでもマルチゲームを作れます
公式でもこちらこちらで、まず最初にグローバルだけのサンプルを紹介しています。

グローバル処理だけだとプレイヤーがほぼ同じゲーム画面を見ることになりますが、
状況が揃っている必要性が低い短時間のゲームや、その場限りの演出ツール等であれば
この方式も検討する価値があると思います。


この記事でも本来ならシンプルな方から紹介するべきところですが、
応用力を考えてローカルエンティティを使う方法から紹介しました。

また、一つ前の章で紹介したスキップ中の処理の制御も難しいと思います。
g.game.isSkippingがどのプレイヤーのスキップ状態かがわからず、
プレイヤー間の状況のズレを抑えきれないように感じました。

 



今回の説明は以上です。

公式の記事でマルチプレイに関係するものはこちらこちらこちらにあります。
あと短いですがmultiの設定はこちらです。

この記事を書いている時に公式の「ニコニコスネーク」のコードがこちらで公開されました。
jumpergameに次ぐ公式マルチゲームのコード紹介ではないかと思いますが、本格的なものは初めてです。

公式の信頼できるコードだと思いますので読み込んでみてはどうでしょうか。



次回は、もう少しゲームとして遊べるものにしたいと思います。
プレイヤーに個別のキャラがあり、少なくともスコアがあるものを考えています。

ニコニコアカウントからプレイヤー名を取得する方法もやりたいのですが、
次回の時点ではまだできるかどうかわかりません。



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