Vue.jsを使ってテトリスを作ってみた

Pocket
LINEで送る

Vue.jsでテトリス作るお

初めまして、新人エンジニアのふくはらです〜

エンジニア未経験でフクロウラボに入社して間も無く三ヶ月となりますが、こうやって楽しくエンジニアをやれているのはきっと私に類まれな才能があったから優しい先輩たちがいてくれたおかげでしょう👍

さて、現在フクロウではVue.jsを使った画面のリニューアル作業をしており、その過程で私もVue.jsの学習をしております。

しかし、「ただ勉強するだけではつまらない!テトリスを作りながらVue.jsを学ぼう!」ということでテトリスを作ったので、今回はその紹介をさせていただきたいと思います〜👏👏👏

今作品のGitHubリポジトリ

※ 各章ごとにサンプル(JSFiddle)も用意しているのでそちらも確認しながら読み進めてくださいませ


1 Hello Vue.js を表示

まずはVue.jsで「Hello Vue.js」を表示させてみましょう!

環境構築はCDN読み込みでサクッと済ませちゃいます!

サンプル

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>
      <div id="app">
        <p>{{ message }}</p>
      </div>
  </body>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  <script>
    var app = new Vue({
      el: "#app",
      data: {
        message: 'Hello Vue.js'
      },
    });
  </script>
  <style>
  </style>
</html>

スクリーンショット 2019-09-04 11.21.03

はい!無事「Hello Vue.js」が表示されました🎉🎉🎉

楽勝ですね✌️


2 ゲーム画面の土台を作成

画面サイズは後から柔軟に変えられるよう変数で指定しています。

ブロックを画面上に表示させるには、テーブルの各セルにスタイル用のクラスを割り当てる必要があります。

Vue.jsのデータバインディングの仕組みを使えば、動的にクラスを割り当てることができ,非常に簡単に画面の更新を行うことができます!

サンプル

  <body>
      <div id="app">
        <h1>簡単テトリス</h1>
        <li v-for="cells in displayTable">
            <span v-for="cell in cells" :class="getColor(cell)"></span>
        </li>
      </div>
  </body>

  <script>
    var app = new Vue({
      el: "#app",
      data: {
        // 画面サイズY
        displaySizeY: 13,

        // 画面サイズX
        displaySizeX: 10,

        // 位置が確定したブロックの位置情報格納テーブル
        // -1 ブロック非存在, 0 緑ブロック, 1 赤ブロック, 2 青ブロック
        cellsTable: [],

        // 描画用のブロック情報格納テーブル
        displayTable: [],
      },
      methods: {
        // ブロックの種類に応じた色を返す
        getColor(cell) {
          if(cell === -1)  return
          if(cell === 0)   return 'green'
          if(cell === 1)   return 'red'
          if(cell === 2)   return 'blue'
        },

        // 描画用テーブルの情報を更新する
        updateDisplay() {
          // 位置が確定したブロック情報を転写
          this.displayTable = JSON.parse(JSON.stringify(this.cellsTable))
        },

        // ゲーム画面を初期化する
        initTable() {
          // cellsTableのブロック情報を初期化
          this.cellsTable = []
          for(var i = 0; i < this.displaySizeY; i++) { this.cellsTable.push( Array(this.displaySizeX).fill(-1) ) }

          // 描画用テーブルを更新
          this.updateDisplay()
        },
      },
      mounted() {
        // ゲーム画面の初期化
        this.initTable()
      },
    });
  </script>
  <style>

    h1 {
      text-align: center;
    }

    li {
      text-align: center;
      list-style: none;
      height: 30px;
    }

    .green {
      background: linear-gradient(45deg, #389406, transparent);
    }

    .red {
      background: linear-gradient(45deg, #ee3f3f, transparent);
    }

    .blue {
      background: linear-gradient(45deg, #1a5dec, transparent);
    }

    span {
      height: 30px;
      width: 30px;
      display: inline-block;
      background: gainsboro;
      border: 1px solid white;
      box-sizing: border-box;
    }
  </style>

スクリーンショット 2019-09-09 21.17.54


3 キー入力受付処理

キー入力にmoveBlockメソッドを割り当て、それぞれのキーに応じて異なる引数をとります。

moveBlockメソッドには後ほどブロックを操作する処理を追加していきます。

サンプル

        getColor(cell) {
          if(cell === -1)  return
          if(cell === 0)   return 'green'
          if(cell === 1)   return 'red'
          if(cell === 2)   return 'blue'
        },

        // 操作対象のブロックの位置を変更する
        moveBlock(moveY, moveX, moveAngle) {
          console.log(moveY, moveX)
        },

        // 描画用テーブルの情報を更新する
        updateDisplay() {
          // 位置が確定したブロック情報を転写
          this.displayTable = JSON.parse(JSON.stringify(this.cellsTable))
        },

     mounted() {
        // ゲーム画面の初期化
        this.initTable()

        // 指定したキーにイベントを割り当て
        document.onkeydown = function (e) {
          if      (e.keyCode === 37)  this.moveBlock(0, -1, 0) // 矢印キー上
          else if (e.keyCode === 38)  this.moveBlock(-1, 0, 0) // 矢印キー左
          else if (e.keyCode === 39)  this.moveBlock(0, 1, 0)  // 矢印キー右
          else if (e.keyCode === 40)  this.moveBlock(1, 0, 0)  // 矢印キー下
        }.bind(this)
      },

4 キー入力により位置情報を変化させる

操作中のブロックの情報を管理する変数を3つ定義します(currentYX, currentAngle, currentColor)。

currentYXはブロックのy座標とx座標を、

currentAngleはブロックの傾きを、

currentColorはブロックの色(種類)の状態を保持します。

currentYXとcurrentAngleの値はmoveBlockメソッドを通して操作します。

ただし、必ずしも操作が成功するわけではない(後述)ので、中継する変数を経由した上で操作を確定させます。

サンプル

      data: {
        // 画面サイズY
        displaySizeY: 13,

        // 画面サイズX
        displaySizeX: 10,

        // 操作中のブロックの位置情報
        currentYX: [],

        // 操作中のブロックの傾き
        currentAngle: 0,

        // 操作中のブロックの色
        currentColor: 0,

        // 位置が確定したブロックの位置情報格納テーブル
        // -1 ブロック非存在, 0 緑ブロック, 1 赤ブロック, 3 青ブロック
        cellsTable: [],
        moveBlock(moveY, moveX, moveAngle) {
          // 操作後の座標位置
          var nextYX = [this.currentYX[0] + moveY, this.currentYX[1] + moveX]

          // 操作後の傾き
          var nextAngle = (this.currentAngle + 4 + moveAngle) % 4
          console.log(nextYX, nextAngle)

          // 操作後の値を現在の値に反映させる
          this.currentYX = nextYX
          this.currentAngle = nextAngle

          // 描画用テーブルを更新
          this.updateDisplay()
        },
        // ゲーム画面を初期化する
        initTable() {
          // 操作中のブロック位置を初期化
          this.currentYX = [0, 3]

          // cellsTableのブロック情報を初期化
          this.cellsTable = []
          for(var i = 0; i < this.displaySizeY; i++) { this.cellsTable.push( Array(this.displaySizeX).fill(-1) ) }

          // 描画用テーブルを更新
          this.updateDisplay()
        },

5 ブロックを画面に描画する

操作中のブロックを画面上に表示します。

currentYXとcurrentAngleと currentColorの値から画面上のどの位置にブロックが存在するか計算します。

そのための情報をgreenBlock, redBlock, blueBlockで管理しています。


someBlock[現在の向き][n個目のcell][0] // currentYX[0]からy軸がどのくらい離れているか
someBlock[現在の向き][n個目のcell][1] // currentYX[1]からx軸がどのくらい離れているか

サンプル

        // 操作中のブロックの色
        currentColor: 0,

        // 緑ブロックの配置情報
        greenBlock: [
          [[0,1],[0,2],[1,1],[1,2]],
          [[0,1],[0,2],[1,1],[1,2]],
          [[0,1],[0,2],[1,1],[1,2]],
          [[0,1],[0,2],[1,1],[1,2]],
        ],

        // 赤ブロックの配置情報
        redBlock: [
          [[0,2],[1,2],[2,2],[3,2]],
          [[2,0],[2,1],[2,2],[2,3]],
          [[1,2],[2,2],[3,2],[4,2]],
          [[2,1],[2,2],[2,3],[2,4]],
        ],

        // 青ブロックの配置情報
        blueBlock: [
          [[2,1],[1,2],[2,2],[2,3]],
          [[2,1],[1,2],[2,2],[3,2]],
          [[2,1],[2,2],[3,2],[2,3]],
          [[1,2],[2,2],[3,2],[2,3]],
        ],

        // ゲーム上で使用するブロックの配置情報
        usableBlocks: [],
      methods: {
        // ブロックの種類に応じた色を返す
        getColor(cell) {
          if(cell === -1)  return
          if(cell === 0)   return 'green'
          if(cell === 1)   return 'red'
          if(cell === 2)   return 'blue'
        },

        // 引数の情報からブロックの存在している座標を返す
        getBlock(y, x, color, angle) {
          return this.usableBlocks[color][angle].map(function(cell){ return [y + cell[0], x + cell[1]] })
        },

        // 操作対象のブロックの位置を変更する
        moveBlock(moveY, moveX, moveAngle) {
        // 描画用テーブルの情報を更新する
        updateDisplay() {
          // 位置が確定したブロック情報を転写
          this.displayTable = JSON.parse(JSON.stringify(this.cellsTable))

          // 操作中のブロック情報を転写
          var block = this.getBlock(this.currentYX[0], this.currentYX[1], this.currentColor, this.currentAngle)
          block.forEach(cell => { this.displayTable[cell[0]][cell[1]] = this.currentColor })
        },
      mounted() {
        // usableBocks配列に使用するブロックの種類を羅列する
        this.usableBlocks = [this.greenBlock, this.redBlock, this.blueBlock]

        // ゲーム画面の初期化
        this.initTable()

スクリーンショット 2019-09-09 21.22.25


6 ブロックが範囲内にあるか判定

現状ではブロックを画面外に移動することができるので、それを制限する必要があります。

そこでブロックが画面内に存在しているか判定するメソッドを作り、操作後のブロックが画面内にあれば操作を確定させるという処理をmoveBlock内に追加します。

サンプル

        // 引数の情報からブロックの存在している座標を返す
        getBlock(y, x, color, angle) {
          return this.usableBlocks[color][angle].map(function(cell){ return [y + cell[0], x + cell[1]] })
        },

        // 位置が画面内であればtrueを返す
        isInside(y, x) {
          if ( y < 0 || y >= this.displaySizeY || x < 0 || x >= this.displaySizeX ) return false
          else return true
        },

        // ブロックが画面内にあればtrueを返す
        isBlockInside(block) {
          for(var i = 0; i < block.length; i++ ) {
            if ( !this.isInside(block[i][0], block[i][1]) ) return false
          }
          return true
        },

        // 操作対象のブロックの位置を変更する
        moveBlock(moveY, moveX, moveAngle) {
          // 操作後の座標位置
          var nextYX = [this.currentYX[0] + moveY, this.currentYX[1] + moveX]

          // 操作後の傾き
          var nextAngle = (this.currentAngle + 4 + moveAngle) % 4

          // 操作後のブロック情報
          var nextBlock = this.getBlock(nextYX[0], nextYX[1], this.currentColor, nextAngle)

          // 操作後のブロックの位置が正常であるかのフラグ
          var result = true

          // 操作後のブロックが画面外にあればフラグを折る
          if (!this.isBlockInside(nextBlock)) result = false

          // フラグがtrueの場合のみ操作を確定させる
          if (result) {
            this.currentYX = nextYX
            this.currentAngle = nextAngle
          }

          // 描画用テーブルを更新
          this.updateDisplay()
        },

7 ゲーム開始状態の切り替え

ゲームの開始状態を切り替える処理を追加します。

ゲームの開始状態によって画面上に表示させるメッセージを変化させます。

サンプル

      <div id="app">
        <h1>簡単テトリス</h1>
        <h3>{{ reversedStartMessage }}</h3>
        <li v-for="cells in displayTable">
            <span v-for="cell in cells" :class="getColor(cell)"></span>
        </li>
      </div>
  </body>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  <script>
    var app = new Vue({
      el: "#app",
      computed: {
        // ゲームの開始状態に応じたメッセージを返す
        reversedStartMessage() {
          if (this.isStarted) return 'スペースキー押下でゲームをリセットします'
          else return 'スペースキー押下でゲームを開始します'
        }
      },
      data: {
        // ゲームが始まっているか判定するフラグ
        isStarted: false,

        // 画面サイズY
        displaySizeY: 13,
          // 描画用テーブルを更新
          this.updateDisplay()
        },

        // ゲームの開始状態を切り替える
        changeGameMode() {
          // フラグを反転させる
          this.isStarted ^= true

          // ゲーム画面を初期化する
          this.initTable()
        }
      },
      mounted() {
        // usableBocks配列に使用するブロックの種類を羅列する
        this.usableBlocks = [this.greenBlock, this.redBlock, this.blueBlock]

        // ゲーム画面の初期化
        this.initTable()

        // 指定したキーにイベントを割り当て
        document.onkeydown = function (e) {
          if      (e.keyCode === 37)  this.moveBlock(0, -1, 0) // 矢印キー上
          else if (e.keyCode === 38)  this.moveBlock(-1, 0, 0) // 矢印キー左
          else if (e.keyCode === 39)  this.moveBlock(0, 1, 0)  // 矢印キー右
          else if (e.keyCode === 40)  this.moveBlock(1, 0, 0)  // 矢印キー下
          else if (e.keyCode === 32)  this.changeGameMode()    // spaceキー
        }.bind(this)
      },
    });
  </script>
  <style>

    h1, h3 {
      text-align: center;
    }

スクリーンショット 2019-09-09 21.24.26


8 ブロックの自動落下

ブロックの自動落下処理を実装します。

実装はとてもシンプルで指定したミリ秒ごとにmoveBlockメソッドを呼び出してブロック位置を1つ下げるだけです。

サンプル

      data: {
        // ゲームが始まっているか判定するフラグ
        isStarted: false,

        // ブロックが落下する間隔(ミリ秒)
        fallMilliSecond: 500,
          // 描画用テーブルを更新
          this.updateDisplay()
        },

        // ブロックの自動落下(無限ループ)
        autoFall() {
          // ゲーム開始状態でなければループを止める
          if ( !this.isStarted ) return

          // ブロックを1マス落下させる
          this.moveBlock(1, 0, 0)

          // fallMilliSecond で指定した間隔分待って再帰呼び出しする
          setTimeout(this.autoFall, this.fallMilliSecond)
        },

        // 描画用テーブルの情報を更新する
        updateDisplay() {
        // ゲームの開始状態を切り替える
        changeGameMode() {
          // フラグを反転させる
          this.isStarted ^= true

          // ゲーム画面を初期化する
          this.initTable()

          // ゲーム開始状態であればブロックを自動落下させる
          if (this.isStarted) this.autoFall()
        }

9 ブロックの着地と新ブロックの生成

操作中のブロックが着地した場合、位置が確定したブロックとしてcellsTableにブロック情報を登録します。

それと同時に新しいブロックを生成します。

サンプル

          // フラグがtrueの場合のみ操作を確定させる
          if (result) {
            this.currentYX = nextYX
            this.currentAngle = nextAngle
          }

          // 床への着地した場合
          // 操作前の位置にブロックを固定させ、新しいブロックを生成する
          if (!result && moveY === 1) {
            var currentBlock = this.getBlock(nextYX[0] - 1, nextYX[1], this.currentColor, this.currentAngle)
            this.determineBlock(currentBlock, this.currentColor)
            this.createNewBlock()
          }

          // 描画用テーブルを更新
          this.updateDisplay()
        },

        // 新しいブロックを生成する
        createNewBlock() {
          // 操作中のブロック位置を初期化
          this.currentYX = [0, 3]

          // 操作中のブロックの傾きを初期化
          this.currentAngle = 0

          // 操作中のブロックの色(種類)をランダムで決める
          this.currentColor = Math.floor( Math.random() * this.usableBlocks.length )
        },

        // ブロックの位置を固定させる
        determineBlock(block, color) {
          block.forEach(cell => { this.cellsTable[cell[0]][cell[1]] = color })
        },

        // ブロックの自動落下(無限ループ)
        autoFall() {


10 ブロック同士の衝突判定

操作中のブロックと位置が確定したブロックとの衝突判定を実装します。

これは画面外にブロックが存在しているか判定する時と似ており、操作後のブロックの位置にすでにブロックが存在しているかどうかで判定しています。

そして、他のブロック上に着地した場合の処理を併せて実装します。

サンプル

          return true
        },

        // ブロック同士がコンフリクトしていなければtrueを返す
        isNotConflict(block) {
          for(var i = 0; i < 4; i++ ) {
            if ( this.cellsTable[block[i][0]][block[i][1]] !== -1 ) return false
          }
          return true
        },

        // 操作対象のブロックの位置を変更する
        moveBlock(moveY, moveX, moveAngle) {
          // 操作後のブロックが画面外にあればフラグを折る
          if (!this.isBlockInside(nextBlock)) result = false

          // 操作後のブロックが他のブロックと重複した位置にあればフラグを折る
          if (result && !this.isNotConflict(nextBlock)) result = false

          // フラグがtrueの場合のみ操作を確定させる
          if (result) {
            this.currentYX = nextYX
            this.currentAngle = nextAngle
          }

          // 床もしくは他のブロックへの着地した場合
          // 操作前の位置にブロックを固定させ、新しいブロックを生成する
          if (!result && moveY === 1) {
            var currentBlock = this.getBlock(nextYX[0] - 1, nextYX[1], this.currentColor, this.currentAngle)

11 ブロックの回転処理

すでに回転処理を見据えた実装を行なっていたので、ここでは実装は非常に単純です。

ブロックを回転させるキーを割り当て、moveBlockメソッドを回転させるための引数をとって呼び出すだけです。

サンプル

        // 指定したキーにイベントを割り当て
        document.onkeydown = function (e) {
          if      (e.keyCode === 37)  this.moveBlock(0, -1, 0) // 矢印キー上
          else if (e.keyCode === 38)  this.moveBlock(-1, 0, 0) // 矢印キー左
          else if (e.keyCode === 39)  this.moveBlock(0, 1, 0)  // 矢印キー右
          else if (e.keyCode === 40)  this.moveBlock(1, 0, 0)  // 矢印キー下
          else if (e.keyCode === 68)  this.moveBlock(0, 0, -1) // Dキー
          else if (e.keyCode === 70)  this.moveBlock(0, 0, 1)  // Fキー
          else if (e.keyCode === 32)  this.changeGameMode()    // spaceキー
        }.bind(this)

12 ブロック消去処理

cellsTableのブロックが揃っている行を検知したらその行を削除し、新たな行を最上部に挿入するremoveLineメソッドを追加します。

このメソッドをブロックの位置が確定した(determineBlockメソッドが呼び出された)時に呼び出せば、ブロックが揃っている行が消去されます。

サンプル

        // ブロックの位置を固定させる
        determineBlock(block, color) {
          block.forEach(cell => { this.cellsTable[cell[0]][cell[1]] = color })
          this.removeLine()
        },

        // 行が揃ったラインを消去する
        removeLine() {
          // ブロックが揃っている行を検知したらその列を削除し、空の行を最上部に追加する
          for(var i=0; i<this.cellsTable.length; i++) {
            if (this.cellsTable[i].indexOf( -1, 0) === -1) {
              this.cellsTable.splice(i, 1)
              this.cellsTable.unshift( Array(this.displaySizeX).fill(-1) )
            }
          }
        },

まとめ

まだ不完全な状態ではありますが、とりあえずテトリスがプレイできるようになりました🎉🎉🎉

このコードをベースに下記のような拡張をすることができます。

・ブロックの種類を追加
・ブロック落下スピードを途中で変化させる
・ブロックの消去に応じて得点をつける
・次に生成されるブロックをあらかじめ表示する

興味のある方は、ぜひ拡張に挑戦してみてくださーい👍

Pocket
LINEで送る