LoginSignup
165
153

More than 3 years have passed since last update.

Vue + Vuex + TypeScript でテトリスを作った話

Last updated at Posted at 2019-04-24

TL;DR

ここで遊べます.

リポジトリはこちら

操作方法

  • カーソルキー (左下右) または A, S, D : 移動
  • 上キーまたは W : ハードドロップ
  • J : 左回転
  • K : 右回転
  • R または U : ホールド

一応おまけでスマホでも操作できるようにしてありますが PC 推奨です.
ARE も現代的な T-Spin も無いストイックなやつを目指して作りました. (しかしホールド機能は付けたかったので付けました)

きっかけ

普段フロントエンドの開発をすることは少ないのですが, 遅ればせながら最近になってぼちぼち情報に触れるようになり, 自分も勉強してなんか書いてみようかなという気持ちになったのが昨年末あたり.

ちょうどその頃なぜかたまたま Classic Tetris World Championship (CTWC) の動画1をよく見る日々が続いており, テトリスでも作って自分で遊ぶか〜と思ったのがきっかけです.

とりあえず React の前に Vue から入ろうと思い, Vue と TypeScript の公式ドキュメントを一通り読んでから取り掛かり, 寝る前の隙間時間にちまちま作っていきました.

せっかくなので自分用の備忘録として残しておこうと思って書いたのがこの記事というわけですが, これもちまちま書いていたらこんな時間が経ってしまった.

Vue + TypeScript

Vue の公式ガイドを上から順に読んでいくと,

  • 基本的にはコンポーネントと呼ばれるものを定義し,

    • 親から子コンポーネントへは v-bind を使って props としてデータを受け渡す
    • 子は親の状態を直接変化させない
    • 子から親に何かを伝えたいときはカスタムイベントを発火して親が v-on の形で受け取る
    • props は子の中では immutable なものとして扱い, props を使って宣言的に記述された算出プロパティが親からの props の変更に対してリアクティブに変化する
      • それがテンプレートにも反映される
  • 結局のところ単一ファイルコンポーネントを基本の形としてコードを書いていく

ということがわかります.

続いて TypeScript の公式ドキュメントを読んでいくわけですが, そこで素朴な疑問がひとつ浮かびます. つまり TypeScript では const answer: number = 42 のように変数を定義するのですが, Vue コンポーネントの datacomputed などのオプションはオブジェクトリテラルの形で定義されます. すると TypeScript の variableName: type という表記とオブジェクトリテラルの key: value という表記が衝突してしまうのではないか?という疑問です.

この疑問は TypeScript の Quick Start から辿れる TypeScript-Vue-Starter の中で解決されます.
README を読んでいくと, 一番最後のほうに「デコレータを使ってコンポーネントを定義する (Using decorators to define a component)」という章があります. それによれば, vue-property-decorator という便利なパッケージをインストールすると

HelloDecorator.vue
import { Vue, Component, Prop } from "vue-property-decorator";

@Component
export default class HelloDecorator extends Vue {
    @Prop() name!: string;
    @Prop() initialEnthusiasm!: number;

    enthusiasm = this.initialEnthusiasm;

    increment() {
        this.enthusiasm++;
    }
    decrement() {
        if (this.enthusiasm > 1) {
            this.enthusiasm--;
        }
    }

    get exclamationMarks(): string {
        return Array(this.enthusiasm + 1).join('!');
    }
}

のように, @Prop() というデコレータをつけることでその変数が props として扱われるようになったり, get アクセサをつけることでそのメソッドが算出プロパティとして扱われるようになったりするという話です.
( 上記 README 内での書き方が若干まぎらわしいのですが, vue-property-decorator は vue-class-component を依存先として持つので, インストールするのは vue-property-decorator だけで十分です )

デコレータの詳細については公式ドキュメントやほかの方の記事に解説を譲るとして, 利用者視点で言えば vue-property-decorator によって型を付けながらコンポーネントを定義することができるようになるというわけです.

vue-property-decorator を使ったさらなる書き方についてはこちらの記事も大変参考になりました:


追記 20190521

Vue 3.x から公式で Class API を導入することが議論されていましたが却下され, 代わりに "Composition Functions" と呼ばれるパターンを用いたスタイルを導入することがアナウンスされました:

したがってデコレータを利用した上記の書き方は 3.x 以降, 非推奨となることが予想されます.


tsconfig.json

この時点で tsconfig.json は基本的に以下のような内容になっていると思います:

tsconfig.json
{
    "compilerOptions": {
        "outDir": "./built/",
        "sourceMap": true,
        "strict": true,
        "noImplicitReturns": true,
        "experimentalDecorators": true,
        "module": "es2015",
        "moduleResolution": "node",
        "target": "es5"
    },
    "include": [
        "./src/**/*"
    ]
}

この記事を書いている 2019 年 4 月時点では "experimentalDecorators": true を明記する必要があります.

webpack.config.js

今回は私は勉強のため vue-cli を使わず手動で (?) 必要なパッケージをインストールしたり webpack.config.js を書いたりすることにしました.

基本的には上記の TypeScript-Vue-Starter 内の指示に従えば良いと思いますが, いくつか注意点があったので記録しておきます.

VueLoaderPlugin

webpack で Vue の単一ファイルコンポーネントを扱うために vue-loader が必要ですが, vue-loader v15 からは pluginsVueLoaderPlugin を指定することが必須であるとドキュメントに記載があります:

webpack.config.js
const VueLoaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
  module: {
    rules: [
      // ... other rules
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
    // make sure to include the plugin!
    new VueLoaderPlugin()
  ]
}

ランタイム限定ビルドの利用

Vue 公式ドキュメントのランタイム + コンパイラとランタイム限定の違いという項を読むと, vue-loader を使用していれば, (単一ファイルコンポーネントのテンプレートはプリコンパイルされるので) 「完全ビルドに比べおよそ 30% 軽量」なランタイム限定ビルドを利用することができる, と書かれています.

つまり基本的に単一ファイルコンポーネントの形でコードを書き, エントリポイントとなる index.tsrender 関数を使ってルート要素に描画していれば, vue.esm.js ではなく vue.runtime.esm.js を利用することができます:

src/idnex.ts
import Vue from 'vue';
import App from './components/App.vue';

new Vue({
  el: '#app',
  render: h => h(App)
})
webpack.config.js
module.exports = {
  // ...
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.runtime.esm.js'
    }
  }
}

ESLint + Prettier

私が知る限り, フロントエンド開発においてはリンターに ESLint を, コードフォーマッターに Prettier を用いるという構成が現在のスタンダードのようです. 私も長いものに巻かれてエディタに甘えようと思い, この構成を取ることにしました.

TypeScript の場合 TSLint というリンターもあるようですが, TSLint チームは今後 ESLint に寄っていくというような記事が今年の 1 月に出たり:

していたので ESLint を使おうと判断しました. また折しも今年の 2 月後半には 2019 年中に TSLint が deprecated となる旨がアナウンスされました:

さて, ESLint と Prettier を組み合わせるために Prettier の公式ドキュメントを見ていきましょう. するとまず ESLint から Prettier を実行してくれるプラグインであるところの eslint-plugin-prettier が必要と書かれています. リポジトリの README を見ると .eslintrc.json

.eslintrc.json
{
  "plugins": ["prettier"],
  "rules": {
    "prettier/prettier": "error"
  }
}

と書けば良いようです.

さらに ESLint 側のコードフォーマットに関わるルールを無効にしてくれる eslint-config-prettier なるものもあります. こちらの README にもいろいろと書いてありますが, eslint-plugin-prettier 側の README とも合わせて読むと, 結局これら 2 つをインストールした上で .eslintrc.json"extends" 配列の最後に "plugin:prettier/recommended" を指定すれば良いことがわかります.

その上でさらにいくつか追加のプラグインも紹介されており, それらにも eslint-config-prettier が対応していることが示されています. 今回の構成だと私は

を使ったほうが良さそうです.

@typescript-eslint/eslint-plugin の README を読むと, @typescript-eslint/parser をインストールすることが求められています.

ただし eslint-plugin-vue のユーザーガイドには, eslint-plugin-vue はパーサとして vue-eslint-parser を用いるので, カスタムパーサを使いたいときは parser オプションではなく parserOptions.parser オプションで指定せよとの記述があります.

長々と見てきましたがこれで必要なものが揃いそうなので, すべてインストールして

npm install --save-dev eslint prettier eslint-plugin-prettier eslint-config-prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-vue

すべての設定を融合させましょう:

.eslintrc.json
{
  "extends": [
    "eslint:recommended",
    "plugin:vue/recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint",
    "prettier/vue"
  ],
  "plugins": [
    "@typescript-eslint",
    "prettier",
    "vue"
  ],
  "parser": "vue-eslint-parser",
  "parserOptions": {
    "parser": "@typescript-eslint/parser",
    "project": "./tsconfig.json",
    "extraFileExtensions": [".vue"]
  },
  "rules": {
    "prettier/prettier": "error"
  }
}

VSCode の拡張機能

上記の eslint-plugin-vue のユーザーガイドには Editor integrations という項があり, VSCode で ESLint 拡張機能を .vue ファイルに対しても適用するための設定が書かれています.
また Vetur プラグインを使っている場合は "vetur.validation.template": false を設定せよとあります.

私はそれに加えてファイル保存時に ESLint の auto fix をかけるよう設定したので, .vscode/settings.json は以下のようになりました:

.vscode/settings.json
{
  "eslint.validate": [
      "javascript",
      "javascriptreact",
      { "language": "vue", "autoFix": true },
      { "language": "typescript", "autoFix": true }
  ],
  "eslint.autoFixOnSave": true,
  "vetur.validation.template": false
}

ファイル保存時に自動整形がかかる体験は最高で, コードフォーマッティングに関する些事を人間が気にしなくてよくなり最高.
デフォルトの printWidth: 80 だけは少し窮屈すぎたのでそこだけ広げましたが, その上で prettier が改行すると判断したのならそれは正しいのです.

(とはいえテトリミノの回転を配列で定義して, 見やすいように手で整形したのに prettier が押しつぶそうとしてくる箇所だけは disabled にしましたが...)

実装フェーズ

ようやく開発環境が整ったので, テトリスの実装に入ることができます.

「『小さく、自己完結的で、(多くの場合)再利用可能なコンポーネント』を組み合わせることで、大規模アプリケーションを構築する」2のが Vue のスタイルなので, 必要なコンポーネントについて考えると

  • メインとなる盤面 (ミノが落ちたり回転したり消えたりするところ)

  • NEXT ミノをプレビューできる部分

  • ホールド中のミノを表示する部分

  • 現在のレベルと得点を表示する部分

ぐらいがあれば良さそうです.

するとおおまかな構成は以下のようになるでしょう:

dist/index.html
<!doctype html>
<html>
  <head>
  </head>
  <body>
    <div id="app"></div>
    <script src="./build.js"></script>
  </body>
</html>
src/index.ts
import Vue from "vue"
import TetrisComponent from "components/Tetris.vue"

new Vue({
  el: "#app",
  render: h => h(TetrisComponent)
})
src/components/Tetris.vue
<template>
  <div>
    <div id="tetris-component">
      <hold class="inline-block" />
      <play-field class="inline-block" />
      <div class="inline-block">
        <next-preview />
        <level-score />
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { Vue, Component } from "vue-property-decorator"
import Hold from "components/Hold.vue"
import PlayField from "components/PlayField.vue"
import NextPreview from "components/NextPreview.vue"
import LevelScore from "components/LevelScore.vue"

@Component({
  components: {
    Hold,
    PlayField,
    NextPreview,
    LevelScore
  }
})
export default class TetrisComponent extends Vue {}
</script>

<style>
#tetris-component {
  text-align: center;
}
.inline-block {
  display: inline-block;
  vertical-align: top;
}
</style>

テトリス実装の基本概念

テトリスのロジック自体は素朴な実装にしました.

盤面

盤面として幅 (10 + 2) * 高さ (20 + 2) の 2 次元配列を 2 つ用意し, 1 つは色情報を, もう 1 つはブロックが埋まっているか否かの情報を格納するのに使います.

幅と高さを +2 しているのは外壁, いわゆる番兵というやつです. 色と埋まりの配列を分けずにもっと統一的に扱っても良いかもしれませんが, そこは好みの問題だと思います.

ミノ

テトリスの主役であるところのミノについては, ミノの型を定義して export default interface Tetromino し, すべてのミノの名前と色と回転パターンを記述した Tetrominos: Tetromino[] を定義しておきます:

src/Tetrominos.ts
import Tetromino from "typings/Tetromino"
export const Tetrominos: Tetromino[] = [
  {
    name: "I",
    color: "red",
    blocks: [
      [ [1, 1, 1, 1] ],
      [
        [1],
        [1],
        [1],
        [1]
      ]
    ]
  },

  {
    name: "O",
    color: "gold",
    blocks: [
      [
        [1, 1],
        [1, 1]
      ]
    ]
  },
  .
  .
  .
]

この Tetrominos のインデックスでミノの種類を表現できるというわけです.

操作

ミノを移動させたり回転させたりといった操作を行うときは単純に, その操作後のミノが既に埋まっているマスとぶつからないかどうかを検査し, ぶつからなければ操作可能, ぶつかるなら操作不可能とすれば良さそうです.

ただし moveDown() だけは特別で, それ以上ミノを下に動かせないということはミノが接地したということ, すなわち現在のターンが終了したということを示します. なので揃ったラインを消すことを始めとした様々な終了処理を行う必要が出てくるでしょう.

ラインの消去

テトリスというゲームの命 (?) であるところのライン消去機能を作るには, Array.prototype.splice() がいい感じに使えるでしょう.
ミノを接地した結果すべての列が埋まったラインが出来たならば, 上から順番にそのラインの index について splice(index, 1) すれば良いのです. そして上記の 2 次元配列の先頭には空っぽの unitLine を都度 splice(1, 0, unitLine) して詰めてあげれば良いでしょう (index が 1 なのは一番上の 0 行目が壁だからです).

ホールド

ホールドとはつまり現在の tetrominoIndexholdTetrominoIndex とを交換するという操作です. ただし初回だけはホールドされているミノが無いので, NEXT のミノを盤面に呼ぶ必要があるでしょう. なので holdTetrominoIndex の初期値は -1 とか (インデックスとしてありえない値) にしておいて, それで初回の判定をします.

またホールドは 1 ターンにつき 1 回しかできないので, isHoldTetrominoUsed のような変数を用意して, ホールドしてからミノが接地するまでは isHoldTetrominoUsed = true としてロックしておく等の処理が必要です.

余談: Math.floor(Math.random() * 7) を捨てる

出現するミノをランダムにするために Math.floor(Math.random() * 7) の値を使おうと思う方がいるかもしれませんが, やめたほうが良いです.

代わりに 「7 種類のミノが 1 つずつ入った集合の中から, 空っぽになるまで 1 つずつランダムに取り出す」ことを繰り返すようにしてください.

実際にやってみるとわかるのですが, ナイーブな Math.floor(Math.random() * 7) ではとても遊べるゲームになりません. 動作確認用くらいにとどめておくべきです. これは重要なポイントなのでぜひ覚えておいてください.

今回私は Fisher–Yates アルゴリズムというシャッフルアルゴリズムを使いました:

Algorhythms.ts
// Fisher–Yates shuffle algorithm
export const shuffle = function(array: number[]): number[] {
  for (let i: number = array.length - 1; i > 0; i--) {
    const j: number = Math.floor(Math.random() * (i + 1))
    ;[array[i], array[j]] = [array[j], array[i]]
  }
  return array
}
TetrominoIndices.ts
import { Tetrominos } from "Tetrominos"
import { shuffle } from "Algorhythms"

// ...
  nextTetrominoIndicesSet: number[] = shuffle(Array.from(Array(Tetrominos.length).keys()))
// ...

Vuex

さてこのまま props$emitmethods を駆使してコードを書いていっても良いのですが (実際私も最初はそうしていました), だんだんと状態変数が増えてきて管理が辛くなっていきます.
そこで途中から Vuex による状態管理に移行することにしました (今からやり直すなら最初から Vuex を使うと思います).

Vuex や Flux 自体についてはほかに良い記事がたくさんあると思うのであえて私がここで拙い解説を書くことはしませんが, Vuex の公式ドキュメントを読んでいけば

  • 状態 (state) は Store と呼ばれるグローバルシングルトンの中に保持する

  • 状態を変更する唯一の方法は mutation をコミットすることである

  • mutation は同期的な処理だけを行う

  • 非同期処理を行ったり複数の mutation をコミットしたりするためには action を定義し, その actiondispatch することで実行する

  • 状態は getter で取り出す

ことが基本のスタイルであると読めます.

さらに Vuex で TypeScript を型安全に使うために vuex-module-decorators を利用しました.
これについては以下の記事が大変参考になりました:

コンポーネントの中で this.$store と書かなくてよくなったり mutation 名や action 名を文字列で一字一句間違えずに書かずに済むようになったりというのはこの記事のとおりです.

コーディングスタイル

Vuex の公式ドキュメントには「ミューテーション・タイプに定数を使用する」というオプションが提示されており, 上記の記事でもそのスタイルだったので, 今回は私もそれに則ることにしました.

また vuex-module-decorators を使うと VuexModule を TypeScript の class として書くことになり, 各プロパティやメソッドにアクセス指定子をつけることができます.
私は Vuex Store が外部 (Store を利用する Vue のコンポーネント) に公開すべきなのは getteraction だけであるとドキュメントからエスパーにより読み取った (?) ので, statemutationprivate にし, getteractionpublic としました.

こうなるとテトリスを実装する上での主要なロジックはほとんど Vuex の action となり, Vue のコンポーネントからは props が消え去りました. コンポーネントに残ったのは少しの内部変数と mounted フック, それに action を制御する薄いメソッド, リアクティブな描画のために Store の getter をそのまま返す getter ぐらいです.

反省点

描画

何も考えずに Canvas 上でゲーム画面を描画するものとしてコードを書き始めてしまったのですが, そうすると例えば

import { Tetrominos } from "Tetrominos"

// class PlayField extends Vue とか VuexModule とか { 
// ...

  // 適当な値です
  tetrominoIndex: number = 0
  currentX: number = 5
  currentY: number = 1
  rotation: number = 0

  unitWidth: number = 10
  unitHeight: number = 10

  drawTetromino(): void {
    const canvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement
    const context = canvas.getContext("2d")

    context.strokeStyle = "lightgray"
    context.fillStyle = Tetrominos[this.tetrominoIndex].color

    for (const [dy, row] of Tetrominos[this.tetrominoIndex].blocks[this.rotation].entries()) {
      for (const [dx, blockElement] of row.entries()) {
        if (blockElement != 0) {
          context.fillRect(
            (this.currentX + dx) * this.unitWidth,
            (this.currentY + dy) * this.unitHeight,
            this.unitWidth,
            this.unitHeight
          )
          context.strokeRect(
            (this.currentX + dx) * this.unitWidth,
            (this.currentY + dy) * this.unitHeight,
            this.unitWidth,
            this.unitHeight
          )
        }
      }
    }
  }

// ...
// }

的なコードを書くことになり, fillRect() がいかにも手続き的です.
だいぶ後になってから気がつきましたが, せっかく属性に値を bind できるのだから SVG で描画したほうが良かったかもしれません. こんど何か図形を描画して動かすものを作るときは SVG の利用を検討しようと思います.

そういう感じです

このテトリスを作っている間に Nintendo Switch で Tetris 993 が配信開始されました. この Tetris 99 は Nintendo Switch Online に加入していれば無料で遊べるし, 現代的な要素ももりもり詰まっています.
私はなるべくクラシックなテトリスに近いものを作って遊ぼうというモチベーションがあったから良かったものの, もし現代的テトリスを目指して作っていたら, べつに Tetris 99 とかいう良くできたゲームがあるしわざわざ俺がちまちま作んなくてもいいじゃん...という気持ちになって途中で心が折れていたかもしれません. そういう意味では運良く開発できたなあと思っています.

そういうわけですので皆さんも Tetris 99 をやりましょう.

165
153
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
165
153