TypescriptとReactでTetrisを実装して遊んでみた

## はじめに

[How to Build Simple Tetris Game with React & TypeScript](https://medium.com/front-end-weekly/how-to-build-simple-tetris-game-with-react-typescript-512ed4c040d8)を参考にテトリスを遊びで作ってみました。

今回は全体のコードに関しては上記のMediumのものを使っているので割愛します。CSSもこちらのものを使わせていただいているのでコピーして使ってください。

こちらが実際に作成したテトリスです。

[ts-react-tetris](https://github.com/mikinovation/ts-react-tetris)

今回は初期設定とコードの解説、和訳をしていきます。

## 開発環境の構築

今回はcreate-react-appを利用してtypescriptとreactの環境を構築していきます。

まだcreate-react-appをインストールしていない方は予めインストールしておきましょう

```bash
yarn global add create-react-app
```

以下のコマンドでテトリスのアプリを作成しましょう

```bash
yarn create-react-app react-tetris-ts --typescript
```

## 解説

index.tsxを少し変更します。ルートコンポーネントでテトリスのコンポーネントを渡すようにしましょう。

```index.tsx
import * as React from "react";
import * as ReactDOM from "react-dom";

// Import Tetris component
import Tetris from "./components/tetris";

// Import styles
import "./App.css";

// Import service worker
import * as serviceWorker from "./serviceWorker";

ReactDOM.render(
  <Tetris boardWidth="14" boardHeight="20" />,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
```

続いて型定義です。それぞれの変数について説明します。

```tsx
// Define props for Tetris component
type TetrisProps = {
  boardWidth: any,
  boardHeight: any
}

// Define props for Tetris component state
type TetrisState = {
  activeTileX: number,
  activeTileY: number,
  activeTile: number,
  tileRotate: number,
  score: number,
  level: number,
  tileCount: number,
  gameOver: boolean,
  isPaused: boolean,
  field: any[],
  timerId: any,
  tiles: number[][][][]
}
```

まず最初のTetrisPropsではboardWidthとboardHeightでテトリスのボード全体の横幅と縦幅ブロックを渡しています。

今回の場合は横幅14マス、縦幅20マスになっていますね。

```tsx
let field = []

    for (let y = 0; y < props.boardHeight; y++) {
      let row = []

      for (let x = 0; x < props.boardWidth; x++) {
        row.push(0)
      }

      field.push(row)
    }
```

続いて実際のマスの生成です。propsのマスを横幅と縦幅の大きさに合わせてループさせることで展開します。

これで[2,6],[6,14]のようなx軸、y軸方向にテトリスのボードを座標の形式で展開することが来ました。

```tsx
let xStart = Math.floor(parseInt(props.boardWidth) / 2)
```

続いてタイルが落ちてくる初期のx軸を指定します。横幅の真ん中に合わせました。

```tsx
this.state = {
      activeTileX: xStart,
      activeTileY: 1,
      activeTile: 1,
      tileRotate: 0,
      score: 0,
      level: 1,
      tileCount: 0,
      gameOver: false,
      isPaused: false,
      field: field,
      timerId: null,
      tiles: [
        // 7 tiles
        // Each tile can be rotated 4 times (x/y coordinates)
        [
          // The default square
          [[0, 0], [0, 0], [0, 0], [0, 0]],
          [[0, 0], [0, 0], [0, 0], [0, 0]],
          [[0, 0], [0, 0], [0, 0], [0, 0]],
          [[0, 0], [0, 0], [0, 0], [0, 0]]
        ],
        [
          // The cube tile (block 2x2)
          [[0, 0], [1, 0], [0, 1], [1, 1]],
          [[0, 0], [1, 0], [0, 1], [1, 1]],
          [[0, 0], [1, 0], [0, 1], [1, 1]],
          [[0, 0], [1, 0], [0, 1], [1, 1]]
        ],
        [
          // The I tile
          [[0, -1], [0, 0], [0, 1], [0, 2]],
          [[-1, 0], [0, 0], [1, 0], [2, 0]],
          [[0, -1], [0, 0], [0, 1], [0, 2]],
          [[-1, 0], [0, 0], [1, 0], [2, 0]]
        ],
        [
          // The T tile
          [[0, 0], [-1, 0], [1, 0], [0, -1]],
          [[0, 0], [1, 0], [0, 1], [0, -1]],
          [[0, 0], [-1, 0], [1, 0], [0, 1]],
          [[0, 0], [-1, 0], [0, 1], [0, -1]]
        ],
        [
          // The inverse L tile
          [[0, 0], [-1, 0], [1, 0], [-1, -1]],
          [[0, 0], [0, 1], [0, -1], [1, -1]],
          [[0, 0], [1, 0], [-1, 0], [1, 1]],
          [[0, 0], [0, 1], [0, -1], [-1, 1]]
        ],
        [
          // The L tile
          [[0, 0], [1, 0], [-1, 0], [1, -1]],
          [[0, 0], [0, 1], [0, -1], [1, 1]],
          [[0, 0], [1, 0], [-1, 0], [-1, 1]],
          [[0, 0], [0, 1], [0, -1], [-1, -1]]
        ],
        [
          // The Z tile
          [[0, 0], [1, 0], [0, -1], [-1, -1]],
          [[0, 0], [1, 0], [0, 1], [1, -1]],
          [[0, 0], [1, 0], [0, -1], [-1, -1]],
          [[0, 0], [1, 0], [0, 1], [1, -1]]
        ],
        [
          // The inverse Z tile
          [[0, 0], [-1, 0], [0, -1], [1, -1]],
          [[0, 0], [0, -1], [1, 0], [1, 1]],
          [[0, 0], [-1, 0], [0, -1], [1, -1]],
          [[0, 0], [0, -1], [1, 0], [1, 1]]
        ]
      ]
    }
  }
```

初期のステートの定義になります。

| State       |       内容 |
|-----------------|------------------|
| activeTileX  | ブロックがある位置のX軸 |
| activeTileY  | ブロックがある位置のY軸 |
| activeTile | タイルの種類|
| tileRotate   |  タイルの回転度合い |
| score   | 点数 |
| level  | 難易度 |
| tileCount  | タイルの枚数(横が揃っているかどうか判定する際に使う) |
|gameOver|ゲーム終了判定 |
|field|フィールドのマスが埋まっている状態を表現する状態を表す|
|timerId|一定時間ごとにボードを更新する処理|
|tiles|タイルの種類|

続いてライフサイクルフックを定義します。

```tsx
  componentDidMount() {
    let timerId

    timerId = window.setInterval(
      () => this.handleBoardUpdate('down'),
      1000 - (this.state.level * 10 > 600 ? 600 : this.state.level * 10)
    )

    this.setState({
      timerId: timerId
    })
  }

  componentWillUnmount() {
    window.clearInterval(this.state.timerId)
  }
```

コンポーネントがmountされた際にtimerをスタートします。

レベルが上がるとタイルの落ちるスピードが早くなります。

コンポーネントがunmountされた際にタイルの落ちるスピードが初期状態へリセットされます。

```tsx
handleBoardUpdate(command: string) {
   ...
}
```

次にhandleBoardUpdateメソッドですが、内容がかなり長いので分割して説明します。タイルが落ちる際に毎回このメソッドが処理されます。

```tsx
if (this.state.gameOver || this.state.isPaused) {
      return
    }
```

最初にゲームオーバーかポーズの場合はこれ以降の処理をしないようにします。

```tsx
let xAdd = 0
let yAdd = 0
let rotateAdd = 0
let tile = this.state.activeTile
```

続いて横への移動、下への移動、タイルの回転度、現在のタイル種類を初期値として設定しましょう。

```tsx
if (command === 'left') {
  xAdd = -1
}

if (command === 'right') {
  xAdd = 1
}

if (command === 'rotate') {
  rotateAdd = 1
}

if (command === 'down') {
  yAdd = 1
}
```

次に上下左右へ操作するコマンドの定義をしました。それぞれ１行ずつマスを動かした状態に更新します。

```tsx
let field = this.state.field
let x = this.state.activeTileX
let y = this.state.activeTileY
let rotate = this.state.tileRotate

const tiles = this.state.tiles
```

移動後ののタイルの配置状況、アクティブタイルの位置、回転度を取得しました。

```tsx
field[y + tiles[tile][rotate][0][1]][x + tiles[tile][rotate][0][0]] = 0
field[y + tiles[tile][rotate][1][1]][x + tiles[tile][rotate][1][0]] = 0
field[y + tiles[tile][rotate][2][1]][x + tiles[tile][rotate][2][0]] = 0
field[y + tiles[tile][rotate][3][1]][x + tiles[tile][rotate][3][0]] = 0
```

かなりややこしいですが移動後の元いたフィールドを空にします。

```tsx
let xAddIsValid = true;
```

x軸方向、つまり右方向に動けると仮定します。

```tsx
if (xAdd !== 0) {
  for (let i = 0; i <= 3; i++) {
    if (
      x + xAdd + tiles[tile][rotate][i][0] >= 0 &&
      x + xAdd + tiles[tile][rotate][i][0] < this.props.boardWidth
    ) {
      if (
        field[y + tiles[tile][rotate][i][1]][
          x + xAdd + tiles[tile][rotate][i][0]
        ] !== 0
      ) {
        xAddIsValid = false;
      }
    } else {
      xAddIsValid = false;
    }
  }
}
```

ここでは仮に次の動作でx軸方向に動かせるかどうかをテストしています。

動くことができなければxAddIsValidにfalseを代入しましょう。

```tsx
if (xAddIsValid) {
      x += xAdd;
}
```

水平方向に動かすことができればxに1を足します。

```tsx
let newRotate = rotate + rotateAdd > 3 ? 0 : rotate + rotateAdd;
let rotateIsValid = true;
```

回転ボタンがおされていた場合には回転させます。回転度合いが3の場合には0にリセットしましょう

```tsx
// Test if tile should rotate
if (rotateAdd !== 0) {
  for (let i = 0; i <= 3; i++) {
    // Test if tile can be rotated without getting outside the board
    if (
      x + tiles[tile][newRotate][i][0] >= 0 &&
      x + tiles[tile][newRotate][i][0] < this.props.boardWidth &&
      y + tiles[tile][newRotate][i][1] >= 0 &&
      y + tiles[tile][newRotate][i][1] < this.props.boardHeight
    ) {
      // Test of tile rotation is not blocked by other tiles
      if (
        field[y + tiles[tile][newRotate][i][1]][
          x + tiles[tile][newRotate][i][0]
        ] !== 0
      ) {
        // Prevent rotation
        rotateIsValid = false;
      }
    } else {
      // Prevent rotation
      rotateIsValid = false;
    }
  }
}
```

今度は先程と同じように回転できるかどうかの判定をします。回転した後のブロックが壁を突き破ってしまう場合にはrotateIsValidにfalseを挿入します。

```tsx
if (rotateIsValid) {
  rotate = newRotate;
}

let yAddIsValid = true;
```

そして回転した状態を現在の回転度合いに挿入します。

続いて下に落とすコマンドで下に移動可能かどうかを判定します。

テトリスでは一番下、もしくは別のブロックに到達した場合に終了するわけではなく、回転するかどうかどうかの猶予がありますね。

```tsx
// Test if tile should fall faster
if (yAdd !== 0) {
  for (let i = 0; i <= 3; i++) {
    // Test if tile can fall faster without getting outside the board
    if (
      y + yAdd + tiles[tile][rotate][i][1] >= 0 &&
      y + yAdd + tiles[tile][rotate][i][1] < this.props.boardHeight
    ) {
      // Test if faster fall is not blocked by other tiles
      if (
        field[y + yAdd + tiles[tile][rotate][i][1]][
          x + tiles[tile][rotate][i][0]
        ] !== 0
      ) {
        // Prevent faster fall
        yAddIsValid = false;
      }
    } else {
      // Prevent faster fall
      yAddIsValid = false;
    }
  }
}

// If speeding up the fall is valid (move the tile down faster)
if (yAddIsValid) {
  y += yAdd;
}
```

ここまでくれば同様です。

一番下に到達した時点で下へ移動のコマンドを操作することはできません。下で移動できるようであれば下に１つマスを移動します。

```tsx
field[y + tiles[tile][rotate][0][1]][x + tiles[tile][rotate][0][0]] = tile;
field[y + tiles[tile][rotate][1][1]][x + tiles[tile][rotate][1][0]] = tile;
field[y + tiles[tile][rotate][2][1]][x + tiles[tile][rotate][2][0]] = tile;
field[y + tiles[tile][rotate][3][1]][x + tiles[tile][rotate][3][0]] = tile;
```

ここで移動後のタイルの位置を取得してフィールドを埋めていきます。

```tsx
if (!yAddIsValid) {
for (let row = this.props.boardHeight - 1; row >= 0; row--) {
  let isLineComplete = true;

  for (let col = 0; col < this.props.boardWidth; col++) {
    if (field[row][col] === 0) {
      isLineComplete = false;
    }
  }

  if (isLineComplete) {
    for (let yRowSrc = row; yRowSrc > 0; yRowSrc--) {
      for (let col = 0; col < this.props.boardWidth; col++) {
        field[yRowSrc][col] = field[yRowSrc - 1][col];
      }
    }

    row = this.props.boardHeight;
  }
}
```

ブロックが一番下まで到達した際にブロックが横一列に揃っているか判定します。

ラインが揃っていたらその一列を消してブロックを一段下にずらします。

```tsx
this.setState(prev => ({
  score: prev.score + 1 * prev.level,
  tileCount: prev.tileCount + 1,
  level: 1 + Math.floor(prev.tileCount / 10)
}));
```

点数、落としたタイルの合計数、レベルを更新します。今回はタイルを10個落とすたびにレベルが上がっていますね。

```tsx
let timerId

clearInterval(this.state.timerId)

timerId = setInterval(
  () => this.handleBoardUpdate("down"),
  1000 - (this.state.level * 10 > 600 ? 600 : this.state.level * 10)
)

this.setState({
  timerId: timerId
});
```

続いて時間の更新です。落ちる時間の感覚をレベルに沿って短縮していきます。

```tsx

tile = Math.floor(Math.random() * 7 + 1);
x = parseInt(this.props.boardWidth) / 2;
y = 1;
rotate = 0;
```

次のタイルの種類をランダムに選んでtileに挿入します。

タイルの位置と回転度をリセットします。

```tsx
if (
    field[y + tiles[tile][rotate][0][1]][x + tiles[tile][rotate][0][0]] !==
      0 ||
    field[y + tiles[tile][rotate][1][1]][x + tiles[tile][rotate][1][0]] !==
      0 ||
    field[y + tiles[tile][rotate][2][1]][x + tiles[tile][rotate][2][0]] !==
      0 ||
    field[y + tiles[tile][rotate][3][1]][x + tiles[tile][rotate][3][0]] !==
      0
  ) {
    this.setState({
      gameOver: true
    });
  } else {
    field[y + tiles[tile][rotate][0][1]][
      x + tiles[tile][rotate][0][0]
    ] = tile;
    field[y + tiles[tile][rotate][1][1]][
      x + tiles[tile][rotate][1][0]
    ] = tile;
    field[y + tiles[tile][rotate][2][1]][
      x + tiles[tile][rotate][2][0]
    ] = tile;
    field[y + tiles[tile][rotate][3][1]][
      x + tiles[tile][rotate][3][0]
    ] = tile;
  }
}
```

ここも少々わかりにくいですがifの条件文ででゲームオーバーの判定を行っています。

ゲームオーバーでなければ新しいタイルを生成してゲームを続行します。

```tsx
this.setState({
      field: field,
      activeTileX: x,
      activeTileY: y,
      tileRotate: rotate,
      activeTile: tile
});
```

handleBoardUpdateメソッドのラストです！現在のボードの状態、タイルの状態をstateに保存して終了です。

残り少しです。がんばりましょう！(書いている自分に言っています笑)

```tsx
handlePauseClick = () => {
    this.setState(prev => ({
      isPaused: !prev.isPaused
    }));
  };
```

ポーズボタンを押した際の処理です。タイル等ボードの動きがとまります。

```tsx
handleNewGameClick = () => {
    let field: any[] = [];

    for (let y = 0; y < this.props.boardHeight; y++) {
      let row = [];

      for (let x = 0; x < this.props.boardWidth; x++) {
        row.push(0);
      }

      field.push(row);
    }

    let xStart = Math.floor(parseInt(this.props.boardWidth) / 2);

    this.setState({
      activeTileX: xStart,
      activeTileY: 1,
      activeTile: 2,
      tileRotate: 0,
      score: 0,
      level: 1,
      tileCount: 0,
      gameOver: false,
      field: field
    });
  };
```

ゲームのリスタート処理です。各値を初期化するメソッドになります。

```tsx
render() {
    return (
      <div className="tetris">
        {/* Tetris board */}
        <TetrisBoard
          field={this.state.field}
          gameOver={this.state.gameOver}
          score={this.state.score}
          level={this.state.level}
          rotate={this.state.tileRotate}
        />

        {/* Buttons to control blocks */}
        <div className="tetris__block-controls">
          <button
            className="btn"
            onClick={() => this.handleBoardUpdate("left")}
          >
            Left
          </button>

          <button
            className="btn"
            onClick={() => this.handleBoardUpdate("down")}
          >
            Down
          </button>

          <button
            className="btn"
            onClick={() => this.handleBoardUpdate("right")}
          >
            Right
          </button>

          <button
            className="btn"
            onClick={() => this.handleBoardUpdate("rotate")}
          >
            Rotate
          </button>
        </div>

        {/* Buttons to control game */}
        <div className="tetris__game-controls">
          <button className="btn" onClick={this.handleNewGameClick}>
            New Game
          </button>

          <button className="btn" onClick={this.handlePauseClick}>
            {this.state.isPaused ? "Resume" : "Pause"}
          </button>
        </div>
      </div>
    );
  }
```

最後にレンダリングのコードになります。

各ボタンやボード自体を描画しています。

続いてテトリスのボードコンポーネントの解説をします。

左画にレベルやスコア、あるいはゲームオーバーといった項目が表示されます。

右側に実際のゲームボードが描画されます。

それではコードを見ていきましょう。

```tsx
import * as React from 'react'

// Define props for TetrisBoard component
type TetrisBoardProps = {
  field: any[],
  gameOver: boolean,
  score: number,
  level: number,
  rotate: number
}

// Create TetrisBoard component
const TetrisBoard: React.FC<TetrisBoardProps> = (props) => {
  // Create board rows
  let rows: any[] = []

  props.field.forEach((row, index) => {
    // Create board columns
    const cols = row.map((column: any, index: number) => <div className={`col-${column}`} key={index} />)

    rows.push(<div className="tetris-board__row" key={index}>{cols}</div>)
  })

  return (
    <div className="tetris-board">
      {/* Game info */}
      <div className="tetris-board__info">
        <p className="tetris-board__text">Level: {props.level}</p>

        <p className="tetris-board__text">Score: {props.score}</p>

        {props.gameOver && <p className="tetris-board__text"><strong>Game Over</strong></p>}
      </div>
      {/* Game board */}
      <div className="tetris-board__board">{rows}</div>
    </div>
  )
}

export default TetrisBoard
```

レンダリングする際に各ブロックが埋まっている状況を配列に入れてそれを縦に積み上げていくイメージです。

これでテトリスが完成しました。

## まとめ

今回は簡単なテトリスを作ってみました。早速遊んでみましょう！

この解説で分かりづらい部分があれば教えてください！

ただ、ちょっとこれだけだとクリックだけで操作しなければならないですね。キーボードで操作したほうがUI的には良いと思うので気が向いたら改修します(向くかは分からない笑)