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

 



今回の説明は以上です。

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

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

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


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

ニコ生ゲームを作ろうと思ったらやっぱりマルチ! その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に次ぐ公式マルチゲームのコード紹介ではないかと思いますが、本格的なものは初めてです。

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



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

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



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

ニコ生ゲームを作ろうと思ったらすぐコピペしよう その5


第5回のゲームは間違い探しです。

今回も使い回しを想定しています。
画像を用意して間違い箇所をプログラム上で指定する手間はありますが、頑張ればできます。

出来上がったものがこちらになります。アツマール公開版とは問題が違います。

※ 冒頭のランキング表示を追加しました。[2022/06/14]

 

絵はこちら

ゲームはこんな感じで画像が表示されます。アツマールはこちらです。
  




■目次

  • 画像準備
  • 差分箇所入力
  • デバッグモード
  • 出題数とダミー差分
  • 得点設定
  • ミラーモード設定
  • その他
  • レイヤーの切り替えで出題する





画像準備

画像の準備ですが、まず画像のサイズは 500×500 で固定です。

そして、一つの「元画像」に対して複数の「差分画像」を用意していきます。

元画像の例
animal0



元画像はゲーム画面の左右両方に表示されます。

差分画像の例
順にanimal1 , animal2 , animal3

   

差分画像も500×500で統一してください。名前は連番である必要はありません。

animal1の画像は元画像のanimal0をベースに7箇所の「差分箇所」があります。
差分箇所一つ一つを別々の画像にしても構いませんが、画像の管理とファイルサイズが大きくなるのがデメリットになると思います。
アツマールのファイルサイズ制限は結構ありそうですが、ニコ生ゲームは10MBまでです。

一つの画像の中のそれぞれの「差分箇所」は離れるように配置しています。
ゲーム内で差分箇所を切り取って表示する際に、別の箇所が映り込むのを防ぐためです。
離す距離については後述しますが、40以上を目安にしています。



 



ほぼ同じ場所に別のタイプの差分箇所を置きたい場合は、animal2のように別画像にします。
りんごの差分箇所とバナナの差分箇所はほぼ同じですが映り込むことはありません。
  animal2
また、animal2は差分箇所から離れたところは画像を消してしまっています。
逆に差分箇所から近いカラスとやハリネズミは残しています。

差分箇所を切り取ったときに、周囲が元画像と違う状況だと不自然になってしまうためです。
切り取る距離は差分の大きさと中央から40以上を目安にしてください。



差分画像に透過画像を使うこともできます
animal3は実は周囲が透過している画像になっています。
  animal3
上からかぶせるだけであれば、差分だけの透過画像を使うのがシンプルです。
使用している画像編集ソフトにレイヤー機能があればこの方法がやりやすいと思います。
ただし、元画像の後ろに配置するような重ね方はできません。


差分箇所入力

では差分を作成した箇所をプログラムに入力していきます。

入力例が 247行目から277行目 にあります。

  let quiz = [ // asset, x, y, size(片幅),
    [
      "animal0", //元画像
      [ "animal1", 52, 192, 50],
      [ "animal1", 248, 95, 80],
      [ "animal1", 414, 105, 50],
      [ "animal1", 403, 205, 40],
      [ "animal1", 110, 440, 100],
      [ "animal1", 299, 291, 40],
      [ "animal1", 433, 462, 50],
      [ "animal2", 134, 167, 40],
      [ "animal2", 410, 215, 40],
      [ "animal3", 133, 314, 70],
      [ "animal3", 453, 316, 40],
    ],
    [
      "zombie0", //元画像
      [ "zombie1", 60, 87, 40],
      [ "zombie1", 252, 190, 60],
      [ "zombie1", 0, 297, 70],
      [ "zombie1", 180, 424, 40],
      [ "zombie1", 457, 115, 50],
      [ "zombie1", 462, 391, 60],
      [ "zombie2", 7, 66, 90],
      [ "zombie2", 228, 241, 45],
      [ "zombie2", 131, 364, 40],
      [ "zombie2", 289, 33, 50],
      [ "zombie2", 446, 176, 70],
      [ "zombie2", 325, 399, 100],
    ],
  ];

まず全体が quiz という配列になっていてカッコで囲まれています。

その中に各元画像に関連する配列が2つあります。元画像が多ければいくらでも増やせます。

そのさらに中には、最初に元画像の名前が入ります。
そして各差分箇所の配列が続きます。

各差分箇所の配列には4つはいっていて、

 [ 差分画像の名前 , X位置 , Y位置 , 大きさ ],

の順になっています。

全体的にカッコとカンマの位置によく気をつけてください。


X位置とY位置の入力は差分箇所の中心の座標を入力します。

MS標準のペイントでもマウスカーソルを持っていけば、下にその座標が出ると思います。


大きさは、差分画像を切り取る大きさです。
ただし、注意してほしいのは中心から端までの距離になっています。

大きさが40であれば、「切り取りエリア」の幅と高さは80になります。
横長や縦長には対応していません。長い方を採用してください。


大きさは基本的に40以上にしておいてください。小さいとタップするのが難しくなります。
スペースが許せば10ぐらい余裕を持って大きくすると、理不尽な誤タップ判定が減ります。
30台でも動きますので、スペース上仕方ないときは採用しても大丈夫です。


上限は明確にはありませんが、隣の差分箇所にまで到達すると映り込んでしまいます。

また、切り取りエリアが重なると同時には出題できなくなります。
250に近い値にすると、その差分が選ばれると1問しか出題できなくなったり、
右画像をクリックしたつもりが左画像のエリアをクリックしたことになったりします。
(その他の項目に補足説明あり。)

ちょっとややこしいのですが、同じ差分画像の中で、
切り取りエリア同士が重なることはOKです。同時でなければ出題できます
切り取りエリアが隣の差分箇所の描画に重なるのはNGです。映り込んでしまいます。



また、差分画像の切り取りには元画像を消し潰す目的もあることに注意してください。

下のカラスが移動している差分画像の場合、右のようなエリアで良いように感じますが、

     

元画像はもう少し左下にいて、元のカラスを白で消さなければならないので、
 
    

左下の空白も含めて中心座標と大きさを設定する必要があります。



デバッグモード

ここまでで最低限の動作はできるようになっていると思います。

しかし、登録した座標や切り取りの大きさがうまく行っているかを確認するには、
ランダムで出題される問題が全部出るまで正解し続ける必要があり、気が遠くなる長さです。

これを簡略化するためにデバッグモードを追加しています。
デバッグモードでは一つずつ順番に出題されるので、余計な映り込み等を確認しましょう。


デバッグモードは148行目の debugmode という値を true にすると使えます。

そして次の行の debugimage を元画像の番号にします。
元画像を quiz に格納したときの順番が番号になっていて、5枚なら 0~4です。

全部同時にはチェックできないので、1セットずつ確認してください。
また、チェック後は debugmode を false に戻してからリリースしてください。



出題数とダミー差分

ゲームの進行中に一度にいくつの間違い箇所を出題するかは、244行目で設定できます。
odainumという値を出題数にしてください。

公式のみんなで間違い探しだと4問で、マルチでないものは3~5問だったかと思います。
全体の問題数や大きさにもよりますが、同じぐらいでいいのではないでしょうか。


またダミー差分の数 dummynum も設定できます。

正解の差分画像だけを表示していると間違い探しゲームとして左右を比較する必要がなく、
差分画像を覚えてそれを探せば良いという問題があります。

ちゃんと違いを見る必要性を出すためにこの数だけ不正解の差分を両側に追加しています。

数はodainumと同じか、4、5個でいいんじゃないでしょうか。


得点設定

得点の数値は143行目から146行目にまとめました。

正解とハズレの得点と、一度に出題されたものを全て答えたときのクリア得点があります。

ハズレによって総得点がマイナスになるかどうかは、361行目にあります。
標準ではマイナス不可になっているので、許可したい場合は1行消してください。

クリア得点は最大値と最小値があり、クリア時間に応じて減少していきます。
クリア得点が減るスピードを変えたい方は467行目の最後の 5 を変更してください。


ミラーモード設定

実は間違い探しはとある方法をマスターすると超簡単になってしまいます。
それを防止するために動き等で邪魔することも考えたのですが…意地悪なのでやめました。
使える人と使えない人それぞれ別々で競えればいいなと思います。

と、思っていたのですが

対策として、左右の片方の画像を反転させる方法がある情報を得ました。
これなら、そこまでストレスではない上に割としっかり対策になります。

処理もそこまで難しくなかったので機能を追加しました。
246行目の mirrormode を true にすると使えます。


この対策について検索していると、ニコニコ大百科の記事が最初に出てきました。
しかも、「アツマールでゲームを作る人は注意」とピンポイントで書かれている有様。
灯台下暗しでした。


その他

差分のサイズを大きくする際の注意

左右の画面端に縦長の差分箇所がある場合、
隣のエリアまで差分領域が到達してしまうことがあります。

下の画像のケースでは赤い大型のキャラをうまく囲うため、中心を画像外においています。



縦長の切り取りエリアが出来上がっているように見えて、
左の画像の差分箇所にかぶさってしまっています。

大きさによっては完全に覆ってしまうことで、クリックできなくなる詰みの恐れがあります。


基本的な対処方法は画像内に中心を入れ、切り取りエリアも画像内に収める、です。

どうしても左右の画面端で縦長領域を作りたい場合は、下の設定を復活させてください。

  // a.touchable = false;
  // b.touchable = false;

452行目453行目にありますが、普段は無効になっています。
行頭の // を消すと設定が復活します。

この設定を復活させると、一度クリックして正解するとその領域が無効になって、
下の領域をクリックできるようになります。

逆にこの設定があると、間違ってダブルクリックすると2クリック目が不正解になります。


全体を覆うpaneを作っとけばよかったんですけどね。



チュートリアル画像について

チュートリアル画像はシンプルなものと、画像とタイトルを入れられるものを用意しました。


冒頭にある絵のリンクから持っていってもらってもいいですし、
1から作ってもらっても、透明画像に差し替えてもらっても構いません。

akashic scan asset さえやればサイズも不問です。




このゲームの記事は以上です。

アツマール版では、5枚の元画像にそれぞれ4枚の差分画像と50の差分箇所を作り、
250箇所の間違い箇所を用意しました。

画像編集と違いのネタ出しが結構大変だったので、20~30箇所を4枚とかでいいと思います。
しかも今回、BGMが2MBで画像が9MBあったので10MBをオーバーしました。
大変申し訳ないのですがBGMの尺を短くさせていただきました。
画像を圧縮する方法もあったのかも。

[追記] JPEGにすれば圧縮できました。画質?も特に問題なさそうです。



あと、間違い探し向けの画像というのがちょっと難しくて、
画面全体に物があって、あまり規則正しくないものという縛りがあります。

今回アツマール版はいらすとやさんからイラストをお借りしましたが、
結構苦労して途中から複数の絵を組み合わせる方向にシフトしました。
自分で絵を書ける人ならここはクリアできそうですね。




レイヤーの切り替えで出題する

ここからは間違い探しゲームの改造とは関係ありません
クイズ形式やパズル形式のゲーム全般で使える方法を解説します。

前回のクイズでも使っていましたが、出題に関する画像等をレイヤーに配置し
次の出題に移る際にレイヤーを破壊して、次のレイヤーを生成し直す方法があります。



レイヤー切り替えのメリット

メリットはレイヤーごと削除することでまとめて削除できるところです。

複数の画像を配置している場合、全て削除するには画像等の名前を覚えておき、
それぞれ、 sprite.destroy(); と書く必要があります。
ゲームの進行によって画像の枚数が違う場合は、更にめんどくさくなります。

全てレイヤーに乗せておけば、 layer.destory(); と書くだけですべて削除できます。


画像などを削除せずに使い回すことももちろんできますが、
リセットする際にこれまで変更したパラメータを全て把握して書き換える必要があります。

全て作り直せば、初期のパラメータでリセットできます。



レイヤー切り替えの流れ

流れはこのような形です。




レイヤーの構成

まず、162~164行目で表示順を制御するために全体で使うレイヤーを3つ配置しています。

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


そして、切り替え用のレイヤーtouchlayer308行目に書いてあります。

  let touchlayer = new g.E({ scene: scene, parent: backlayer});

このとき、 parent を backlayer にしてbacklayerの上にtouchlayerを配置します。
スコア等を表示するuilayerと音量ボタン用のbuttonlayerの後ろに配置するためです。


レイヤーの構成を図で表すとこのような形です。



ちなみに、「レイヤー」という呼び方は私が勝手に言っているだけです。
正式には「入れ子」「Eオブジェクト」「親エンティティ」「g.E」などです。うーん



レイヤーを最初に配置するタイミング


touchlayerは307行目makequiz関数というまとめた処理の中で配置します。

function makequiz() {
  let touchlayer = new g.E({ scene: scene, parent: backlayer});

   ︙

}

そしてこのmakequiz関数は179~188行目のように、
ウォームアップ時間がなくなった瞬間に1度だけ処理されます

  scene.onUpdate.add(function () {//時間経過
    if (warmup >= 0) warmup -= 1 / g.game.fps;
    if (warmup < 0) {
      if (!startstate) {
        makequiz();
      }
      startstate = true;
      gametime += 1 / g.game.fps;
    }
  });

onUpdateを使っているのでこんな書き方ですが、setTimeoutでもいいです。



画像を配置する関数へのレイヤー引き継ぎ

makequizの処理を進めていくと、画像の配置は似たような処理が出てきます。
makeimage、makesabun、makedummyの3つの関数にまとめ、都度再利用しています。

その中で配置する画像等はparent を touchlayerにしますが、そのままでは使えません

makequizの中で作ったtouchlayerの名前はmakequizの中でしか通用しません


このため、313行目のようにmakeimageを使う際に、
touchlayerをカッコの中に入れて引き継ぎします。

  makeimage(odai[0], -1, touchlayer);

他にも画像の名前と配置する位置情報も引き継いでいます。


makeimageの本体の方でも、受け入れる変数を記載しています。

  function makeimage(asset, pos, touchlayer) {
    let image = new g.Sprite({
      scene: scene, src: scene.assets[asset], parent: touchlayer,
      x: g.game.width * (0.5 + pos * 0.22), y: g.game.height * 0.5,
      scaleX: pos > 0 && mirrormode ? -1 : 1, scaleY: 1,
      anchorX: 0.5, anchorY: 0.5, opacity: 1, touchable: true,
      tag : { stage: stage },
    });
      ︙

こうすることでmakeimageの中でもtouchlayerが使えるようになります。

受け入れの時の名前は別の名前で構いませんが、makeimageの中では統一して使います。
layerにしておいて、状況に応じて引き継ぐレイヤーを変えたりすることもできます。

「引き継ぎ」は正確には「引数」と呼ぶらしいです。
「いんすう」ではなく「ひきすう」らしいです。引き算は関係ないと思います。



レイヤーの破壊と生成

レイヤーの破壊は426行目onPointDownでクリックするタイミングから始まっていきます。

a.onPointDown.add(function(ev) {
 if (startstate && !finishstate && !clearstate && !missstate) {
  if (a.tag.stage == stage && !a.tag.clear && remain > 0) {
   let score = seikaipoint; //正解一つの得点
    g.game.vars.gameState.score += score;
    let marua = new g.FrameSprite({
     scene: scene, src: scene.assets["batumaru"], parent: touchlayer,
     x: a.x, y: a.y, scaleX: 0.5, scaleY: 0.5,
     width: 200, height: 200, srcWidth: 200, srcHeight: 200, frames: [1],
     anchorX: 0.5, anchorY: 0.5, opacity: 0.6, touchable: false,
    });
    let marub = new g.FrameSprite({
     scene: scene, src: scene.assets["batumaru"], parent: touchlayer,
     x: b.x, y: b.y, scaleX: 0.5, scaleY: 0.5,
     width: 200, height: 200, srcWidth: 200, srcHeight: 200, frames: [1],
     anchorX: 0.5, anchorY: 0.5, opacity: 0.6, touchable: false,
    });
    let label = new g.Label({
     scene: scene, text: "+" + score, parent: uilayer,
     font: font, fontSize: 48,
     x: g.game.width/2+40, y: scorelabel.y,
     anchorX: 0.5, anchorY: 0.5, opacity: 1,
    });
    a.tag.clear = true;
    b.tag.clear = true;
    // a.touchable = false;
    // b.touchable = false;
    remain --;
    remainlabel.text = "あと" + remain + "つ";
    remainlabel.invalidate();
    scene.setTimeout(function() {
     label.destroy();
    }, 1250);
    if (remain <= 0){
     if (soundstate) scene.assets["se_clear"].play().changeVolume(0.4);
     stage++;
     remain = 0;
     clearstate = true;
     remainlabel.text = "クリア!";
     remainlabel.invalidate();
     let clearscore = Math.floor(clearmaxpoint - (g.game.age - startframe) / g.game.fps * 5);
     if (clearscore < clearminpoint) clearscore = clearminpoint; //1クリア時の最低限のボーナス
     g.game.vars.gameState.score += clearscore;
     let clearlabel = new g.Label({
      scene: scene, text: "+" + clearscore, parent: uilayer,
      font: font, fontSize: 48,
      x: g.game.width/2-160, y: scorelabel.y,
      anchorX: 0.5, anchorY: 0.5, opacity: 1,
     });
     scene.setTimeout(function() {
      clearlabel.destroy();
     }, 1250);
     scene.setTimeout(function() {
      if (!finishstate){
       touchlayer.destroy();
       makequiz();
      }
     }, 1250 - 1000 * debugmode);
    } else {
     if (soundstate) scene.assets["se_seikai"].play().changeVolume(0.4);
    }
   }
  }
 });

今回のゲームは出題箇所が0になったら次に進むので、460行目残りの数を判定しています。
これがスライドパズルのように盤面全体が一致しているかを判定する場合は、
全パネルをチェックする for (let i = 0; i < paneltable.length; i++) を入れたりします。

そして、正解であることを表示する時間を479行目タイマーで開始し、
touchlayer.destoy();で破壊、そのままmakequiz();で次の出題を生成します。


makequizがある意味無限ループですが、クリックしないと進まないので大丈夫です。



関数の中で常に監視する処理

491行目にonUpdateがありますが、sceneではなくtouchlayerのonUpdateにしています。

これをscene.onUpdateにしてしまうとsceneはtouchlayer.destory();で破壊されません
このため、ループが進んで繰り返されると処理が重複します
100問目になると100処理たまることになります。

touchlayer.onUpdateにすれば、処理も毎回削除されて常に一つしかないようになります。


また、scene.onUpdateの中に、touchlayerの画像等に関する処理があると、
破壊した後に処理が行われるとエラーになります。

同じ名前の画像等を作ったとしても別物になってしまうので、
そういう場合は関数の外に破壊しない変数を作っておいて
生成するごとにその変数に画像等を格納する方法があります。






次回からは、テンプレゲームはお休みしてマルチゲームと物理の解説をやろうと思います。
アツマールの方の流れでマルチゲームの需要が出てきそうです。


また、テンプレゲームの方針が迷子になってきているのもあります。
残っているネタとして音ゲースワップパズル、画像からのさがし物、はあるのですが
再利用したいと思えるほど魅力的なゲームデザインとなるとなかなか見つかりません。

もともとはACTや横STG 弾幕STGも考えていたんですが、割と改造が大変だったり、
ゲーム性が変わりにくかったりでテンプレとしては使いづらそうな見込みです。
テンプレとしての再利用は忘れて作ったものを公開することはあるかもしれません。


もしテンプレートとして良さそうなネタが有りましたら教えて下さい。

ただし、あくまで再利用に向いていて多くの方が使えるジャンルのアイデア募集であって、
作って欲しいゲームのリクエストを受け付けるわけではありません。
欲しいものがあったら基本的に自分で作りましょうという記事でもあります。


この記事以外にも、コピペできそうなコードや解説を公開しています。
こちらに記事のリンクがまとまっていますので御覧ください。
そろそろブロマガから移行する準備もしないとだめですね。

ニコ生ゲームを作ろうと思ったらすぐコピペしよう その4


第4回のゲームは4択クイズです。早く多く回答することで得点が高くなるタイプです。

今回は使い回しを想定して出題と回答を追加しやすいようにしました。


出来上がったものがこちらになります。アツマール公開版とは問題やフォントが違います。

※ 不正解時の答え合わせ機能を追加しました。[2021/04/09]
※ 冒頭のランキング表示を追加しました。[2022/06/13]


今回は公式の再配布可能な音ファイルも入れてます。CC BY 2.1 JP DWANGO Co., Ltd.
絵はこちら
ゲームはこんな感じで選択肢が表示されます。アツマールはこちらです。

  


■目次

  • 出題内容作成
  • 正解と不正解
  • パネル配置
  • 画像表示
  • 文字表示
  • 得点設定
  • 不正解時の答え合わせ
  • 共通ランダム化
  • ダイナミックフォント
  • 縁取りビットマップフォント作成





出題内容作成

出題内容は、233行目quizという配列に、1行ずつ追加していきます。

 let imagescale = 1; //上部出題欄toppanelと 大型回答欄largepanel
 let textscale = 1; //中央出題欄centerpanelと 小型回答欄smallpanel
 let fontsize = 42; //文字直接入力
 let quiz = [// top ,center, 正解, 不正解
  [ "vene", "★の場所のイタリアの都市は?", ["ヴェネツィア"], ["ローマ", "ナポリ", "ミラノ", "ジェノヴァ"]],
  [ "snake", "巳年のつぎは何年?", ["午年"], ["未年", "酉年", "丑年"]],
  [ "", "巳年のつぎは何年?", ["午年"], ["未年", "酉年", "丑年"]],
  [ "巳年のつぎは何年?", "", ["午年"], ["未年", "酉年", "丑年"]],
  [ "", "ローマはどこにある?", ["roma"], ["boro", "jeno", "napo", "vene"]],
  [ "", "仲間はずれはどれ?", ["crab", "dog", "snake"], ["crow", "duck", "hawk"]],
 ];


一つ一つの問題は4つの値が格納されています。

それぞれ" "で囲まれた文字列か、文字列が格納された配列です。

  [ 上部出題内容 ,  中央出題内容 ,  正解 ,  不正解 ],

行の最後にもカンマ , がありますので忘れないようにしてください。

アツマール版では出題は110問用意しました。
1問正解後の待ち時間が1.25秒あるので、制限時間60秒だと最大48問進むことになります。
48問よりは多くしておかないと同じ問題が出てしまいます。
問題が少ない場合は制限時間も減らしてください。



パネル配置

パネルの配置レイアウトは3種類あり、上部出題中央出題の文字列で変化します。

1.両方あるタイプ

画像で出題する標準の形。

  [ "snake", "巳年のつぎは何年?", ["午年"], ["未年", "酉年", "丑年"]],

  


2.上部がないタイプ

問題文だけで出題する、または画像で回答させたい場合
一番最初の項目を "" にする。

  [ "", "巳年のつぎは何年?", ["午年"], ["未年", "酉年", "丑年"]],
  [ "", "ローマはどこにある?", ["roma"], ["boro", "jeno", "napo", "vene"]],


  
  
  



3.中央がないタイプ

問題文がない画像に問題文が入っている、または問題文を大きく表示したい場合
二番目の項目を "" にする。

  [ "巳年のつぎは何年?", "", ["午年"], ["未年", "酉年", "丑年"]],

  


正解と不正解

正解と不正解は3つ目と4つ目の場所に入れます。
両方とも配列にするため [ ] に囲んで入れます。文字列が1個しかなくても囲みます。

  [ "", "ローマはどこにある?", ["roma"], ["boro", "jeno", "napo", "vene"]],
  [ "", "仲間はずれはどれ?", ["crab", "dog", "snake"], ["crow", "duck", "hawk"]],


 


配列の中には文字列 "〇〇" を入れますが、正解は1個以上、不正解は3個以上必要です。

両方とも多い分には問題ありません。ゲーム開始時にランダムで選択されます。
クイズは覚えゲーになるのが避けられませんが、正解も不正解も複数あると寿命が伸びます。


画像表示

画像を配置させる場合はassetの名前を入力します。4つの入力箇所全て共通です。

89行目の let assets というリストの中にその文字列があればその画像が配置されます。


用意する画像のサイズはバラバラで大丈夫ですが、だいたい同じサイズが好ましいです。
一括でスケールを調整することはできますが、個別に調整はできません。

このように 400 x 320 と 80 x 80 の画像が混在していると、
小さい方が小さいままになり、調整しきれません。

 
  



ただし、大きいパネル横長の小さいパネルは、スケール調整が別々になっています。


大きいパネル

余白抜きで 幅420 高さ280 ぐらいの画像、余白は透明が望ましい。

230行目の let largepanelscale で一括サイズ調整が可能です。

上部出題欄 toppanel
 大型回答欄 largepanel




横長の小さいパネル

余白抜きで 幅420 高さ110 ぐらいの画像が望ましい。
文字を配置する想定のパネルですが、画像も配置できます。

231行目の let smallpanelscale で一括サイズ調整が可能です。

中央出題欄 centerpanel
 小型回答欄 smallpanel






文字表示

画像の代わりに直接文字を入力することもできます。

89行目の let assets にない文字列であれば自動で文字入力なります。

 [ "vene", "★の場所のイタリアの都市は?", ["ヴェネツィア"], ["ローマ", "ナポリ", "ミラノ", "ジェノヴァ"]],





フォントサイズは232行目の let fontsize で一括調整できます。
バラバラ調整は不可です。


ただし、残念ながら改行は使えません。
改行したい場合は画像にしてGoogle図形描画で書くのが早いと思います。

一応、akashic-labelというものもあり、これを導入すれば改行ができるようです。
こちらこちらこちらを読めばできるかもしれません。私はできません。


得点設定

得点は正解時は100~150点の範囲に設定しています。
時間経過で0.1秒あたり1点減っていきます。

不正解時の減点の値は305行目にあります。

  let score = -50; // 不正解のときの得点
  if (panel.tag.seikai) {
    score = Math.floor(150 - (g.game.age - startframe) / g.game.fps *10);
    if (score < 100) score = 100;
    // if (soundstate) scene.assets["se_seikai"].play().changeVolume(0.4);
  } else {
    // if (soundstate) scene.assets["se_hazure"].play().changeVolume(0.6);
  }
  g.game.vars.gameState.score += score;
  if (g.game.vars.gameState.score < 0) g.game.vars.gameState.score = 0;


不正解によるマイナスで総得点もマイナスになるかどうかは、314行目で判定しています。
313行目の得点計算の直後で判定することで、マイナス点の状態を極力なくしています。

総得点がマイナスになってもいい場合は、314行のみ削除してください。



不正解時の答え合わせ

不正解時の答え合わせ機能について、要望が多かったので追加しました。

144行目の kotaeawase を true にすると、選択すると同時に正解に丸が出てきます。

  let kotaeawase = true; // 不正解時に答え合わせをするか

false にすると無効になります。

この機能があると、デバイスやアカウントを2つ使うと簡単にカンニングできてしまうので
意図的に外していました。私の作品では今のところ無効にする予定です。

ただやはりプレイした方の中で「正解が知りたい」という声は多く、
必ずしもガチのクイズ競技として遊ぶわけでもないと思います。
問題を単に楽しんで遊ぶ用途に機能を選択できるようにしました。




改造箇所の説明はここまでです。
ここからは細かい説明です。


共通ランダム化

それぞれのクイズは難易度が異なるので、プレイヤー全員が同じ問題になるようにします。
一方で、マンネリ化しないように多数からランダムに選択します。

共通したランダムの結果を使用するのがparam.randomです。

133行目に以下の定義があります。

  let syncrandom = param.random;

このsyncrandomという文字列は私が勝手につけているだけなので何でもよいです。
シンプルにrandomでもいいです。

この定義は、function makescene(param) {またはfunction main(param) { の中にあり、
scene.onLoad.add(function () { の外にあります。

アセットの登録と同じような場所ですね。



この後、syncrandomを使うと共通化されたランダムが使えるようになります。

利用するときは、以下のように使います。248行目や389行目にあります。

  syncrandom.generate();
  syncrandom.get(2, 4);


ただし、getの方は今後推奨されないらしくそのうち使えなくなるかもしれません。
その場合は以下のようにしてください。

  2 + Math.floor(3 * syncrandom.generate());


ダイナミックフォント

今回のコードではダイナミックフォントを使用しています。

ダイナミックフォントはフォント画像やフォントファイルを用意しなくて良いので、
漢字などの様々な文字をすぐに使えるのがメリットです。

デメリットはフォントの種類が3種類しかないことと、縁取りに限界があることです。


個人的な話ですが、ダイナミックフォントは縁取りと太字化ができないと思いこんでいて、
視認性の面からあまり使っていませんでした、

実際には十分実用に耐えますし、今回のように文字表示が多い場合は大活躍するので、
説明していきます。


まず、153行目のように let font = new g.DynamicFont({ と宣言します。

// フォントの生成
let font = new g.DynamicFont({
  game: g.game,
  fontFamily: "sans-serif",
  // "sans-serif": サンセリフ体・ゴシック体のフォント。
  // "serif": セリフ体・明朝体のフォント。
  // "monospace": 等幅フォント
  fontWeight: "bold", // "normal" または "bold"
  size: 96, fontColor: "black", strokeWidth: 8, strokeColor: "white",
});

ビットマップフォントと違ってglyphの定義が不要で、アセットの登録も不要です。

使うときは、g.Labelのfontにフォントの名前fontを入れるだけです。

// スコアラベル
let scorelabel = new g.Label({
  scene: scene, text: "", parent: uilayer,
  font: font, fontSize: 42,
  x: g.game.width-16, y: 40,
  anchorX: 1, anchorY: 0.5, opacity: 1,
});


ダイナミックフォントのパラメータは主に以下のものがあります。

  • fontFamily : "sans-serif""serif""monospace" から選ぶ
             それぞれのフォントのデザインはこちらで確認

  • fontWeight : 太字なら"bold"。通常は"normal"

  • size      : 大きめのサイズ 96 を入れる
               実際には都度サイズを指定するが、大きいとぼやけにくくなる

  • fontColor  : 色指定"red""#112233""rbg(0,100,200)"など
              色の違うフォントを併用する場合は、let font_redなどを別途定義

  • strokeWidth : 縁取りの太さ。サイズが96の場合はだいたい 8 が限界
             もっと太くしたいところだが、文字が欠ける

  • strokeColor : 基本的に"white"か"black"のどちらか。他の色も可



フォントの色はg.Labelのfontの方に、textColor: "red",と入力して変えることもできます。
こっちはfontColorではなくtextColorです。

しかしこの場合は、フォントの色と縁取りのstrokeの色が両方とも変わってしまいます。
縁取りをしない場合はこの方法も使えますね。



[2021/2/19加筆]
ここからの記事は、縁取りフォントにビットマップフォントを使う前提で記載しています。
ダイナミックフォントでも縁取りが簡単にできることがわかりましたので、
フォントにこだわらない方やクイズゲームにだけ興味がある方はこの先は無視してください。

縁取りをもっと分厚くして視認性を向上させたい場合や、
こだわりのフォントを使いたい場合は、ここからの手順を参考にしてください。

個人的に丸みのあるフォントが好きなので、この方法で作成したものを使っています。
下の方で作成したものを公開しています。しかし、漢字はほぼ全く使えません。



縁取りビットマップフォント作成

以前も解説しましたが、フォントを縁取りすると視認性が大幅に改善します。

今回のクイズゲームは背景に白いパネルがあるのでなんとかなりますが、
一度よく使う文字で作ってしまえば使い回せるので、作り方も紹介します。

まず、フォントファイルはこちらのリンクにあるものを確認してください。

 


基本的に縁取りフォントファイルのの作り方は3つあります。

  1. bmpfont-generator の strikeコマンド
  2. bmpfont-generator と 画像縁取り加工
  3. bmfont64 と glyphファイル変換

1.と2.はニコ生ゲームを作ろうと思ったらすぐコピペしよう その2で紹介しましたが、
1.は縁取りを太くできず、2.は縁取りが若干不自然、という問題があります。

3.の方法であればきれいに作れますので今回紹介します。
しかし手順が長く細かいので、苦手な方は上のリンクにあるものをそのまま使ってください。


その長い手順を5個に分けてみます。

  1. bmfont64導入
  2. 設定ファイル読み込み
  3. 各種設定
  4. FNTファイル出力
  5. JSONへ変換


1. bmfont64導入

bmfont64(正式名称:Bitmap Font Generator)はこちらでダウンロードできます
ダウンロードしたファイルを起動するだけです。

このソフトはドワンゴやakashic engineとは全く関係ありません。ご注意願います。

bmfont64でGoogle検索をしてもらったら、最初に日本語の解説サイトが出てきます。
そちらを見るだけでも基本的な使い方はわかると思います。


2. 設定ファイル読み込み

メニューの option から Load configuration を選択して設定ファイルを読み込みます。

読み込む設定ファイルは上のリンクにあります。中のfont_round.bmfc を開いてください。




これで縁取りや文字の種類などの設定が読み込まれます。

今回こちらのフォントを使っています。このフォントがないとエラーが出るかもしれません。
あとから変えられるのでエラーは一旦無視してください。



3. 各種設定

設定はいくつかありますが、まずメニューに有る Font settings から。



めぼしい設定は以下のとおりです。他のものはあまり理解していません。

  • Font フォントを指定します。Windowsにインストールしたフォントが使えます
  • Add font file 直接フォントファイルを指定することもできます
  • Size フィントサイズ。小さいと拡大時にボケる。大きいと画像が大きくなりすぎる
  • Match char height 高さを揃えるかどうか。必要だったようなそうでもないような…
  • Outline thickness 縁取りの厚さ(太さ)



次は Export options



  • Texture Width - Height
             生成する画像のサイズ
             文字数や文字の大きさによって合わせる
             数値が小さいと画像が2枚になるのでそうならないような大きさにする
  • Chnl - Value - Invert
             文字色と縁取り色の変更
             設定の法則性が難解なので、既存のファイルを参考にしてください
             もしくは画像編集ソフトで画像の色調を直接調整してください


今回フォントのサイズを48でやっていますが、拡大時にぼやける場合は
Size 、Outline thickness 、Texture Width - Height をそれぞれ変更してください。



そして、文字選択は2つの方法があります。

1つ目メインのウィンドウでクリックして追加していきます。



右側がカテゴリ分けになっていて、名前をクリックすると左側が切り替わります。
チェックボックスをクリックすると全部登録されてしまいます。
カテゴリを切り替えて、左側の文字を一つ一つクリックして文字を選択していきます。

文字にはコードがあり、HTML数値文字参照(10進数表記) が右下に表示されています。
上の例だと9632という数字です。これを参考にして探してもいいのですが…
次に説明する方法のほうが簡単かもしれません。


2つ目の方法は文字が書かれたテキストファイルを読み込む方法です。
bmpfont-generatorにも似たような方法がありました。

Edit の Select chars from file からファイルを指定できます。
あまり使ってませんでしたが、簡単そうですね。
テキストファイルの保存はUTF-8にしましょう。





4. FNTファイル出力

設定ができたら出力して確認しましょう
Option - Save bitmap font as... からファイルを指定して保存します。

FNTファイルというのが出てきますが、同時に画像も生成されます。
画像が1枚に収まっていて、色や文字の綺麗さが問題なければOKです。

FNTファイルをテキストエディタなどで開くと、
「char id=」で始まる行が文字の数だけあります。



id , x , y , width , height の5つの値が今後フォントファイルを作るのに必要ですが、
jsonと書き方が違います。

また、右側のoffsetの値や下の方にあるkerningも不要です。

次にこれらを加工していきます。


5. jsonへ変換

jsonの書き方に変換するには、こちらスプレッドシートを使っています。
変換するプログラムをササッと作れればいいんですが、私はそういうのじゃないので。

このシートは閲覧可能ですが、編集不可にしています。
ご自身のGoogleドライブにコピーして使ってください。

使い方は以下のとおりです。

  1. FNTファイルの「char id=」で始まる行をすべてコピーする
  2. シートの左端A列に貼り付る
  3. 右端のO列をコピーして、jsonファイルに貼り付ける





縁取りフォント作成の手順は以上です。

ビットマップフォントはちょっとのミスでテストプレイの表示が真っ白になります。
これだけ長々とやって真っ白になると落ち込みますが、
既存のうまく行っているファイルと見比べたりしてなんとか頑張ってください。




半分近くがクイズと直接関係ないフォントの話ですが、これで終わりです。

クイズの方は文字と画像とタイトル画面とBGMあたりを変えるだけでゲームになりそうです。
興味あればぜひ改造してみてください。

[2021/3/2]
この記事のコードを利用していただいた方が現れました! gm18707 gm18805
パネル画像やフォントの雰囲気が統一されていて素晴らしいです。
この一連の企画が役に立ってるかどうか知る方法がなかったのでひとまず安心しました。

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


ニコ生ゲームを作ろうと思ったらすぐコピペしよう その3

第3回のゲームはタイミングよくクリックするゲームです。

動くバーをクリックで止めるので、ウィリアムゴリラに近いですね。


出来上がったものがこちらになります。前回と同じく、音を無効にしています。

今回のゲームは画像などを差し替えて、新しいゲームにする想定ではありません。
使われている処理について解説をしていきますので、参考にしてください。

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


  


■目次

  • 音量の調整
  • FPSの変更
  • 状態の切り替わり
  • タイミングの計測
  • 背景を動かす移動表現
  • バーの表示 アンカーの指定
  • 画像の切り抜き
  • リスタート時のウォームアップ時間

 



音量の調整

BGMや効果音はプログラム上で音量を調節できます。

上で公開したコードは音ファイルを消してしまっていますが、
例えば150行目に以下のような処理を残してあります。

// if (musicstate) scene.assets["bgm"].play().changeVolume(0.2);



最後のカッコの中が音量です。1だと変更なしですね。

公開されているゲームで音量を調節せずにうるさくなっているものがちょくちょくあります。
audioファイルの加工がめんどくさいのだろうと思いますが、この方法なら簡単です。

泥棒バスターのコードを改造する場合は色んな所にplay()があるので探さないとだめですね。


今回は音量調節を使って簡易エコーもやってみました。

281行目にきこりが斧を振る音の処理があります。

// if (soundstate) scene.assets["se_hit"].play().changeVolume(0.6*volume);
// scene.setTimeout(function() {
// if (soundstate) scene.assets["se_hit"].play().changeVolume(0.3*volume);
// },100);
// scene.setTimeout(function() {
// if (soundstate) scene.assets["se_hit"].play().changeVolume(0.2*volume);
// },200);
// scene.setTimeout(function() {
// if (soundstate) scene.assets["se_hit"].play().changeVolume(0.1*volume);
// },300);


0.1秒ずつずらして徐々に小さくなる音を鳴らしています。
用意した素材が短めだった場合は、これで響いている風になります。

volumeという値は、直前で決定した斧を振る強さから算出しており、
強さに応じて音の大きさが変わるようになっています。

音量調整バーを用意して調整可能にするのもできそうですね。


音量、特にBGM音量をどれぐらいに調整するかは結構難しく、私も未だに失敗します。
akashic-sandboxの環境では、ぎりぎりうるさいぐらいの音量にするのがいいでしょう。

というのも、実際の生放送のプレイヤーでは放送者の声などに合わせて音量バーを下げて
視聴するのが普通だと思います。
おそらく、akashic-sandboxの環境は放送時に音量をMAXにした設定に相当します。
このためテストプレイ中はぎりぎりうるさいぐらいだと、放送中にちょうどよくなります。


FPSの変更

今回はクリックのタイミングの正確さを競うゲームです。

一般的にゲームはFPSの回数だけ処理が行われます。FPS30なら、1秒あたり30回です。
それぞれの処理でクリックしたかどうかを判定するので、判定は33ミリ秒間隔です。

例えば、FPS30を反射神経を競うゲームに採用すると、
人間の反応速度が200ミリ秒台なのに対し、200、233、267、300ミリ秒
という非常に粗い評価しかできません。

ここで、FPSを60に変更すると、判定は17ミリ秒間隔になり、
200、217、233、250、267、283、300ミリ秒というように評価が細かくなります。



では、FPSを変更してみます。

FPSgame.jsonの冒頭に記載があります。

{
  "width": 1280,
  "height": 720,
  "fps": 60,

ここを60にするだけなので簡単です。



しかし、注意点があります。
処理ごとに画像を移動させるような処理があると、移動のスピードやタイマーのスピードも変わってしまいます。

例えば以下のような処理があった場合

scene.onUpdate.add(function () {
  shot.x += 3;
  shot.modified();
});

FPS30だと1秒に90進みます。FPS60だと180進みます。
FPSを最初に決めて、その後にスピードやタイマーなどを合わせていけば問題ないですが、途中で変えるとなると面倒です。

FPSの値はg.game.fpsで参照できるので、FPSが変わっても対応できるようにすることも可能です。

scene.onUpdate.add(function () {//時間経過
  if (warmup >= 0) warmup -= 1 / g.game.fps;
  if (warmup < 0) startstate = true;
  if (warmup < 0) gametime += 1 / g.game.fps;
  if (startstate && state == "warmup") {
    state = "power";
    startframe = g.game.age;
  }
});

141行目の時間経過処理は 1 / g.game.fps で1処理の時間を計算して足し引きします。



FPS30よりFPS60のほうが細かく処理ができるのであれば、
常にFPS60でいいのでは?と思わなくもないですが、処理が重くなるデメリットがあります。

大量の描画対象があるゲームの場合は、特に顕著です。
今回のゲームも放送者の場合、時間の進みが遅く制限時間が足りないとの情報もありました。

処理能力はプレイヤーによって様々ですので、
シビアなタイミングゲームや音ゲーでない限りFPS30がいいと思います。


状態の切り替わり

今回のゲームでは、「パワーバー動作中」「狙いバー動作中」「伐採中」の3つの状態がループして進行します。

それぞれの状態で処理が変わってくるので、stateという値を用意し次のように切り替えます。

"warmup" ⇛ "power" ⇛ "aim" ⇛ "cut" ⇛ "power" ⇛ 以降ループ

パワーの強さなどは、stateが"power"のときのみ変化するようにし、
狙いの値は"aim"のときのみ変化するように、場合分けしています。



状態の切り替えは"power"と"aim"に関しては、269行目の通りクリックで切り替えます。

let score = 0; //1回分のスコア
let speed = 50;
touch.onPointDown.add(function(ev) {//操作押し込み
  if (startstate && !finishstate){
    if (state == "aim") {
      state = "cut";
      cutter.frameNumber = 1;
      cutter.modified();
      score = power * (1 - Math.abs(aim - 1)*5) * 50;
      score = Math.floor(score < 1 ? 1 : score);
      speed = score * 0.7 + 5;
      slash.scaleX = score/25;
      slash.scaleY = score/25;
      let volume = ( (score + 20)/70) ** 3;
      // if (soundstate) scene.assets["se_hit"].play().changeVolume(0.6*volume);
      // scene.setTimeout(function() {
      // if (soundstate) scene.assets["se_hit"].play().changeVolume(0.3*volume);
      // },100);
      // scene.setTimeout(function() {
      // if (soundstate) scene.assets["se_hit"].play().changeVolume(0.2*volume);
      // },200);
      // scene.setTimeout(function() {
      // if (soundstate) scene.assets["se_hit"].play().changeVolume(0.1*volume);
      // },300);
    }
    if (state == "power") {
      state = "aim";
      cutter.stop();
      cutter.frameNumber = 0;
      cutter.modified();
      startframe = g.game.age - g.game.random.get(0, 5);
      aimframe = Math.round(g.game.fps * 0.5 * (1.5 - power)/0.5);
      aim = (g.game.age - startframe) /aimframe;
      aimline.y = targetline.y - chargeback.height*(1-aim);
      aimline.modified();
      pluslabel.opacity = 0;
      pluslabel.modified();
      // if (soundstate) scene.assets["se_charge"].play().changeVolume(0.5);
    }
  }
});


このとき、"aim"の処理を"power"の処理の前に配置しています。

これを逆にすると、"power"から"aim"に変わったあと、
そのまま"aim"から"cut"に変わってしまい、"aim"の状態がスキップされてしまいます。


一方で"warmup"と"cut"の状態からの切り替えは、時間と距離の経過で起こります。
146行目445行目にあるので興味あれば確認してください。


タイミングの計測

タイミングの計測にはg.game.ageという常に加算されるフレーム数と、
タイミングの計測を開始したときのフレーム数startframe
計測がリセットされるまでのフレーム数chargeframeまたはaimframeを使います。

let chargeframe = 50;
chargebar.onUpdate.add(function () {
  if (state == "power" || state == "warmup") {
    power = (g.game.age - startframe) /chargeframe;
    if (g.game.age - startframe > chargeframe){
      power = 2 - power;
      if (g.game.age - startframe > chargeframe*2){
        power = -power;
        startframe += chargeframe*2;
      }
    }
    chargebar.height = chargeback.height*power;
  }
  chargebar.modified();
});


まず最初に、stateを"power”にするところから始まります。
ここには書かれていませんが、146行目445行目にあります。
そのときに、startframeにg.game.ageの値を入れて時刻合わせをします。

その後、上の処理を実行しますが、startframe - g.game.age経過フレーム数です。
経過フレーム数がchargeframeを超えると折返しを始め、chargeframeの2倍を超えるとstartframeを再度設定してリセットします。

処理中はpowerという値でチャージ率を決めており、この値は0~1の範囲にあります。
stateが"power"でなくなると、次のチャージまでpowerの値が更新されなくなります。

この値をチャージバーの高さchargebar.heightや、伐採する木の本数の計算に使います。



あと、ちょっと意地が悪いのですが計測の開始時に"ゆらぎ"を発生させています。
297行目450行目にありますが、0~5フレームだけランダムで開始がずれます。

startframe = g.game.age - g.game.random.get(0, 5);


この目的はツールによるタイミング管理の防止で、毎回同じタイミングでバーが動くと、ツールで簡単にパーフェクトが出せてしまいます。

同じタイミングだとリズムで覚えられて、ノリでプレイできるメリットもありますが、
今回は、狙いのバーの速度も可変にして、リズムゲーではなく目押しゲーにしました。



背景を動かす移動表現

前回のアヒルのゲームのように画面内でキャラクターを動かすだけだと、
ダイナミックな動きは表現できません。

画面外まで移動しているように見せるには2つやり方があります。
一つは背景を動かす方法で、今回のものです。
もう一つは背景を動かさずにカメラを動かす方法ですが、今回は説明しません。

405行目に以下の処理があります。

if (state == "cut") {
  slash.opacity = 1;
  slash.y = aimline.y;
  dist += speed;
  g.game.vars.gameState.score = Math.floor(dist/step);
  for (var i = 0; i < 25; i++) {
    trees[i].x -= speed;
    if (trees[i].x < -150) {
      trees[i].x += 2000;
      trees[i].opacity = 1;
      trees[i].angle = 0;
    }
    trees[i].modified();
    bases[i].x -= speed;
    if (bases[i].x < -150) bases[i].x += 2000;
    bases[i].modified();
  }
  treelayer.children.sort( (a, b) => a.x - b.x);

  cutter.x -= speed;
  cutter.modified();

  backtree1.x -= speed/2;
  if (backtree1.x < -g.game.width) backtree1.x += g.game.width*2;
  backtree1.modified();
  backtree2.x -= speed/2;
  if (backtree2.x < -g.game.width) backtree2.x += g.game.width*2;
  backtree2.modified();

  background1.x -= speed;
  if (background1.x < -g.game.width) background1.x += g.game.width*2;
  background1.modified();
  background2.x -= speed;
  if (background2.x < -g.game.width) background2.x += g.game.width*2;
  background2.modified();

  if (dist >= step * (prev + score)) {
    state = "power";
    cutter.x = 300;
    cutter.modified();
    cutter.start();
    prev = Math.floor(dist/step);
    startframe = g.game.age - g.game.random.get(0, 5);
    pluslabel.text = "+" + score;
    pluslabel.opacity = 1;
    pluslabel.invalidate();
  }
} else {
  slash.opacity = 0;
}
slash.modified();


面倒ですが、背景、木、木こりのすべてを移動させています。

背景の地面backgroundと、背景の木backtree2つ用意してあって、
一つが画面左の外に移動しても次のものが画面右から入ってきます。
そして、1つ目が完全に左に行って見えなくなると、 g.game.widthの2倍の距離を右に移動し途切れずに背景が表示されるようになっています。

伐採する木の方は、画面外に行くと2000だけ移動して角度と透明度も戻すようにしています。

また背景の木は移動速度を半分にして、多重スクロール風に試してみました。
うまくやると遠近感を表現できるのですが、今回はよくわかりませんね。
雲とかがあればよかったかもしれません。


バーの表示 アンカーの指定

バーの表示にはg.FilledRectを使っています。

var chargebar = new g.FilledRect({
  scene: scene, cssColor: "orangered", parent: uilayer,
  x: charge.x, y: chargeback.y + chargeback.height/2,
  width: 30, height: 0,
  anchorX: 0.5, anchorY: 1, opacity: 1,
});

このバーの高さchagebar.heightを変化させてバーの動きを表現していますが、
下方向には動かず、上にのみ伸びる必要があります。





この表現を行うため、anchorYという値を1に設定しています。

このanchorYはこの四角のどこに基準点を置くかを示しています。

基準点が決まると主に2つの動きが決まります。

  • xとyの値を指定したときに四角のどの点がその位置に置かれるか
  • 回転するときの中心点

わかりにくいと思いますが、例で示すと以下のようになります。

x : 0 , y : 0 のとき
anchorX : 0 , anchorY : 0  なら 四角の左上が x : 0 , y : 0 になる(四角は画面内)
anchorX : 0.5, anchorY : 0.5 なら 四角の中心が x : 0 , y : 0 になる(1/4だけ映る)
anchorX : 1 , anchorY : 1  なら 四角の右下が x : 0 , y : 0 になる(四角は画面外)

今回のchangebarはanchorYが1なので、四角の下端の位置が固定になり、
その状態で高さを変えると上方向にのみ伸びるようになります。


特に必要がない場合は、anchorX : 0.5, anchorY : 0.5 にしておいたほうが直感的に
位置を認識できると思います。個人の感覚によりますが。

スコア表示のような右揃えで数字を表示したい場合はanchorX : 1 にすると良いですね。

値を指定しないときは、標準としてanchorX : 0, anchorY : 0 になるようです。



画像の切り抜き

今回のメインである木はpaneという切り抜きを使い、切断される表現をやってみました。



すべての木は上側を切り抜いたものと下側を切り抜いたものをつなげて配置しています。
切断したときに上側だけ角度が変わるようになっています。

215行目
の以下の表現です。

let trees = ;
let bases =
;
let div = 508;
let step = 2000 / 25;
for (var i = 0; i < 25; i++) {
  let treepane = new g.Pane({
    scene: scene, parent: treelayer,
    x: i * step + 320, y: div,
    width: 300, height: div,
    anchorX: 0.5, anchorY: 1,
  });
  let tree = new g.FrameSprite({
    scene: scene, src: scene.assets["tree"], parent: treepane,
    x: 0, y: 0,
    width: 300, height: 720, srcWidth: 300, srcHeight: 720, frames: [i%3],
    anchorX: 0, anchorY: 0, opacity: 1,
  });
  trees[i] = treepane;

  let basepane = new g.Pane({
    scene: scene, parent: movelayer,
    x: i * step + 320, y: div,
    width: 300, height: 720 - div,
    anchorX: 0.5, anchorY: 0,
  });
  let base = new g.FrameSprite({
    scene: scene, src: scene.assets["tree"], parent: basepane,
    x: 0, y: -div,
    width: 300, height: 720, srcWidth: 300, srcHeight: 720, frames: [i%3],
    anchorX: 0, anchorY: 0, opacity: 1,
  });
  bases[i] = basepane;
}


今回の木は1本が300x720の画像ですが、根元付近で切り離されたようにするため、
上から508のところで画像を分割します

分割された画像を上下別々に用意してアセットとして登録してもいいのですが、
プログラム上で分割すれば分割する高さを調整することもできますのでやってみます。

まずlet treepaneという上側の切り抜きを用意します。
この切り抜きは width: 300, height: 508 というサイズの四角いエリアを指定しています。
この後このpaneの上に画像を配置しますが、四角いエリアの外の部分は表示されず
エリア内だけ切り抜かれて表示されます。

この上側の切り抜きは伐採したときに、切断面を中心にして回転させます。
このため anchorY: 1にし、yの値も合わせて調整します。

この切り抜きの上にlet treeという画像を配置します。
parentにいつものレイヤーではなく treepane を指定することで切り抜きに載せられます

切り抜きに乗せる画像のxとyの値は、画面全体の座標を指定するのではなく、
切り抜きの中での座標を指定しています。


同じようなやり方で下側のbasepaneも配置します。


また、今回は常に同じ切断面ですが、操作に応じて切断する高さを変えることもできますね。



リスタート時のウォームアップ時間

ウォームアップは生放送で初見プレイする人向けに、ある程度長め(7秒)に設定します。
しかし、アツマールで繰り返しプレイする人にはチュートリアルは不要です。

そこでリスタートしたときはwarmupの値を別の値(3秒)にするようにします。

function makescene(param) { の中でwarmupを定義していると、毎回同じ値になってしまうので、その外の66行目warmupを設定しています。

そして、リスタートボタンを押した時の処理の588行目warmup = 3;を追加しています。

これでリスタートしたときのみウォームアップが短くなります。




はい今回は以上です。

似たような処理がほしい場合に参考にしてもらえたらと思います。

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




[リリース後追記]
どうもfirefoxだと重くなってしまうようです。

背景の木は削除しましたが、他にも以下のような重い点が考えられます。

  • FPS60
  • 木が25本ある
  • paneを多用している
  • 大きめの画像が回転する
  • 音が重なって鳴る
  • onUpdateが多すぎる

木や音は無駄な演出とはいえ、そこも込みのゲームのつもりなので、
今からは変えづらくどれがネックなのかもわかりません。

firefox、edge、chrome、スマホを常に動作確認するのは大変なので、
ある程度見切り発進しないとやってられません。
FPS60のときは演出を控えめにする配慮が必要かもしれないですね。参考にしてください。




[再追記 21/2/21]
こちらで公式の高速化記事を発見しましたので、とりあえずpaneを取りやめました。

効果確認はできていませんが、pane多用はまずそうですね。しかもFPS60なので。

あと、めんどくさいので大量にonUpdateさせてる処理があるのですが、こちらは後回し。

ニコ生ゲームを作ろうと思ったらすぐコピペしよう その2

第2回のゲームは見下ろし型のアクションゲームです。

マウスやタッチパネルを使って操作性の良いアクションゲームを作るのは結構難しいです。
その中で可能なやり方の一つが、タッチ箇所へキャラクターが向かう操作方法です。

公式のゲームでいうと逃げゲーと同じです。
ボスネコラッシュもすこし近いですね。ニコニコスネークは違う操作方式です。


とりあえず出来上がったものがこちらになります。


これはアツマール用にエクスポートしたzipではなく、ファイルをまとめただけです。
音ファイルはありません。main.jsの音の記載も無効にしてあるので、追加した場合はplay()で検索して有効にし、assetIDsの登録scan assetをやってください。

絵はこちら

ゲームはこんな感じで真ん中のアヒルが画面内を動きます。アツマールはこちらです。

  


■目次

  • 画像入れ替え
  • プレイヤーパラメータ変更
  • 敵キャラパラメータ変更
  • 敵出現パターン
  • アイテムパラメータ変更
  • タッチ操作
  • フレームアニメーション
  • プレイヤーと敵キャラの状態
  • プレイヤーの見た目変化
  • ヒット判定


プレイヤーと敵を改造すれば、別ゲーにしやすいように意識したつもりです。

敵の動きはいくつかパターンを用意しました。

画像の差し替えと速度などのパラメータを変更し、出てくる数も変えればゲームの印象を変えられるんじゃないかと思います。





画像入れ替え

今回も画像入れ替えで雰囲気が変えられるようにします

変えるとよい画像はこちら

  • tutorial.png     タイトルと操作説明
  • player.png      メインのプレイヤーキャラ
  • ene1 ~ ene8.png  敵キャラ 登場させたい数だけ用意する
  • item.png      得点になるアイテム 登場させたい数だけ


キャラクターはアニメーションさせる場合は複数の画像が結合されたものを使いますが、
アニメーションさせない場合は1枚のものでも大丈夫です。

変えるとよいが、用意しなくてもいい画像はこちら。
ただし、画像自体が無いと起動できないので透明画像に差し替えるか、画像のopacityを0にするなどの処理が必要です。

  • background.png   背景画像 なしまたは透過四角でいい
  • playerhit.png     メインキャラのやられ表示
  • fire.png       メインキャラのパワーアップ表示


また、playerhitだけは、消すとプレイヤーが表示されない瞬間が出てくるので、
後述するplayerhittimeを0にするか、playerと同じ画像をplayerhitに名前を変えて
置いてください。

それぞれimageフォルダに上書きして、assetIDsに登録して、scan assetをやります。


プレイヤーパラメータ変更

プレイヤーのパラメーターは変更しやすいように142行目あたりにまとめています。

let playerspeed = 4.5; //プレイヤーのスピード
let playersize = 50; //プレイヤーの当たり判定の大きさ 直径 ピクセル 
let playercenter = 30; //プレイヤー中央の停止判定エリアの大きさ
let playerscale = 1.0; //プレイヤー画像の倍率 当たり判定は別
let playerimagenum = 4; //プレイヤー画像の分割枚数
let playerimagew = 80; //プレイヤー画像の分割 横幅
let playerimageh = 80; //プレイヤー画像の分割 縦高さ
let playerdash = 4; //プレイヤーダッシュ時のスピード倍率
let playerdashtime = 0.4; //プレイヤーダッシュの持続秒数
let playerdashcooltime = 1.0; //プレイヤーダッシュのクールタイム秒数
let playerhittime = 1.5; //プレイヤーがダメージで硬直する秒数
let playermutekitime = 4.0; //プレイヤーがダメージ後に無敵になる秒数
let playerpowerupnum = 10; //プレイヤーのパワーアップに必要なアイテムの敵
let playerpoweruptime = 5.0; //プレイヤーパワーアップの秒数


数が多いですがコメントも入れていますので、色々変更してみてください。

とりあえずスピードを異常に早くしたりすれば雰囲気が変わるんじゃないでしょうか。

画像を差し替えたときに、画像のサイズや枚数が変わった場合は2~6個目を変えてください。

画像サイズを変えた場合は、playerimagewを変えるだけではなく、当たり判定も変える必要があるのでplayersizeを同じぐらいの値か、6割ぐらいに変えます。
画像の外周に余白が多くある場合はそれも考慮して、当たり判定を小さくします。

このゲームではキャラクターの中心をポイントしていれば、動きが止まります。
その中心の大きさをplayercenterとしてplayersizeの半分ぐらいにします。
0でもいいんですが、キャラクターがブルブル動いてしまうかもしれません。

作った画像が意外と小さかった場合は、playerscaleを1.5とか2とか3にしてください。

アニメーションさせるつもりで複数の画像を結合している場合は、playerimagenumにその枚数を入れてください。
アニメーションさせない場合はplayerimagenum = 1と入れてください。

それ以外のパラメータも興味があったら色々変えてみてください。
ダッシュは、残念ながら直進しかできません。


敵キャラパラメータ変更

敵キャラは8タイプ用意しています。

function makeene1(inix, iniy) {

}


のようにくくったところが敵を作る処理です。makeene1~makeene8まであります。

最初にパラメータを並べてあります。タイプによって種類が異なります。

let speed = 6; //敵のスピード
let size = 70; //敵の当たり判定の大きさ 直径
let scale = 1.0; //敵画像の倍率 当たり判定は別
let imagenum = 1; //敵画像の分割枚数
let imagew = 120; //敵画像の分割 横幅
let imageh = 120; //敵画像の分割 縦高さ
let lifetime = 15; //敵の生存秒数 即消滅と画面外へ出ていく場合あり
let dashtime = 0.8; //敵ダッシュの持続秒数
let dashleng = 300; //敵ダッシュの距離

size、scale、imagenum、imagew、imageh はプレイヤーと同じですね。
画像のサイズや枚数を変えた場合は調整しましょう。


lifetimeというのは、敵が画面内に居座り続ける秒数です。
ゲームに多数発生させる画像等は、用済みになれば.destroy()で消すのが望ましいです。

消さないと数が溜まってきて、処理が重くなります。数百個なら大丈夫だったりしますが。数千、数万は怪しいですね。PCだと大丈夫だけどスマホだと重い、ということもありえます。

画面外に出ていって戻ってくる予定がないなら消すのが一般的です。
今回はいろいろな動きをするので、寿命lifetimeを決めてそれが終われば外へ出ていくようにしています。


パラメータが少し違う似た敵を出したければmakeene9を作ってください。
名前はmakesnakeとかでも大丈夫です。

動きを組み合わせたものや、1から作り直したものを作ってもらっても構いません。


敵出現パターン

敵出現パターンは429行目から547行目にあります。

scene.setTimeout(function() {
  makestage();
}, warmup * 1000);
function makestage() {
  switch (stage) {
    case 1:
    switch (g.game.random.get(1, 3)) {
      case 1:
      makeene1(g.game.random.get(0, 1), g.game.random.get(2, 5)/10);
      makeene4(g.game.random.get(0, 1), g.game.random.get(2, 8)/10);
      break;
      case 2:
      makeene3(g.game.random.get(0, 1), g.game.random.get(2, 8)/10);
      makeene7(g.game.random.get(0, 1), g.game.random.get(2, 8)/10);
      break;
      case 3:
      makeene5(g.game.random.get(0, 1), g.game.random.get(2, 8)/10);
      makeene6(g.game.random.get(0, 1), g.game.random.get(2, 8)/10);
      break;
    } break;
    case 2:
      makerandom(); break;

    ~ 中略 ~

    default:
    break;
  }
  scene.setTimeout(function() {
    stage ++;
    if (!finishstate) makestage();
  }, 10000);
}

 

まず最初の3行で、ウォームアップの時間を待って開始するようにしています。

その後 function makestage() { の中で、配置を設定しています。
このmakestage()最後の5行で、10秒ごとに繰り返されるようになっています。

その際にstageという数字を増やしており、毎ステージ違う敵が出てきます。



各ステージの配置は固定配置ランダム配置交互に来ます
固定配置は3種類の固定配置からランダムで一つ選ぶようになっています。

面倒だったら以下のように全部ランダム配置にしてください。

function makestage() {
  makerandom();
  scene.setTimeout(function() {
    stage ++;
    if (!finishstate) makestage();
  }, 10000);
}


ステージ数ごとの切り替えと、ランダムな切り替えはswitchを使います。
switchはif文と同じように場合分けができます。

switch (処理番号) {
  case 1:
  処理内容1
  break;
  case 2:
  処理内容2
  break;
  case 3:
  処理内容3
  break;
}


処理番号が1の場合、case 1: と break; で囲まれた処理内容を実行する形ですね。
処理番号g.game.random.get(1, 3)にすれば、ランダムで処理内容が選択されます。

厳密に言うとこれはカッコではありません。また、処理番号は処理文字列でもいけますが、
そのへんの説明は省略します。流石に怒られそうですね。
コロン : とセミコロン ; の違いに気をつけてください。ていうかコピペしてください

ステージ1の固定パターンの1個めは以下のようになっています。

case 1:
  makeene1(g.game.random.get(0, 1), g.game.random.get(2, 5)/10);
  makeene4(g.game.random.get(0, 1), g.game.random.get(2, 8)/10);
break;


makeene1makeene4で2つの敵を配置します。
それぞれのカッコの中には配置する場所の数値が入っています。横位置inixと縦位置iniyです。

ランダムを使わずにシンプルに書くと、
makeene1(0, 0); であれば画面左上に、
makeene1(0.5, 0.5); であれば画面中央に敵1を配置します。

今回の配置では、左端か右端に配置するために、横位置inixを0か1のランダムにし、
上端か下端から少し離して配置するために、縦位置iniyを0.2~0.8にしています。
さらに、敵1に関しては特性上、上半分に限って0.2~0.5にしています。


ランダム配置の方は、549行目に処理が書いてあります。

function makerandom() {
  for (let i = 0; i < stage/3 + 1; i++) {
    switch (g.game.random.get(1, 8)) {
      case 1: makeene1(g.game.random.get(0, 1), g.game.random.get(2, 5)/10); break;
      case 2: makeene2(g.game.random.get(0, 1), g.game.random.get(2, 8)/10); break;
      case 3: makeene3(g.game.random.get(0, 1), g.game.random.get(2, 8)/10); break;
      case 4: makeene4(g.game.random.get(0, 1), g.game.random.get(2, 8)/10); break;
      case 5: makeene5(g.game.random.get(0, 1), g.game.random.get(2, 8)/10); break;
      case 6: makeene6(g.game.random.get(0, 1), g.game.random.get(2, 8)/10); break;
      case 7: makeene7(g.game.random.get(0, 1), g.game.random.get(2, 8)/10); break;
      case 8: makeene8(g.game.random.get(0, 1), g.game.random.get(2, 8)/10); break;
      default:
    }
  }
}


まずforの繰り返しで配置する数を決めています。

数はstage/3 + 1としていて、ステージが進むにつれて数は増えます。
固定の数字でもいいんですが、この場合ステージ10までで2→5に上がっていきます。

もっと出す数を増やしたかったらstageだけでもいいですね。
stage*100でも動きましたが、ちょっと重かったのでやっぱり1000体近いときついですね。


完全にランダムにするのは楽ですが、同じ場所に配置してしまったり、難易度が不安定になってしまったり、敵の組み合わせによるシナジーを表現できません。
このため、固定の配置とランダム配置が交互に来るようにしています。


アイテムパラメータ変更

アイテムの配置も基本同じですが、敵と違って一つの画像に様々な種類をまとめています。

1192行から1239行のところがアイテム配置の処理です。
let imagenum = 4; //アイテム画像の分割枚数のところでアイテム種類数を指定しています。


let itemtable = [ 0, 0, 0, 0, 1, 1, 1, 2, 2, 3];

let scoretable = [20, 30, 50, 100];
scene.setTimeout(function() {
  makeitems();
}, warmup * 1000);
function makeitems() { //アイテム設置
  for (let i = 0; i < Math.floor(stage/3)+2; i++) {
  makeitem(0.1 + 0.8*g.game.random.generate(), 0.15 + 0.7*g.game.random.generate(), itemtable[g.game.random.get(0, itemtable.length-1)]);
  }
  scene.setTimeout(function() {
    if (!finishstate) makeitems();
  }, 3000);
}
function makeitem(x, y, type) { //アイテム設置
  let size = 40; //アイテムの当たり判定の大きさ 直径
  let scale = 1.25; //アイテム画像の倍率 当たり判定は別
  let imagenum = 4; //アイテム画像の分割枚数
  let imagew = 50; //アイテム画像の分割 横幅
  let imageh = 50; //アイテム画像の分割 縦高さ
  let yokokutime = 12; //アイテム消滅の予告点滅が始まる秒数
  let lifetime = 15; //アイテムの生存秒数

 ~ 中略 ~

}


用意した画像を分割して左から順番に番号が振られます。
4分割なので0~3の番号がアイテムの種類と対応します。

  

各アイテムの出現確率を変えるため、let itemtable = [ 0, 0, 0, 0, 1, 1, 1, 2, 2, 3];
というテーブルを作り、この中からランダムで選んで出現確率を変えます。
0.5%とかに設定したい場合は、かなり書き方が変わるので紹介はまたの機会に。

それぞれのアイテムから得られる得点として、let scoretable = [20, 30, 50, 100];
というテーブルで管理します。
scoretable[1]とすれば、1番のスコアである30を引用することができます。

アイテムが登場するタイミングは3秒毎になっています。

残念ながら、アイテムをアニメーションさせたい場合は全体的に作り変える必要があります。





まだ半分ですが、ここまでを色々変更すればゲームを変えられると思います。

ここからは、今回使っている処理の解説がメインになります。


タッチ操作

タッチ操作が今回のメインですね。

233行目辺りから以下のような処理があります。

let touch = new g.FilledRect({// タッチ画面生成
  scene: scene, cssColor: "black", parent: touchlayer,
  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,
});
let arrow = new g.Sprite({
  scene: scene, src: scene.assets["arrow"], parent: uilayer,
  x: 0, y: 2000, scaleX: 0.4, scaleY: 0.6,
  anchorX: 0.5, anchorY: 0.5, opacity: 0, touchable: false,
});
let pointx;
let pointy;
touch.onPointDown.add(function(ev) {//操作押し込み
  if (touchstate == false && startstate) {
    touchstate = true;
    if (player.tag.state != "dash") {
      pointx = ev.point.x;
      pointy = ev.point.y;
    }
  }
});
touch.onPointMove.add(function(ev) {//操作移動
  if (touchstate == true) {
    if (player.tag.state != "dash") {
      pointx = ev.point.x + ev.startDelta.x;
      pointy = ev.point.y + ev.startDelta.y;
    }
  }
});
touch.onPointUp.add(function(ev) {//操作リリース
  if (touchstate == true){
    touchstate = false;
    if (player.tag.state != "hit" && player.tag.state != "dash") {
      if (charge == 1) {
        chargeframe = 0;
        player.tag.state = "dash";
        player.tag.frame = 0;
        player.interval = 30;
        player.start();
        // if(soundstate) scene.assets["se_dash"].play().changeVolume(1);
      } else {
        player.tag.state = "stop";
        player.stop();
        player.frameNumber = 0;
      }
    }
  }
});
touch.onUpdate.add(function() {//矢印表示
  if (touchstate && startstate){
    if (player.tag.state == "stop" || player.tag.state == "walk") {
      let controlx = pointx - player.x;
      let controly = pointy - player.y;
      let leng = Math.sqrt(controlx**2+controly**2);
      let angle = 0;
      if (leng < playercenter){
        arrow.opacity = 0;
        player.tag.state = "stop";
        player.stop();
        player.frameNumber = 0;
      } else {
        arrow.opacity = 0.7;
        angle = -Math.atan2(controlx, controly)*180/Math.PI+90;
        arrow.angle = angle;
        player.tag.dir = angle;
        player.interval = 150;
        if (player.tag.state != "walk") player.start();
          player.tag.state = "walk";
        }
        arrow.x = pointx;
        arrow.y = pointy;
      }
    } else {
      arrow.opacity = 0;
    }
  arrow.modified();
});

まず最初にtouchという画面サイズと同じ大きさの四角を配置しています。
透明ですが、touchableをtrueにしているので基本的にこのtouchを触ることになります。

音用のボタンなどはレイヤーでtouchより手前にしてあるのでポイントできます。



そして3つのタッチ関係の処理を使っています。touch.onPointDown.add
touch.onPointMove.addtouch.onPointUp.addの3つです。

これらの処理の中では、functionにfunction(ev)と入れておくと以下の数値を使えます。

 ev.point.x     最初にポイントした場所のX座標
 ev.startDelta.x  最初にポイントした場所から動かした総量(PointDownでは使えない)
 ev.prevDelta.x  その瞬間に動かした量 (PointDownでは使えない)

厳密な定義はちょっと違うので、興味あればこちらを確認ください。
それぞれ.yもあります。


また、touch.onPointMove.addは押したまま動かない場合は処理されません。
押したままの状態か、既にはなした状態かで処理が変わる場合を考慮し、touchstateという値を用意して、touch.onUpdate.addで常に監視することにしています。

これが最善かどうかわかりませんが、マルチタッチの複雑な状況も排除できている・・・かもしれません。



プレイヤー操作の処理の流れはこの様になっています。

  1. onPointDownとonPointMoveでpointxとpointyという現在座標を出す
  2. onUpdateで、プレイヤーの進むべき方向angleを計算する
  3. ポイントしている場所がプレイヤーと近ければ止まらせる
  4. player.tag.dirという値に角度angleをいれる
  5. あとは、327行目のplayer.onUpdate.addに任せる

onPointUpダッシュを開始するかどうかの処理にのみ使っています。


angleの計算Math.atan2というものを使えばシンプルにできました。

Math.acosを使っていますが、これは角度は0度と180度のときに使えないので場合分けが複雑になってしまっています。
誰か簡単に計算する方法を知っていたら教えてください。

コピペして使えるようなら無視してそのまま使ってください。


フレームアニメーション

すでに何度か使っていますがFrameSpriteではアニメーションをさせることができます。

let enemy = new g.FrameSprite({
  scene: scene, src: scene.assets["ene2"], parent: enemylayer,
  x: g.game.width*inix, y: g.game.height*iniy, scaleX: inix<0.5 ? scale : -scale, scaleY: scale,
  width: imagew, height: imageh, srcWidth: imagew, srcHeight: imageh, frames: frames, interval: 150, loop: true,
  anchorX: 0.5, anchorY: 0.5, opacity: 1, touchable: false,
  tag: {state: "walk", vx: inix<0.5 ? 1 : -1, vy: 0, frame: -1, lifeframe: 0, targetx: 0, targety: 0},
});
enemy.start();


FrameSpriteの場合はwidth、height、srcWidth、srcHeight、framesが必要です。
ただのSpriteの場合はこの行はなくていいです。srcWidth、srcHeightの2つの値で、画像を切り取って番号をつけていきます。
画像の並べ方は横一列や縦一列だけでなく5x5などでも大丈夫です。

framesのところに[0, 1, 2, 3]のようなアニメーションの順番が書かれた配列を入れます。
[0] や [3, 1, 1, 1]のような変則的なものでも大丈夫です。

アニメーションさせない場合は切替可能な画像のセットのような使い方もできます。
enemy.frameNumber = 1; enemy.modified();とすると画像を切り替えられます。

この場合、配列の中の2番めの画像に変わります。

  


アニメーションさせる場合は追加でloopとintervalも指定できます。

loopはtrueにしていればアニメーションが途切れずにループします。基本trueですね。

interval画像の切り替えの時間ですね。100なら100ミリ秒です。
指定しない場合はg.game.fpsに合わせてくれます。33ミリ秒ぐらいですね。

アニメーションはenemy.start()と書かれた処理が行われるまで動きません。
ただし、常にこの処理が行われるようなところに置くと、毎回最初の画像だけ表示されてしまうので、1回ずつでしか処理されないところにおいてください。
enemy.stop()とすると止まります。


プレイヤーと敵キャラの状態

プレイヤーと敵キャラの処理は状態.tag.stateで切り替えるようにしました。

敵キャラの場合はタイプが様々ですが、基本的に.tag.state
"stop" → "walk" → "dash" → "stop" の順番で切り替わります。


プレイヤーが敵にあたった場合は、 "hit"になったあとに一定時間"stop"に戻ります。
この制限時間の処理は以下のように.tag.frameのカウントで制御し、
ある値を超えたら次の状態に移るようになっています。


if (player.tag.frame >= 0) player.tag.frame ++;

 ~ 中略 ~

if (player.tag.state == "hit"){
  if (player.tag.frame > playerhittime * g.game.fps) {
    player.tag.state = "stop";
    player.stop();
    player.frameNumber = 0;
    player.opacity = 1;
  } else {
    player.opacity = 0;
  }
}

.tag.frameは状態変化に応じて適宜0にし、カウンターをリセットしています。
また、初期の状態では値を-1にしてあって、その状態では制限時間の判定が無効になります。

無敵時間の方は状態.tag.stateとは絡めずに独立のカウンターで管理し、
無敵時間中でもダッシュや停止ができるようになっています。



敵キャラクターにも"hit"の状態があり、プレイヤーがパワーアップ中のダッシュに敵が当たると"hit"状態になります。

こうなると、他の処理を無視して斜め上方向に飛んでいきます

if (enemy.tag.state == "hit"){
  enemy.x += inix<0.5 ? -enemyhitspeed : enemyhitspeed;
  enemy.y -= enemyhitspeed;
} else {

 ~ 他の処理 ~

}



プレイヤーの見た目変化

プレイヤーは敵にあたったときと、アイテムが揃ったときに見た目の画像が変わります。

今回はプレイヤーにアニメーションをつけてしまったので、状態変化による見た目の画像変化は別途画像を用意しました。

playerhitfireの画像は普段はopacityが0ですが、常にplayerの位置についていきます。
必要なタイミングでopacityを1にし、役目が終わればまた0に戻ります。

fire.onUpdate.add(function () {
  fire.x = player.x;
  fire.y = player.y-12;
  if (fire.tag.frame >= 0) {
    fire.tag.frame ++;
    if (fire.tag.frame < playerpoweruptime * g.game.fps) {
      fire.opacity = 1;
    } else {
      fire.tag.frame = -1;
      fire.opacity = 0;
      power = 0;
    }
  }
  fire.modified();
});



ヒット判定

敵キャラがプレイヤーにあたったかどうかはhitcircleで判定しています。

今回は敵もアイテムも、円形の当たり判定で統一しました。
縦長などの敵の場合は、判定式を作り直す必要があります。


function hitcircle(enemy, size) { //円状の敵とプレイヤーのヒット判定処理
  if (player.tag.state != "hit") {
    if (((enemy.x-player.x)**2 + (enemy.y-player.y)**2) < (size/2 + playersize/2)**2){
      if (fire.tag.frame < 0) {
        if (player.tag.mutekiframe < 0){
          player.tag.state = "hit";
          // if (soundstate) scene.assets["se_hit"].play().changeVolume(1);
          player.tag.frame = 0;
          player.tag.mutekiframe = 0;
          player.tag.mutekitime = playermutekitime;
          playerhit.tag.frame = 0;
          if (!finishstate) g.game.vars.gameState.score -= 100;
          makelabel(scorelabel.x - 50, scorelabel.y + 50, "" + (-100), uilayer);
        }
      } else {
        if (player.tag.state == "dash"){
          if (!finishstate) g.game.vars.gameState.score += 100;
          makelabel(scorelabel.x - 50, scorelabel.y + 50, "+" + 100, uilayer);
          enemy.tag.state = "hit";
          // if (soundstate) scene.assets["se_attack"].play().changeVolume(1);
        }
      }
    }
  }
}


また、この処理の中ではenemy.xのようにenemyの値を使用します。

この関数はmakeene1などの中で呼び出されますが、カッコの中にenemyをいれてhitcircle(enemy, size);のようにして呼び出します。

これによってmakeene1の中で配置された各enemyの情報をもとに判定処理がなされます。




また長くなってしまいましたが、これで終わりです。

ある程度使い回せるものを用意しようとすると、多くなってしまいますね。
次は使い回しというより参考にするための単純な例で短く作りたいところです。

今回のファイルも記事冒頭においてあります。

画像含め使いまわしてもらって構いません。



[リリース後追記]

リリースしたダックダッシュですが、

割と評判が悪いです。


まあちょっと思いあたる節もあるので、反省点をまとめていきます。

時間が長い

今回は複数の敵がいるので、回避方法が構築される時間があればと思い、
長めの100秒にしていましたが、どうも長いようです。

やはり70秒以下がいいですね。

攻略方法を構築したくなるようなゲームジャンルであれば長くてもいいのかもしれませんが、
このアクションだと敵に受動的になるのでネガティブ印象で固定されるのかもしれません。


敵が多い

テストしていると無駄にプレイスキルが上がり簡単に対処できるようになります。

難易度が上昇するタイプのアクションは、最終局面のみ自分がやりごたえを感じ、
序盤は退屈になる、ぐらいで初見の人にも優しい難易度になるのかもしれません。

たくさんいる敵をまとめて倒したりできればいいなとも思ってたのですが、
少なくとも終盤以外は半分ぐらいで良かったかもしれません。

stage/3+1という比例する形よりも、
1+1*(stage>2)+1*(stage>5)+1*(stage>6)とか、
let enemytable = [0, 1, 1, 2, 2, 2, 3, 4]; を作っておいて enemytable[stage] とか、
にしてステージ7までにする方法もあったかと思います。


敵が機敏すぎる

攻略方法も含めて自分で考えているので、つい簡単に避けられるようになりますが、
ニコ生ゲームは人によっては一生で一度しかプレイしないかもしれません。

攻略法を知らなくても避けれるレベルには、回避のしやすさや予備動作による予告などが必要でしたね。犬、蛇、カニ あたりは初見だときつかったかもしれません。

いっそのことダッシュ中無敵にして、ローリング回避風にしたほうが良かったかも。
これは1165行目を少し長くして下のようにすれば実現できると思います。

  変更前 if (player.tag.state != "dash"){

  変更後 if (player.tag.mutekiframe < 0 && player.tag.state != "dash"){



再利用したいという方がいれば、参考にしていただければと思います…

黒歴史化しそうだし、誰かこの作品が忘れられるレベルの奴に作り直してくれないかなあ。


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

ニコ生ゲームを作ろうと思ったらすぐコピペしよう その1

今回からはシンプルなゲームを用意し、かいつまんで中身の解説をしていきます。

とりあえず出来上がったものがこちらになります。

これはアツマール用にエクスポートしたzipではなく、ファイルをまとめただけです。
音ファイルはありません。main.jsの音の記載も無効にしてあるので、追加した場合はplay()で検索して有効にし、assetIDsの登録scan assetをやってください。
[2021/2/27更新]
公式 の再配布可能な音ファイルも入れてあります。CC BY 2.1 JP DWANGO Co., Ltd.


絵はあまり書いていませんが、こちらになります。

ゲームはこんな感じです。アツマールはこちらにあります。

  


■目次

  • コメント表記
  • 画像入れ替え
  • ランダム出題
  • レイヤー生成
  • フォント作成
  • 時間管理
  • 背景
  • ランダム配置
  • var から let へ
  • マルチタップ対策(同時押し排除)
  • ステージ進行
  • 画面内で反射する画像
  • 重なったときのクリック判定
  • 定期的な得点ボーナス
  • 画像重ねの表示順整理
  • ランキング登録
  • ランキングボタンとリスタートボタン

今回は基本機能が多いので項目が多くなっていますが、次回以降は半分もないと思います。

そもそも使うだけだったら中身を理解せずに、コピペしてそのまま使えばOKです。




コメント表記

main.jsを開くと、最初の60行は無駄なものがおいてあります。

これは何かあったときにコピペしようとおいているだけで、プログラム上は処理されません。

たしかコメント機能という名前で良かったと思いますが、行頭に「//」がある、または、
 /*  */ のカッコでくくられている箇所はコメントになりプログラム上無効になります。


画像入れ替え

今回のゲームは画像を置き換えて、数値を変更するとそのまま使えるはずです。

例えばこちらの画像に差し替えて、akashic scan assetをやります。



114行目の数値を以下のように書き換えるとそのまま動きます。

let imagenum = 20; //用意した画像の枚数
let imagew = 120; //用意した画像の1枚の幅
let imageh = 120; //用意した画像の1枚の高さ
let imagescale = 1; //選択する画像の倍率
let odaiscale = 2; //右に表示するお題の倍率
let speed = 2;

20枚だとちょっと簡単なので50枚近く用意するか、スピードをいじったり拡大倍率をいじって調整すると良いでしょう。

今回のひらがなの画像は、後述するbmpfont-generatorを使いました。
上の差し替え用の画像は、こちらのサイトを使って結合しました。

ランダム出題

そもそもランダムな数字を用意するにはいくつか方法があります。
 g.game.random.get(0, 3)    0,1,2,3から4択でランダム選択 マイナスも使用可
 g.game.random.generate()   0~1の数値をランダム生成 0は含むが1は含まない

これを使って、ランダムな出題を生成します。(123行目)
毎回ランダムに選択してしまうと、一定確率で同じ文字が現れて面倒なことになります。
そのため出題は46個の文字を並べ替えて生成します。

let ans = ; //出題ランダム化
  for (let i = 0; i < imagenum ; i++) {//入れ替え用配列作成
    shuffle[i] = i;
  }
for (let i = 0; i < imagenum ; i++) {//入れ替え 代入
  let select = g.game.random.get(0, shuffle.length-1);
  ans[i] = shuffle.splice(select,1);
}

これがなにをやっているかというと、先に以下のような配列を作ります。

  shuffle = [0,1,2,3]

そしてランダムに一つ選んで移します。

  shuffle = [0,2,3]   ans = [1]
  shuffle = [0,3]    ans = [1,2]
  shuffle = [3]     ans = [1,2,0]
  shuffle =      ans = [1,2,0,3]

このようにして、ランダムな順番で数字が格納された配列ansを作ります。


また、これら以外にも param.random というのもあります。
今回は詳細は省略しますが、全員に同じランダム結果を提供したい場合につかいます。
同じゲーム内容にすることで、運ゲー要素を減らし競技性を確保することができます。

今回のゲームはどちらにするか迷いどころだったのですが、放送者がひらがなを先に口にしてしまう可能性もあるので、バラバラの出題にしました。
また、ゲームによってはサブアカウントで出題を確認し、本命アカウントで正解を選ぶ、ということもできてしまいます。


レイヤー生成

今回は先にレイヤーを作っておき、各種配置の際にこのレイヤーに乗せるようにします。

let backlayer = new g.E({ scene: scene, parent: scene });
let movelayer = new g.E({ scene: scene, parent: scene });
let uilayer = new g.E({ scene: scene, parent: scene });
let touchlayer = new g.E({ scene: scene, parent: scene });
let buttonlayer = new g.E({ scene: scene, parent: scene });

このレイヤーの重ね順は変わらないので、今後どの順番で画像などを追加しても、parentに上記のlayer名を入れておけば、ある程度重ね順を制御することができます。

レイヤーを.destroy()することでまとめて削除したり、あえてparent:sceneはつけないでおいて、あとでscene.append(layer);まとめて追加、といった使い方もできます


フォント作成

スコア表示などに使うフォントはbmpfont-generatorを使って作成しました。
こちらに使い方が書いてあります。


ただし、割と深刻な落とし穴が2つありました。

上のサイトに使い方例として以下のコマンドが書いてあるのですが、

bmpfont-generator --chars '0123456789' --height 14 --fill '#ff0000' sourceFont.ttf bitmap.png

色を指定するカッコが間違っている気がします。こちらならうまくいきます。

bmpfont-generator --chars '0123456789' --height 14 --fill "#ff0000" sourceFont.ttf bitmap.png



もう一つは、出力された _glyphs.json ファイルがそのままでは動かない気がします。

最初の {"map": と
最後の ,"missingGlyph":{ ~~~ },  ~~~ }
を消せばうまくいきます。



こちらのように {"32: から始まって }} で終わればOK。
32という数字は変わるかもしれません。





最終的に今回使ったコマンドはこちら。フォントのttfファイルは各自で用意願います。
フォントサイズは大きいときれいですが、画像サイズも大きくなります。
文字数次第ですが。

  bmpfont-generator rounded-x-mplus-1c-black.ttf hira.png -c あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん -H 96 -F "black" --margin 8

  bmpfont-generator rounded-x-mplus-1c-black.ttf round.png -f round.txt -H 96 -F "black" --margin 8

ここで使っているように -f round.txt のように書くと、フォントにしたい文字を入力したテキストファイルを先に作っておき、名前を指定して読み込みこともできます。
その場合はテキストファイルは UTF-8 で保存しましょう。

そして、出力されたPNG画像をこちらのサイトなどで白く縁取りすると、背景色によらずに見やすいフォントになります。

4ptの縁取りを入れるため、コマンドのところに--margin 8 と追加して8ptのマージンを入れておきました。
上記のサイトだと、縁取りを入れたときに画像が大きくなってしまうのがネックですね。うまいトリミングのやり方を考えるか、ちゃんとした画像編集ソフトを導入してください。


時間管理

今回はウォームアップとゲーム中を分けるため時間の変数を2つにしました。

scene.onUpdate.add(function () {//時間経過
  if (warmup >= 0) warmup -= 1 / g.game.fps;
  if (warmup < 0) startstate = true;
  if (warmup < 0) gametime += 1 / g.game.fps;
});

先にwarmupをカウントして、warmupが切れたらgametimeを数え始めます。
また、startstateという変数を用意して、ゲーム中かどうかの判定をしやすくしました。

今回はゲーム開始音を泥棒バスターの「よーい、スタート」を使っています。
「よーい」の分を早めに再生するため、開始から6秒待って処理しています。

scene.setTimeout(function() {
  if (soundstate) scene.assets["se_start"].play();
}, 6*1000);//warmupは動的なので固定数を指定

このscene.setTimeout(function() {も割とよく使います。
秒数の指定はミリ秒で、カッコの最後にあります。

エディタに出てくる候補にsetTimeoutという短いものが出てくることがありますがこれはそのまま使ってはいけません。必ず頭にscene.をつけましょう


背景

背景は何かしらあったほうがいいですね。

放送の背景画像をゲームに合わせて変えてくれる方もいらっしゃるので完全に塞ぐともったいないですが、完全な透過にするとゲームの表示が見づらくなることがあります。

また、アツマールの背景が真っ黒なのと、放送でも真っ黒の背景を使う人が多いので、黒い表示が埋もれたりしないように、何らかの色を配置するのが良いです。

最近良く使ってるのは薄い白です。

let background = new g.FilledRect({ //背景
  scene: scene, cssColor: "white", parent: backlayer,
  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.4, touchable: true,
});


ランダム配置

さきほどのランダム数値を使って、配置をランダムにしています。

let image = new g.FrameSprite({
  scene: scene, src: scene.assets["odai"], parent: movelayer,
  x: g.game.width*(0.1+0.6*g.game.random.generate()),
  y: g.game.height*(0.1+0.8*g.game.random.generate()),
  scaleX: imagescale, scaleY: imagescale,
  width: imagew, height: imageh, srcWidth: imagew, srcHeight: imageh, frames: [ans[i]],
  anchorX: 0.5, anchorY: 0.5, opacity: 0, touchable: false,
  tag: 360*g.game.random.generate(),
});

それぞれ 0.1~0.70.1~0.9 の間に収まるようになっています。

また、tagのところに、imageが進む方向の角度を入れてあります。
こちらは 0以上360未満 の数字が入っています。

var から let へ

今回は var という表記をやめて全面的に let を使っています。
akashic-engineではなくjavascriptの仕様でこちらのほうがいいらしいです。

細かいことは知りたくもないのですが、今回は実害が出たので変えることにしました。
236行目からの以下のところで、var i = 0とするとうまく動きません。

for (let i = 0; i < imagenum; i++) {
  let image = new g.FrameSprite({
    ︙
  });
  image.onPointDown.add(function(ev) {
    if (startstate && !finishstate) {
      if (i == stage) {
        g.game.vars.gameState.score += 100;
        if (soundstate) scene.assets["se_seikai"].play();
          stage++;
        point = 1000;
      }
    }
  });
  image.onUpdate.add(function () {//画像移動・画像表示ONOFF・タッチ判定更新
    ︙
  });
}

forの繰り返しの中の、onPointDown.addの中で、iを使うとだめな気がします。

letの方のデメリットはあるんでしょうか。わかりません。


マルチタップ対策(同時押し排除)

ニコ生ゲームを作ると、想定外の動きで得点が加速してしまうことがあります。

一つは連打ツールの絨毯爆撃
もう一つは、タッチパネルのマルチタップです。

PCで開発しているとつい忘れるのですが、本来は1回しか押せないはずの得点ボタンをマルチタップで2回やそれ以上押してしまうと、得点が倍になったりします。

押したらそのまま無効になるようにすればいいのですが、1フレームレベルで同時に押すと有効な状態で複数回押したことになる気がします。

このため、下のように押したあとに処理を続けるかの判定を追加し、そのまま判定用の数値を変更しています。

image.onPointDown.add(function(ev) {
  if (startstate && !finishstate) {
    if (i == stage) {
      g.game.vars.gameState.score += 100;
      if (soundstate) scene.assets["se_seikai"].play();
      stage++;
      point = 1000;
    }
  }
});

これで多分対策できていると思います…
確認するためには同時押しをマスターしないといけないのでちょっと難しいです。

ルーターを使っていれば、スマホやタブレットで
http://(PCのローカルIPを調べて入力):3000/game/
にアクセスするとテストすることができます。同時押しは練習しないとできませんが。


画面内で反射する画像

image.tagを進行方向の角度にしたので、x方向とy方向の移動量を計算して動かします。

四方の壁に到達したら、角度を反転させて戻ってくるようにしています。
今回のケースではたぶん大丈夫ですが、壁に埋め込まれて戻ってこなくならないように一度壁の真上に移動させてから角度を反転させています。

image.onUpdate.add(function () {//画像移動・画像表示ONOFF・タッチ判定更新
  image.x += speed * Math.cos(Math.PI*image.tag/180);
  image.y += speed * Math.sin(Math.PI*image.tag/180);
  if (image.x < g.game.width*0.1) {
    image.x = g.game.width*0.1;
    image.tag = 90 - (image.tag - 90);
  }
  if (image.x > g.game.width*0.7) {
    image.x = g.game.width*0.7;
    image.tag = 90 - (image.tag - 90);
  }
  if (image.y < g.game.height*0.1) {
    image.y = g.game.height*0.1;
    image.tag = - image.tag;
  }
  if (image.y > g.game.height*0.9) {
    image.y = g.game.height*0.9;
    image.tag = - image.tag;
  }
   ︙
});

今回は角度をimage.tagで保存するようにしたので、反射角度の計算が直感的でなくなってしまいましたが tag : { vx : 1 ,vy : 1} のように保存すれば、

if (image.x < g.game.width*0.1) {
  image.x = g.game.width*0.1;
  image.tag.vx = - image.tag.vx;
}

という感じで反転するだけでいけます。

重なった時のクリック判定

今回は複数の画像をばらまいているので、重なって見えなくなっている画像が多々あります。
この時、奥にある画像をクリックしようとすることは可能なのでしょうか。

答えは、手前の画像がクリックできないようになっていれば可能です。

画像・ラベル・四角などにはtouchableという値を設定できます。
これがtrueになっていればクリックが可能で、onPointDown.addを利用できます。
falseになっていればonPointDown.addは無効になります。

touchableがfalseだとクリックしても無かったものとされ、奥にあるものをクリックしたことになります。

そしてtouchableがtrueのものが複数重なっている場合は、一番手前のみクリックしたことになります。

クリックはtouchableがtrueのものの中で一番手前のもののみクリックしたことになる、という言い方もできるでしょうか。


今回のゲームではステージ数と一致した番号の画像のみtouchableをtrueにし、それ以外の画像はすべてfalseにしています。

image.onUpdate.add(function () {//画像移動・画像表示ONOFF・タッチ判定更新
   ︙
  if (i == stage){image.touchable = true;
  } else { image.touchable = false;}
  image.modified();
});

そして背景にあるbackgroundもtouchableをtrueにしていて、これをクリックすると減点するようにしています。正解の画像をクリックしたときは、その先にあるbackgroundはクリックしていないことになります。

右側の情報エリアをクリックして減点されると若干理不尽なので、rightbackという半透明の四角をbackgroundにかぶせています。
rightbackはtouchableがtrueなので、そのエリアのクリックを無効化できています。


定期的な得点ボーナス

あまり簡単すぎるとすぐに全消しできて、得点がみんな同じ様になってしまいます。
今回は画像を46枚も用意しましたし、テストプレイで一回も全消しできなかったのでたぶん大丈夫でしょう。

ただし、万が一のことがあるので正解時以外にも細かく点が入るようにしましょう。

ニコパニのように全クリ後にタイムボーナスが入るパターンや、制限時間付きでコンボ判定をしたりと、色々なやり方があると思います。

今回は、進行状況に応じて毎秒のボーナスが上がるようにしてみました。

scene.onUpdate.add(function () {//ステージ継続ボーナス
 if (startstate && !finishstate && g.game.age%g.game.fps==0) g.game.vars.gameState.score += stage;
});

ここで出てくるg.game.ageゲーム起動時からカウントしたフレーム数です。
1秒あたりのフレーム数g.game.fpsなので、これで割り算をすると経過時間がわかります。

割り算の余りg.game.age % g.game.fpsが0だとぴったりの秒数経過したことになります。

ゲーム中のみ加算するようにstartstate && !finishstateという条件も追加しています。
finishstateには!をつけていますが、これは反転する効果があります。通常はfinishstateがtrueのときという条件ですが、finishstateがtrueではないときという条件に変わります。


画像重ねの表示順整理

今回のゲームでは正解の順に画像を貼り付けているので、重なりの一番奥にあるものが正解というわかりやすい状況になっていました。

重なる順序をランダムに並び替えるため以下の処理を行います。

movelayer.children.sort( (a, b) => a.tag - b.tag);


画像はmovelayerにまとめて配置していたので、そこにあるものはmovelayer.childrenとするとまとめて選択できるようです。

さらに.tagの値を基準に、.sortで並び替えています。.tagは進行方向を決めるためにランダムな値を保存していたのでこれを利用しています。


少し脱線しますがこの.children.sortは奥行きを表現するのに便利です。
ベルトスクロールアクションのようにキャラクターが移動して奥に行くことがある場合、ゲーム進行に応じて適宜重なり順を更新する必要があります。

scene.onUpdate.add(function () {
  layer.children.sort( (a, b) => a.y - b.y);
});

このように画像の縦の位置 y常にソートすると奥行きがあるように見えます。

 

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

 

ランキング登録

アツマール向けには、ランキング登録関係の処理があります。

ランキング登録はゲームが終了したときにのみ処理します。
1度だけになるようにfinishstateという変数で管理しています。

.setRecordはやたら長いですが、カッコになっています。
そのカッコの中に.displayが入っています。

scene.onUpdate.add(function () { //終了したときに1度だけ処理する内容
 if (!finishstate && gametime > timelimit) {
  finishstate = true;
  if (soundstate) scene.assets["se_finish"].play();
  if (param.isAtsumaru) {//アツマールのときのみ
   window.RPGAtsumaru.experimental.scoreboards.setRecord(1, g.game.vars.gameState.score).then(function () {
    window.RPGAtsumaru.experimental.scoreboards.display(1);
   });
  }
 }
});

.setRecordが終わってから.displayを処理するようになっています。まだ登録が終わっていないのにスコアボードを表示してしまうことがないようにするためです。

あと()の中の1を変えると、スコアボードの番号を変えられます。
1ゲームあたり30個用意されているので、モード等によって変えることができます。


ランキングボタンとリスタートボタン

ランキングとリスタートを表示するボタンも追加します。
if (param.isAtsumaru)を使って、アツマールのときにしか機能しないようにしています。

通常はゲーム終了時に表示させていますが、ゲーム序盤でリセットしたくなりそうなゲームの場合は最初から表示させています。

if (param.isAtsumaru) {//アツマールのときのみ配置
  let restart = new g.Sprite({ // リスタートボタン
    scene: scene, src: scene.assets["restart"], parent: buttonlayer,
    x: g.game.width*0.86, y: g.game.height*0.26, scaleX: 1, scaleY: 1,
    anchorX: 0.5, anchorY: 0.5, touchable: true,
  });
  let ranking = new g.Sprite({ //ランキングボタン
    scene: scene, src: scene.assets["ranking"], parent: buttonlayer,
    x: g.game.width*0.94, y: g.game.height*0.26, scaleX: 1, scaleY: 1,
    anchorX: 0.5, anchorY: 0.5, touchable: true,
  });
  restart.onPointDown.add(function(event) {// リスタート操作
    g.game.replaceScene(makescene(param));
  });
  ranking.onPointDown.add(function(scene) {// ランキング表示
    window.RPGAtsumaru.experimental.scoreboards.display(1);
  });
}

リスタートの処理をg.game.replaceScene(makescene(param));と書いていますが、これはもともとリスタートの意味はなく、新しいシーンに移る処理です。

メインのゲームをmakescene(param)という関数に格納し、それを読み込み直すことでリセットっぽくしています。


サンプルゲームはもともとこのような構造をしていました。

exports.main = void 0;
function main(param) {
    ----ここまで冒頭----

    ----ここから末尾----
  g.game.pushScene(scene);
}
exports.main = main;

main(param)という関数にほぼ全て囲まれていました。

これをmakescene(param)に入れ替えて、main(param)からはすぐに移動します。

exports.main = void 0;
let soundstate = true;
let musicstate = true;
function main(param) {
  g.game.pushScene(makescene(param));
}
function makescene(param) {
    ----ここまで冒頭----

    ----ここから末尾----
  return scene;
}
exports.main = main;

makescene(param)の中の最後の一文はg.game.pushScene(scene);からreturn sceneに変えています。

なぜこれでうまくいくのか、どこでこのやり方を見つけたのか、覚えていないのですが、これでリセットと同等の効果が得られます。

効果音とBGMのON/OFFはリセットする必要がないので、soundstateとmusicstateの登録はこれらの関数の外に置いています。



終了

以上です。

今回のファイルは記事冒頭においてあります。

画像だけ変えたのもリリースしてもらって構いません。むしろお願いします。

別ゲーだと思える程度には変えてほしいですが、このゲームは画像だけでなんとかなると思います。色合いがぜんぜん違う画像だと簡単になって面白くなるか、もしくは簡単すぎて面白くなくなると思います。

一応フォントは自家製 Rounded M+を使ってるので、利用について公式サイトで確認してもらえると助かります。


[2021/4/9]
この記事のコードを利用していただいた方が現れました! gm19238
麻雀牌はカラフルでゲーム用具として歴史があるだけあって視認性がいいんでしょうね。
全消し前提にテンポが調整されていて、ひらがなよりも人気が出そうな勢いです!


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