LoginSignup
9
6

【p5.js】20分で弾幕シューティングを作る

Posted at

はじめに

p5.js、大体わかってきた!」という方が、めちゃくちゃ手軽に弾幕シューティングを作るための手引きです。

とてもシンプルですが、弾幕シューティングを作るためのいくつかのエッセンスを詰めたつもりです。

それでは、行ってみましょう。

1. やりたいこと

↓ こんなのを作りたい!
シンプルすぎる弾幕シューティング.gif

また、今回はp5.jsを使う際の煩わしい設定をすべて吹き飛ばすため、オンラインでp5.jsの実行ができる p5.js Web Editor を使います。

全体の動作は私のp5.js WebEditor上のスケッチで確認できます。

インスタンスモードを使う硬派な方は適宜読み替えてください。

2. 全体の流れ

↓ 以下のコードをp5.js Web Editor にさっくりとコピペしてください。

/*
全ての弾を持っておく配列
x,y - 弾の位置
angle - 弾の進む角度 (ラジアン)
speed - 弾の速さ
size - 弾の大きさ
*/
let bullets = [];
// プレイヤー情報。init()で設定
let player = { x: 0, y: 0, size: 0, moveSpeed: 0 };

// 毎フレーム実行され、良い感じのタイミングで弾を発射する。
function shoot() {}

// 以下は基盤

function setup() {
  createCanvas(400, 500);
  frameRate(60);
  noStroke();
  init();
}

// ゲームをリセットするときに呼ぶ
function init() {
  bullets = [];
  player = { x: 200, y: 400, size: 15, moveSpeed: 4 };
  keys = { up: false, down: false, left: false, right: false };
  frameCount = 0;
}

function draw() {
  background(0);
  movePlayer();
  moveBullets();
  shoot();
  deleteOutBullets();
  renderPlayer();
  renderBullets();
  checkHit();
}

function movePlayer(){
  //プレイヤーを動かす
}

function moveBullets(){
  //弾を動かす
}

function deleteOutBullets(){
  //外に出た弾を消す
}

function checkHit(){
  //プレイヤーが弾に当たっているかどうかの判断と当たった場合の処理
}

function renderPlayer(){
  //プレイヤーを描く
}

function renderBullets(){
  //弾を描く
}

...これを実行すると、真っ黒な長方形があらわれると思います。

...まだ何も動きません。ここではゲームのループの説明をします。

 ゲームというのは、基本的に1秒に30か60くらいの画面更新処理を行っています。p5.jsに慣れた人は、draw() 関数がこれに対応するものだと分かるでしょう。何度も似た処理を繰り返し実行するため、メインループなどと呼ばれがちです。また、一回の画面更新のことをフレームと呼びます。

 弾幕シューティングも例外ではありません、どころか他のゲームよりも画面の更新処理が多いため、より強く意識する必要さえあります。

コードの説明を以下に書きます。大体わかっているならスキップしても構いません。

  • bullets[]は、ゲーム上に存在するすべての弾を保存しておく配列です。今回は、この配列に弾の情報をどんどん突っ込んでいくだけで全処理が動くようにします。
  • shoot()は、メインループに組み込まれた関数です(つまり、毎フレームごとに呼ばれます)。この関数でbulletsに弾の情報を追加していきます。この内容を書き換えるだけで様々なパターンの弾幕を作ることができるようにします。詳しくは次の節で。
  • setup()は、定義しておくとp5.jsがスケッチの開始時に自動で呼んでくれるやつでしたね。frameRateは画面更新処理...つまり、drawが呼ばれる回数を、1秒間に60回と固定します。また、一部をinit()に切り出しています。
  • そんなinit()はゲームの初期化を担当します。今回は手軽に作るので、画面遷移処理とかはすべて無視してこの関数を呼べばゲームが最初からプレイできるとします。例えば、ミスした時とか。
  • draw()は画面更新処理、フレームに相当します。この中で各裏処理をすることで、shoot()を実装してbullets[]をいじるだけでゲームが動くようにします。

3. shootをとりあえず実装する

ここでは、一番 華の部分である、弾を作成する部分を実装します。shootを以下で置き換えてください。

function shoot() {
  // 200フレームを超えると何も出さなくする
  if (frameCount >= 200) return;

  // 20フレームごとに
  if (frameCount % 20 == 0) {
    // ランダムな角度で全方向弾を発射
    let deltaAngle = random(TAU);
    for (let i = 0; i < TAU; i += TAU / 40) {
      bullets.push({
        x: 200,
        y: 100,
        angle: i + deltaAngle,
        speed: 3,
        size: 10,
      });
    }
  }
}

...まだ実行しても何も出ません。というのは、弾は確かに作られていますがそれを描画していないからです。

今回は全ての角度をラジアンで扱います。詳しいことはいろいろな人が解説を出しているのでそちらを。とりあえず大事なのは360°はTAU、つまり2πラジアンに等しいということです。

コードの説明として、この関数は毎フレーム呼ばれるので、毎回弾を作っていたら大変なことになります。「20の倍数は20フレームごとに訪れる」という(とても当たり前な)ことを使って、現在のフレームカウントが20の倍数だった時、つまり20フレームごとに、弾を発射します。

4. 弾とプレイヤーを描く

弾とプレイヤーを描画することで見えるようにします。以下関数を置き換えます。

function renderPlayer() {
  push();
  fill("red");
  circle(player.x, player.y, player.size);
  pop();
}

function renderBullets() {
  for (let { x, y, size } of bullets) {
    circle(x, y, size);
  }
}

実行すると、弾が上の中心あたりに生まれているのが分かると思います。(モノを動かす処理を書いていないのでまだ弾もプレイヤーも動きません。)

これはほとんど見たとおりですが、いくつか説明をします。

  • push()pop()は現在の描画スタイルを保存し、復元する役割を持ちます。イメージとしては、p5.jsのデフォルトであるfill(255)push()で保存しておき、pop()で復元することで、fill("red")が外に影響しないようにします。
  • for (let { x, y, size } of bullets) は、
    for(let bullet of bullets){
        let x = bullet.x, y = bullet.y, size = bullet.size;
    
        //...
    }
    

 と、概ね等価です。

5. 弾を動かす

弾が動かないのは味気ないので、ここでは、弾を動かす処理を書きます。置き換えしましょう。

function moveBullets() {
  for (let bullet of bullets) {
    bullet.x += bullet.speed * cos(bullet.angle);
    bullet.y += bullet.speed * sin(bullet.angle);
  }
}

三角関数(cos, sin) が出てきました...説明は端折ります。
とにかく、ここで大事なのは、

「今いるところ$(x, y)$から角度$θ$の方向に長さ$r$だけ進んだ位置は、$(x+r×\cos(θ),y+r×\sin(θ))$ である」

ということです。今回の場合、毎フレームごとに「今いるところから角度 angle の方向に長さ speed だけ進む」ので...当てはめると上のコードのようになるのです。

6. キー入力を受け付ける

プレイヤーも動かせるようにしますが、まずその前にキー入力を受け付けるようにします。置き換えするのです。

function keyPressed() {
  switch (keyCode) {
    case UP_ARROW:
      keys.up = true;
      break;
    case DOWN_ARROW:
      keys.down = true;
      break;
    case LEFT_ARROW:
      keys.left = true;
      break;
    case RIGHT_ARROW:
      keys.right = true;
      break;
  }
}

function keyReleased() {
  switch (keyCode) {
    case UP_ARROW:
      keys.up = false;
      break;
    case DOWN_ARROW:
      keys.down = false;
      break;
    case LEFT_ARROW:
      keys.left = false;
      break;
    case RIGHT_ARROW:
      keys.right = false;
      break;
  }
}

...プレイヤーはまだ動きません。キー入力を保存しているだけだからです。

keyPressedはあるキーが押されたとき、keyReleasedはあるキーが押されなくなったときにp5.jsが勝手に呼んでくれる関数です。keyCodeでどのキーについてのイベントか判別できるので、このようにしてkeysに「どのキーが現在押されているのか?」を保存することができます。

これにより、keysは、あるキーが押されているときにそのキーに対応するところがtrueになります。例えば、keys.left がtrueであることは、左矢印キーが押されているということを表します。複数のキー入力を受け付けるにはこの方法が良いです。

7. プレイヤーを動かす

上のようにして作っておいた「現在そのキーが押されているか情報」を使ってプレイヤーを動かします。置き換え給え。

function movePlayer() {
  let xUnit = 0;
  let yUnit = 0;

  if (keys.left) xUnit--;
  if (keys.right) xUnit++;
  if (keys.up) yUnit--;
  if (keys.down) yUnit++;

  let vectorSize = sqrt(xUnit * xUnit + yUnit * yUnit);
  if (vectorSize == 0) return;
  xUnit /= vectorSize;
  yUnit /= vectorSize;

  player.x += xUnit * player.moveSpeed;
  player.y += yUnit * player.moveSpeed;

  // 枠内に押し込める
  player.x = constrain(player.x, player.size / 2, 400 - player.size / 2);
  player.y = constrain(player.y, player.size / 2, 500 - player.size / 2);
}

これでプレイヤーも動くようになります。

...「なんでこんな複雑なコードを書く必要があるのか、単純にX座標やY座標に直接speedを足せばいいじゃないか」という方のために説明をします。キーが1つしか押されないのであればそれでも問題ありませんが、キーが2つ以上押されたときに問題が発生します。右キーと上キーを押したときのことを考えてみてください。

image.png

右にspeed進み、上にもspeed進むと、合計した距離(赤線)はspeedよりも少し長くなります。ですから、普通、このやり方は取りません。

この関数では、以下の手順でプレイヤーの座標を決めます。

  1. プレイヤーが進む方向を決める。以下のコードで、プレイヤーが左に進むならxUnit=-1、右はxUnit=1、上はyUnit=-1、下はyUnit=1となるように値を決めます。動かない方向は0となります。また、例えば右下の場合、xUnit=1yUnit=1とします。

    let xUnit = 0;
    let yUnit = 0;
    
    if (keys.left) xUnit--;
    if (keys.right) xUnit++;
    if (keys.up) yUnit--;
    if (keys.down) yUnit++;
    
  2. プレイヤーが進む長さをひとまず1とする。例えば右下方向の場合、$\sqrt2$ で各座標を割ることで、進む長さを1とします(つまり、このxUnitとyUnitをプレイヤーの座標に足すと、プレイヤーは全体として右下に長さ1だけすすんだことになる)。この割る値は三平方の定理によって出てきます。

    (x方向へ進む長さ(xUnit),y方向へ進む長さ(yUnit),全体としてある角度方向に進む長さ(vectorSize)) の3つをまとめて直角三角形として見ているというわけです。相似の関係より、斜辺をその長さで割って1にすることは、他の辺を斜辺の長さで割るということで表現できます。

      let vectorSize = sqrt(xUnit * xUnit + yUnit * yUnit);
      if (vectorSize == 0) return;
      xUnit /= vectorSize;
      yUnit /= vectorSize;
    
  3. 最後に、それぞれspeed倍することで、求める移動長さになります。

    相似なので云々

      player.x += xUnit * player.moveSpeed;
      player.y += yUnit * player.moveSpeed;
    
  4. プレイヤーがキャンバス外に出ていかないように、座標を調整することもここでやっておきます。constrainは、指定した値の中に値を閉じ込めて返すp5.jsの関数です。

      // 枠内に押し込める
      player.x = constrain(player.x, player.size / 2, 400 - player.size / 2);
      player.y = constrain(player.y, player.size / 2, 500 - player.size / 2);
    

8. 弾が当たったかどうかの処理を行う

あと少しです。プレイヤーが弾に当たった時の処理を追加します。置き換えるのじゃ。

function checkHit() {
  for (let bullet of bullets) {
    let hitDistance = (bullet.size / 2 + player.size / 2) * 0.8;

    if (dist(player.x, player.y, bullet.x, bullet.y) < hitDistance) {
      init();
    }
  }
}

弾幕シューティングでは、円形の当たり判定が一般的です。全部を円形としたとき、その当たり判定は、「中心同士の距離が半径の合計以下」で表すことができます。↓

タイトルなし.png

この図では、左が「弾が当たっている状態」、右が「弾が当たっていない状態」です。弾の当たり判定を、「中心同士の距離が半径の合計以下」で表すことができるということを確認してください。

この条件が満たされたとき、プレイヤーが弾に当たっているので、init()を呼んでゲームをリセットします。(なお、弾幕シューティングでは見た目よりも当たり判定が小さいことが普通なので、ここでは半径の合計を0.8倍したものを当たる距離として使用しています。)

dist(x1, y1, x2, y2) は座標(x1, y1)と(x2, y2) の間の距離を返すp5.jsの関数です。距離の計算には通常三平方の定理を用います。弾の当たり判定についてより詳しいことは、私が過去に書いた記事をお読みください。

9. 外に出た弾を消す

これで完成だ!としてはいけません。このままでは画面外に弾のゴミがたまってしまいます。画面外に飛んで行った弾は消してやらねばなりません。(今回は弾の数はさして多くないので気にしなくても良いですが、念のため)。最後の置き換えです。

function deleteOutBullets() {
  let newBullets = [];
  for (let bullet of bullets) {
    let {x, y, size} = bullet;
    
    //弾が画面内?
    if(0 - size < x && x < 400 + size &&
           0 - size < y && y < 500 + size){
      newBullets.push(bullet);
    }
  }
  
  bullet = newBullets;
}

このコードでは、今存在している弾ごとに、その弾が画面内にあるかどうかを判定します。画面内にあるものだけを次のbullets配列としています。

なお、if文を

    // ダメなコード!
    if(0 < x && x < 400 &&
           0 < y && y < 500){

としてはいけません。xやyは中心の座標なので、中心が外に出てもまだ弾の一部は画面に残っています。よって、弾のsizeも考慮して消す処理を書く必要があります。

配列のfilter()を知っている方なら、以下のような書き方がスマートです。
(最初はこっちを使おうと思いましたが説明が大変なのでやめました)

function deleteOutBullets() {
 bullets = bullets.filter(({ x, y, size }) => {
   return 0 - size < x && x < 400 + size &&
          0 - size < y && y < 500 + size;
 });
}

完成

以上で完成です。おめでとうございます。

終わりに

これまでの取り組みで、自分の考えた弾幕を簡単に再生するためのツールができました。新しい弾幕をどんどん動かしてみてもいいですし、他のいろんな機能を追加することだってできるかと思いますよ。

全体のコード

/*
全ての弾を持っておく配列
x,y - 弾の位置
angle - 弾の進む角度 (ラジアン)
speed - 弾の速さ
size - 弾の大きさ
*/
let bullets = [];
// プレイヤー情報。init()で設定
let player = { x: 0, y: 0, size: 0, moveSpeed: 0 };

// 毎フレーム実行され、良い感じのタイミングで弾を発射する。
function shoot() {
  // 200フレームを超えると何も出さなくする
  if (frameCount >= 200) return;

  // 20フレームごとに
  if (frameCount % 20 == 0) {
    // ランダムな角度で全方向弾を発射
    let deltaAngle = random(TAU);
    for (let i = 0; i < 40; i ++) {
      bullets.push({
        x: 200,
        y: 100,
        angle: i / 40 * TAU + deltaAngle,
        speed: 3,
        size: 10,
      });
    }
  }
}

// 以下は基盤

let keys = { up: false, down: false, left: false, right: false };

function setup() {
  createCanvas(400, 500);
  frameRate(60);
  noStroke();
  init();
}

function init() {
  bullets = [];
  player = { x: 200, y: 400, size: 15, moveSpeed: 4 };
  keys = { up: false, down: false, left: false, right: false };
  frameCount = 0;
}

function draw() {
  background(0);
  movePlayer();
  moveBullets();
  shoot();
  deleteOutBullets();
  renderPlayer();
  renderBullets();
  checkHit();
}

function movePlayer() {
  let xUnit = 0;
  let yUnit = 0;

  if (keys.left) xUnit--;
  if (keys.right) xUnit++;
  if (keys.up) yUnit--;
  if (keys.down) yUnit++;

  let vectorSize = sqrt(xUnit * xUnit + yUnit * yUnit);
  if (vectorSize == 0) return;
  xUnit /= vectorSize;
  yUnit /= vectorSize;

  player.x += xUnit * player.moveSpeed;
  player.y += yUnit * player.moveSpeed;

  // 枠内に押し込める
  player.x = constrain(player.x, player.size / 2, 400 - player.size / 2);
  player.y = constrain(player.y, player.size / 2, 500 - player.size / 2);
}

function keyPressed() {
  switch (keyCode) {
    case UP_ARROW:
      keys.up = true;
      break;
    case DOWN_ARROW:
      keys.down = true;
      break;
    case LEFT_ARROW:
      keys.left = true;
      break;
    case RIGHT_ARROW:
      keys.right = true;
      break;
  }
}

function keyReleased() {
  switch (keyCode) {
    case UP_ARROW:
      keys.up = false;
      break;
    case DOWN_ARROW:
      keys.down = false;
      break;
    case LEFT_ARROW:
      keys.left = false;
      break;
    case RIGHT_ARROW:
      keys.right = false;
      break;
  }
}

function moveBullets() {
  for (let bullet of bullets) {
    bullet.x += bullet.speed * cos(bullet.angle);
    bullet.y += bullet.speed * sin(bullet.angle);
  }
}

function deleteOutBullets() {
  let newBullets = [];
  for (let bullet of bullets) {
    let {x, y, size} = bullet;
    
    //弾が画面内?
    if(0 - size < x && x < 400 + size &&
           0 - size < y && y < 500 + size){
      newBullets.push(bullet);
    }
  }
  
  bullet = newBullets;
}

function checkHit() {
  for (let bullet of bullets) {
    let hitDistance = (bullet.size / 2 + player.size / 2) * 0.8;

    if (dist(player.x, player.y, bullet.x, bullet.y) < hitDistance) {
      init();
    }
  }
}

function renderPlayer() {
  push();
  fill("red");
  circle(player.x, player.y, player.size);
  pop();
}

function renderBullets() {
  for (let { x, y, size } of bullets) {
    circle(x, y, size);
  }
}

おまけ

  • フレームごとの処理について
    弾をフレームごとに一定距離進めるコードは、本来1秒に何フレームを刻むかによって結果としての弾の速度が変化してしまうため、「前回のフレームからの時間経過」を使うことが考えられます(p5.jsのdeltaTime)。ただ、弾の作成や当たり判定などの時に考えることが非常に増加するので、やめた方がよいと私は思っています。

  • 画面遷移中の演出は?

  • クリア画面は?

  • 自機ショットは?
    この記事の裏テーマは「できるだけ手を抜いて弾幕シューティングを作る」なので、ないです。幸いにもミニマルなコードなので変形は容易いかと思います。なお、私は自機ショットのシステムはあまり好きではありません(思想)。

  • 自機狙い弾はどうする?
    atan2 を使うと...?
    私が書いた参考記事

9
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6