ニコ生ゲームを作ろうと思ったらすぐコピペしよう その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
麻雀牌はカラフルでゲーム用具として歴史があるだけあって視認性がいいんでしょうね。
全消し前提にテンポが調整されていて、ひらがなよりも人気が出そうな勢いです!


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