23
Help us understand the problem. What are the problem?

posted at

updated at

Organization

vue.jsで作る落ち物パズルゲーム

はじめに

ずっとパズルゲーム系のアプリを作成してみたくて、ついに作成してみました。
意外とWeb上での落ち物ゲーの作り方の記事が少ない印象だったので実装ポイントをまとめてみました。(ググり方が悪いだけ)
まだ、駆け出しなのでうまくまとめられませんが、読んでいただけると幸いです。

成果物 

https://sugiyama-vuejs.netlify.app/
Image from Gyazo

ポイント

使用技術

  • Vue.js 2.6.12
  • vuetify 1.11.3

参考URL

考え方のベースはこちらの記事を参考にさせていただきました。とても助かりました。ありがとうございます!!

https://note.com/syun77/n/n34aae4a81940

初期表示&リトライ

初回とリトライ時は以下のメソッドを呼べば開始の状態を作れます。

ポイントは、ボールオブジェクトの現在地をx,y座標で持つのとそれとは別に一意の通し番号を持たせておくことです。(後のボールを消す処理の時、計算に使用する)

また、classNameの差異で色を変えていますが、厳密には単純にランダムに色を決定してしまうと理論上全ての色が隣同士にならない可能性があるので配慮すべきですが、今回は確率が低い&パッと思いつかなかったのでのでスルーします。。。

qiita.vue
  computed: {
    getBalls() {
      // 通し番号
      let serialNumber = 0;
      // y座標
      for (let i = 0; i < this.yAxis; i++) {
        // x座標
        for (let j = 0; j < this.xAxis; j++) {
          // ボール1個分のオブジェクト
          let ballInfo = {
            className: this.ballClass[Math.floor(Math.random() * this.ballClass.length)], // ボールの配色をクラス名で分ける ballClass == ["lisa", "jennie", "rose", "jisoo"]クラス名は気にしないでください...
            x: j, // x位置
            y: i, // y位置
            serialNumber: serialNumber, // 通し番号
            deleteFlag: 0, // 初期値は全て表示されるので0
            breakCheck: false, // 削除対象か否かのフラグ
            primaryKey: Math.random().toString(32).substring(2), // v-forで表示させるためkeyを一意に設定
            gridArea: "grid-area: " + (i + 1) + " / " + (j + 1) + " / span 1 / span 1;" // grid-areaで実際の位置を設定
          }

          this.balls.push(ballInfo);
          serialNumber++;
        }
      }

      return this.balls
    },
  },

ボール押下時の処理

1. 削除対象のボールを取得

  • 押下したボールを基準とし隣り合うボールを1ずつ取得し同色か否かを判定
  • 同色の場合、その同色を起点とし再度隣り合うボールを1ずつ取得し同色か否かを判定
  • 削除対象が1件の場合消せないためここでreturnし後続の処理を行わない

ポイントは、基準が違うだけで上下左右のボールを取得し判定する処理は同じなので、基準が変更された場合に同じメソッドを呼び再帰的処理を呼んであげることです。

この時に、ボールオブジェクトで保持していたx,y座標から公式に当てはめて上下左右のボール位置を特定します。
(例:左隣のボール = (基準のボールのx軸から-1したx位置かつ、基準のボールのy軸と同じy位置))

qiita.vue
    // 削除対象のボールを再帰的(上下左右)に取得
    breakCheckRecursive(startingBall, selectedClassName) {
      // 上下左右に同色があるかチェック。存在した場合そのボールを起点に左右上下をチェック
      // 左(x-1 && y === y)
      const leftBall = this.balls.find(b => b.x === startingBall.x - 1 && b.y === startingBall.y);
      this.targetDelBall(leftBall, selectedClassName);

      // 右(x+1 && y === y)
      const rightBall = this.balls.find(b => b.x === startingBall.x + 1 && b.y === startingBall.y);
      this.targetDelBall(rightBall, selectedClassName);

      // 上(x === x && y - 10)
      const topBall = this.balls.find(b => b.x === startingBall.x && b.y === startingBall.y - 1);
      this.targetDelBall(topBall, selectedClassName);

      // 下(x === x && y + 10)
      const bottomBall = this.balls.find(b => b.x === startingBall.x && b.y === startingBall.y + 1);
      this.targetDelBall(bottomBall, selectedClassName);
    },
    targetDelBall(delBall, selectedClassName) {
      // 削除対象ボールのチェックと削除情報設定
      if (delBall && delBall.className === selectedClassName && delBall.deleteFlag === 0) {
        // 同色かつ表示されているボールを削除対象とする
        let ball = this.balls.find(b => b.x === delBall.x && b.y === delBall.y)
        ball.deleteFlag = 1;
        ball.breakCheck = true;
        ball.className = "";

        // 起点を変え再度breakCheckRecursiveを呼び出す
        this.breakCheckRecursive(delBall, selectedClassName);
      }
    },

2. ボール削除後の下方向へ削除した分、ボールを詰める処理

削除対象が消えた後空中に浮いているボールを下方向へ詰める処理を行わないといけません。
ここでのポイントは、削除対象のボールのy軸を1つずつ見てボール何個分下に落とすかを決定します。
注意しなければならないのはy軸から見て削除対象のボールに挟まれているパターンや削除対象のボールが縦に並んでいたりするパターンです。(若干見づらいですが、黄色を消した時の107番とか118, 108, 98, 88の縦列とか)
Image from Gyazo

qiita.vue
  // 削除対象のボールをループさせ、残っているボールのy軸方向のgrid-areaを設定
  setGridArea(ballsBackUp, breakBalls) {
      // 落下対象のボールをindexの降順で取得(下から上へ処理)
      let orderdBreakBalls = breakBalls.sort((a, b) => b.serialNumber - a.serialNumber);
      let xProcessed = [];

      // 削除対象をループ
      for (let i = 0; i < orderdBreakBalls.length; i++) {
        // 同じx軸のボールは既にgrid-areaを設定したためスキップ
        if (xProcessed.includes(orderdBreakBalls[i].x)) continue

        // 削除対象ボールを起点とし、起点より上にあるボールのgrid-areaを設定する
        let topBall = this.balls.find(b => b.x === orderdBreakBalls[i].x && b.y === (orderdBreakBalls[i].y - 1)) // 上のボール
        let downCount = 1; // 落下数
        // 先頭のy軸になるまでループ
        while (typeof topBall !== "undefined") {
          let beforeTopBallY = topBall.y; // topBallの元のy座標
          // 上のボールが表示対象のボールの場合
          if (topBall.deleteFlag === 0) {
            // grid-areaを設定
            topBall.y += downCount;
            topBall.serialNumber += downCount * 10;
            topBall.gridArea = "grid-area: " + (topBall.y + 1) + " / " + (topBall.x + 1) + " / span 1 / span 1;"
          } else {
            // 上のボールが削除対象の場合、落下数を+1にして次のループ
            downCount++;
          }

          // 更に1つ上のボール
          topBall = this.balls.find(b => b.x === topBall.x && b.y === (beforeTopBallY - 1));
        }

        // x軸方向へは1度だけ落下処理したいため処理済みのx方向を保持
        xProcessed.push(orderdBreakBalls[i].x)
      }
    },

3. ここで削除対象のボールを実際に削除

今まで保持してた削除対象ボールリストを除外します。
filterメソッドを使うことで配列を新しくします。(←認識違いでしたら申し訳ございません。。。)

qiita.vue
this.balls = this.balls.filter(b => b.deleteFlag === 0);

4. 縦列が全て消えた時に列ごと、右か左に詰めないといけない

次にx軸方向への詰め処理です。今回は右へ詰めます。
一気に何列消えるかは未知なので右への移動数を変数として持たせます。
ここでのポイントは、縦列を1列ずつ右から取得しその結果が0件の場合、その列にボールが存在しないことになるので
右への移動数が+1となります。
ここでも下方向へボールを詰めた時同様に一気に2列消えるパターンや1つ飛ばして列が消えるパターンを想定します。

qiita.vue
  // 右寄せチェック
  rightJustified() {
      // 右へ移動数
      let rightCount = 0; // 右へ移動数
      let movementFlag = false; // 右へ移動確定フラグ
      let xAxis = this.xAxis - 1; // x軸
      // x軸分ループ
      while (xAxis >= 0) {
        // x方向のリストを取得
        let xBalls = this.balls.filter(b => b.x === xAxis);

        if (xBalls.length === 0) {
          // x軸に存在しない場合
          rightCount++;
          // 左隣のx軸を取得
          let nextXBalls = this.balls.filter(b => b.x === (xAxis - 1) && b.deleteFlag === 0);
          if (nextXBalls.length !== 0) {
            // x軸を右寄せ
            this.xMovement(nextXBalls, rightCount);
          } else {
            // 左隣のx軸が取得できなかった場合、更に左隣のx軸を取得
            rightCount++;
          }

          // 他のx軸も確定で移動
          xAxis--;
          movementFlag = true;
        } else if (xBalls.length > 0 && movementFlag) {
          // 移動が確定している場合、x軸を右寄せ
          this.xMovement(xBalls, rightCount);
        }

        xAxis--;
      }
    },
    xMovement(xBalls, rightCount) {
      // 実際の右寄せ処理
      xBalls = xBalls.map(function(xBall) {
        xBall.x += rightCount;
        xBall.serialNumber += rightCount;
        xBall.gridArea = "grid-area: " + (xBall.y + 1) + " / " + (xBall.x + 1) + " / span 1 / span 1;";
        return xBalls
      });
    },

5. 最後に画面上にまだ削除対象ボールが存在するかのチェック(ゲーム続行可能か否か)

1つでも消せるボールのペアがあればゲーム続行とします。なければ終了(全てのボールが消されていても終了(要は全クリの場合))のダイアログを出してゲーム終了となります。

qiita.vue
endOfGameCheck() {
      if (this.balls.length === 0) {
        // 終了処理
        this.message = "💖🖤👑 PERFECT 👑🖤💖";
        this.endOfGame();
        return
      }

      // 削除対象ボールの存在チェック
      const breakBallsYes = this.balls.some(breakBall => {
        const leftBall = this.balls.find(b => b.x === breakBall.x - 1 && b.y === breakBall.y); // 左(x-1 && y === y)
        const rightBall = this.balls.find(b => b.x === breakBall.x + 1 && b.y === breakBall.y); // 右(x+1 && y === y)
        const topBall = this.balls.find(b => b.x === breakBall.x && b.y === breakBall.y - 1); // 上(x === x && y - 10)
        const bottomBall = this.balls.find(b => b.x === breakBall.x && b.y === breakBall.y + 1); // 下(x === x && y + 10)
        // 削除対象ボールのチェックと削除情報設定
        if ((leftBall && leftBall.className === breakBall.className && leftBall.deleteFlag === 0)
        || (rightBall && rightBall.className === breakBall.className && rightBall.deleteFlag === 0)
        || (topBall && topBall.className === breakBall.className && topBall.deleteFlag === 0)
        || (bottomBall && bottomBall.className === breakBall.className && bottomBall.deleteFlag === 0)) {
          return true
        }
      });

      if (!breakBallsYes) {
        // 終了処理
        this.message = this.balls.length < 8 ? "GAME CLEAR!!!" : "GAME OVER";
        this.endOfGame();
      }
    },
    endOfGame() {
      // 終了処理
      // ゲーム終了ダイアログ表示
      this.isDisplay = true
    },

最後に

これらの処理をボールオブジェクト押下時に行うことで一連の動作が可能になりました。
実装ポイントとしては以上になります!

実際にはさらに得点の加算処理とか消した時の効果音、落下する際のアニメーションとかとかを入れています。

Vue.jsとこの手の落ちゲーの相性はとても良いような感じがしました。(ボールの状態管理など)

あくまで実務未経験で駆け出しの自分の脳みそとググり力で実装してみたって程度のものなので、確実にもっと綺麗でかつわかりやすいコードは存在します。
なので参考程度に見ていただけたら幸いです。。。

ほぼ自前で実装できたのですこしだけ嬉しかったです。

また別のゲームも作成してみたいので、その時はもう少し良いコードが書けるよう日々勉強していきます。

以上、最後まで読んでくださり、ありがとうございました!!!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
23
Help us understand the problem. What are the problem?