ニコ生ゲームを作ろうと思ったらすぐコピペしよう その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させてる処理があるのですが、こちらは後回し。