Edited at

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


はじめに

How to Build Simple Tetris Game with React & TypeScriptを参考にテトリスを遊びで作ってみました。

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

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

ts-react-tetris

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


開発環境の構築

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

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

yarn global add create-react-app

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

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();


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

// 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マスになっていますね。

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軸方向にテトリスのボードを座標の形式で展開することが来ました。

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

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

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
タイルの種類

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

  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された際にタイルの落ちるスピードが初期状態へリセットされます。

handleBoardUpdate(command: string) {

...
}

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

if (this.state.gameOver || this.state.isPaused) {

return
}

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

let xAdd = 0

let yAdd = 0
let rotateAdd = 0
let tile = this.state.activeTile

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

if (command === 'left') {

xAdd = -1
}

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

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

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

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

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

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

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

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

let xAddIsValid = true;

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

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を代入しましょう。

if (xAddIsValid) {

x += xAdd;
}

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

let newRotate = rotate + rotateAdd > 3 ? 0 : rotate + rotateAdd;

let rotateIsValid = true;

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

// 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を挿入します。

if (rotateIsValid) {

rotate = newRotate;
}

let yAddIsValid = true;

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

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

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

// 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;
}

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

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

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 (!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;
}
}

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

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

this.setState(prev => ({

score: prev.score + 1 * prev.level,
tileCount: prev.tileCount + 1,
level: 1 + Math.floor(prev.tileCount / 10)
}));

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

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
});

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


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

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

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

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の条件文ででゲームオーバーの判定を行っています。

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

this.setState({

field: field,
activeTileX: x,
activeTileY: y,
tileRotate: rotate,
activeTile: tile
});

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

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

handlePauseClick = () => {

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

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

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
});
};

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

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>
);
}

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

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

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

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

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

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

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的には良いと思うので気が向いたら改修します(向くかは分からない笑)