React.js版テトリス風ゲーム プログラムについて

概要

ゲーム本体はこちらにあります。

React.jsとは何か? みたいな話は適当にぐぐってください。

さて、 Canvas版ではCanvasにテトリスの画面を描画しましたが、 仮想DOMで画面を構築するReact.jsを使いながら画面描画にCanvasを使っていては React.jsのメリットも何もないわけで、React.js版では画面自体はtable要素で組んでいます。

コンポーネント指向のReact.jsのメリットを生かして、 ゲームのステージ本体と、Next欄は同じコンポーネント(Boardクラス)を使用している、 のですが、メリットというほどのものなんだろうかこれ……

React.js環境構築

ネット上にいくらでも情報は転がっていますが、私自身のメモも兼ねて。

この手の環境構築情報は、公式サイトを見るのが定石です。 日本語で読みたい気持ちはわかりますが、情報が古くてはまることが多いものです。 私の場合、使い方を調べるためにチュートリアルのページを読んだので、 そこにある手順で導入しました。

チュートリアルには、 「This is completely optional and not required for this tutorial!」 とか書いてあるけど、実際にはここまではやらないと、 自作アプリの公開ひとつできないし、役に立たないのではないかなあ。

私のPCはWindowsですが、以下の手順で構築できました。

  1. Node.jsのインストール
    React.jsの環境構築は、 Node.jsのパッケージ管理システムであるnpmを使うのが一番楽なようです。 公式サイトからNode.jsインストーラ(.msi)を落としてきてインストールするだけです。 だいぶ前に別件で入れたきり放置していたので、バージョンはv8.11.4、 npmのバージョンは5.6.0でした。
  2. アプリケーションのプロジェクトの作成

    コマンドラインで「npx create-react-app (アプリケーション名)」とタイプします。 こんな感じ。

    C:\maebashi\temp>mkdir reacttetris
    
    C:\maebashi\temp>cd reacttetris
    
    C:\maebashi\temp\reacttetris>npx create-react-app reacttetris
    npx: installed 1 in 2.765s
    Path must be a string. Received undefined
    npx: installed 91 in 12.11s
    C:\Users\kmaebashi\AppData\Roaming\npm-cache\_npx\10072\node_modules\create-react-app\index.js
    
    Creating a new React app in C:\maebashi\temp\reacttetris\reacttetris.
    
    Installing packages. This might take a couple of minutes.
    Installing react, react-dom, and react-scripts...
    
    
    > core-js@2.6.9 postinstall C:\maebashi\temp\reacttetris\reacttetris\node_modules\babel-runtime\node_modules\core-js
    > node scripts/postinstall || echo "ignore"
    
    
    > core-js-pure@3.1.4 postinstall C:\maebashi\temp\reacttetris\reacttetris\node_modules\core-js-pure
    > node scripts/postinstall || echo "ignore"
    
    + react@16.8.6
    + react-dom@16.8.6
    + react-scripts@3.0.1
    added 1405 packages in 105.713s
    
    Success! Created reacttetris at C:\maebashi\temp\reacttetris\reacttetris
    Inside that directory, you can run several commands:
    
      npm start
        Starts the development server.
    
      npm run build
        Bundles the app into static files for production.
    
      npm test
        Starts the test runner.
    
      npm run eject
        Removes this tool and copies build dependencies, configuration files
        and scripts into the app directory. If you do this, you can’t go back!
    
    We suggest that you begin by typing:
    
      cd reacttetris
      npm start
    
    Happy hacking!
    
    C:\maebashi\temp\reacttetris>
    

    インターネットにつながっていれば、これで必要なパッケージも、 (このディレクトリに)入ります。

  3. srcフォルダのファイルの全削除
    上記の手順で、reacttetrisフォルダが作られているので、 チュートリアルの手順に従い、その下のsrcフォルダの内容を全削除します。
  4. srcフォルダ内にindex.jsとindex.cssを作成
    これもチュートリアルに従い、ここにindex.jsとindex.cssファイルを作ります。
    今回のテトリスのindex.jsとindex.cssはこの下で掲載します。
  5. 起動
    新規作成されたreacttetrisフォルダ以下で以下のコマンドを実行することで、 Webサーバが起動して、 「http://localhost:3000/」でアプリケーションが参照できるようになります (通常は、ブラウザで「http://localhost:3000/」を起動しなくても、 勝手にブラウザが起動します)。
    C:\maebashi\temp\reacttetris\reacttetris>npm start
    
    > reacttetris@0.1.0 start C:\maebashi\temp\reacttetris\reacttetris
    > react-scripts start
    Starting the development server...
    Compiled successfully!
    
    You can now view reacttetris in the browser.
    
      Local:            http://localhost:3000/
      On Your Network:  http://192.168.56.1:3000/
    
    Note that the development build is not optimized.
    To create a production build, use npm run build.
    
  6. デプロイ
    npm startでアプリケーションが起動できても、 それだけでは実際に公開Webサイトに置いたりはできないので、 公開用のパッケージを作る必要があります(ここはチュートリアルには載っていない)。
    さっきのnpm startの実行結果には、「To create a production build, use npm run build.」と表示されてますが、 実際にはこれを実行する前に、reacttetrisフォルダ以下に生成された package.jsonファイルを修正する必要があります。
      1: {
      2:   "name": "reacttetris",
      3:   "version": "0.1.0",
      4:   "private": true,
      5:   "homepage": "./", ←この行を追加
      6:   "dependencies": {
      7:     "react": "^16.8.6",
      8:     "react-dom": "^16.8.6",
    (後略)
    
    その上で、「npm run build」を実行。
    C:\maebashi\temp\reacttetris\reacttetris>npm run build
    
    > reacttetris@0.1.0 build C:\maebashi\temp\reacttetris\reacttetris
    > react-scripts build
    
    Creating an optimized production build...
    Compiled successfully.
    
    File sizes after gzip:
    
      36.83 KB  build\static\js\2.98dcc9dd.chunk.js
      1.95 KB   build\static\js\main.41dbc1c1.chunk.js
      763 B     build\static\js\runtime~main.d653cc00.js
      515 B     build\static\css\main.eb45528a.chunk.css
    
    The project was built assuming it is hosted at ./.
    You can control this with the homepage field in your package.json.
    
    The build folder is ready to be deployed.
    
    Find out more about deployment here:
    
      https://bit.ly/CRA-deploy
    
    
    C:\maebashi\temp\reacttetris\reacttetris>
    
    これでbuildフォルダにできたファイル一式をどこかのWebサーバに置いて、 index.htmlにアクセスすれば、アプリケーションを使用できます。

ソース

Githubにも上げてありますが、

https://github.com/kmaebashi/reacttetris

別段動かしたいわけでもないけれど、ソースをざっと眺めたい、という人は以下を参照してください。

JavaScript

./index.js

  1: import React from 'react';
  2: import ReactDOM from 'react-dom';
  3: import './index.css';
  4: 
  5: class Board extends React.Component {
  6:   render() {
  7:     return (
  8:       <table className="board-table">
  9:         <tbody>
 10:         {this.props.table.map((row, index) => {
 11:           return (
 12:             <tr key={index}>
 13:               {row.map((td, index) => <td key={index} className={"block-type-" + td}></td>)}
 14:             </tr>
 15:           );
 16:         })}
 17:         </tbody>
 18:       </table>
 19:     );
 20:   }
 21: }
 22: 
 23: class TetrisInformationPanel extends React.Component {
 24:   render() {
 25:     return (
 26:       <div>
 27:       <p>LINES:{this.props.lines}</p>
 28:       <p>{this.props.message}</p>
 29:       </div>
 30:     );
 31:   }
 32: }
 33: 
 34: class Tetris extends React.Component {
 35:   constructor(props) {
 36:     super(props);
 37:     this.stageWidth = 10;
 38:     this.stageHeight = 20;
 39:     this.nextAreaSize = 6;
 40:     this.blocks = this.createBlocks();
 41:     this.deletedLines = 0;
 42:     this.message = "";
 43:     // boardTable, nextTableは、[y][x]の形式でアクセスする。
 44:     // HTMLのtableの生成順序と合わせるため。
 45:     this.boardTable = this.create2DArray(this.stageHeight, this.stageWidth);
 46:     this.nextTable = this.create2DArray(this.nextAreaSize, this.nextAreaSize);
 47: 
 48:     this.state = {
 49:       boardTable: this.clone2DArray(this.boardTable),
 50:       nextTable: this.clone2DArray(this.nextTable),
 51:       deletedLines: this.deletedLines,
 52:       message: this.message,
 53:     };
 54: 
 55:     window.onkeydown = (e) => {
 56:       if (this.currentBlock == null) {
 57:         return;
 58:       }
 59:       if (e.keyCode === 37) {
 60:         this.moveLeft();
 61:       } else if (e.keyCode === 38) {
 62:         this.rotate();
 63:       } else if (e.keyCode === 39) {
 64:         this.moveRight();
 65:       } else if (e.keyCode === 40) {
 66:          this.fall();
 67:       }
 68:     }
 69:   }
 70: 
 71:   componentDidMount() {
 72:     this.startGame();
 73:   }
 74: 
 75:   create2DArray(rows, cols) {
 76:     let array = new Array(rows);
 77:     for (let i = 0; i < rows; i++) {
 78:       array[i] = new Array(cols).fill(null);
 79:     }
 80:     return array;
 81:   }
 82: 
 83:   clone2DArray(src) {
 84:     let dest = new Array(src.length);
 85:     for (let i = 0; i < src.length; i++) {
 86:       dest[i] = src[i].slice();
 87:     }
 88:     return dest;
 89:   }
 90: 
 91:   createBlocks() {
 92:     let blocks = [
 93:       {
 94:         shape: [[[-1, 0], [0, 0], [1, 0], [2, 0]],
 95:                 [[0, -1], [0, 0], [0, 1], [0, 2]],
 96:                 [[-1, 0], [0, 0], [1, 0], [2, 0]],
 97:                 [[0, -1], [0, 0], [0, 1], [0, 2]]]
 98:       },
 99:       {
100:         shape: [[[0, 0], [1, 0], [0, 1], [1, 1]],
101:                 [[0, 0], [1, 0], [0, 1], [1, 1]],
102:                 [[0, 0], [1, 0], [0, 1], [1, 1]],
103:                 [[0, 0], [1, 0], [0, 1], [1, 1]]]
104:       },
105:       {
106:         shape: [[[0, 0], [1, 0], [-1, 1], [0, 1]],
107:                 [[-1, -1], [-1, 0], [0, 0], [0, 1]],
108:                 [[0, 0], [1, 0], [-1, 1], [0, 1]],
109:                 [[-1, -1], [-1, 0], [0, 0], [0, 1]]]
110:       },
111:       {
112:         shape: [[[-1, 0], [0, 0], [0, 1], [1, 1]],
113:                 [[0, -1], [-1, 0], [0, 0], [-1, 1]],
114:                 [[-1, 0], [0, 0], [0, 1], [1, 1]],
115:                 [[0, -1], [-1, 0], [0, 0], [-1, 1]]]
116:       },
117:       {
118:         shape: [[[-1, -1], [-1, 0], [0, 0], [1, 0]],
119:                 [[0, -1], [1, -1], [0, 0], [0, 1]],
120:                 [[-1, 0], [0, 0], [1, 0], [1, 1]],
121:                 [[0, -1], [0, 0], [-1, 1], [0, 1]]]
122:       },
123:       {
124:         shape: [[[1, -1], [-1, 0], [0, 0], [1, 0]],
125:                 [[0, -1], [0, 0], [0, 1], [1, 1]],
126:                 [[-1, 0], [0, 0], [1, 0], [-1, 1]],
127:                 [[-1, -1], [0, -1], [0, 0], [0, 1]]]
128:       },
129:       {
130:         shape: [[[0, -1], [-1, 0], [0, 0], [1, 0]],
131:                 [[0, -1], [0, 0], [1, 0], [0, 1]],
132:                 [[-1, 0], [0, 0], [1, 0], [0, 1]],
133:                 [[0, -1], [-1, 0], [0, 0], [0, 1]]]
134:       }
135:     ];
136:     return blocks;
137:   }  
138:   
139:   drawBlock(x, y, type, angle, table) {
140:     let block = this.blocks[type];
141:     for (let i = 0; i < block.shape[angle].length; i++) {
142:       this.drawCell(table,
143:                x + block.shape[angle][i][0],
144:                y + block.shape[angle][i][1],
145:                type);
146:     }
147:   }
148: 
149:   drawCell(table, x, y, type) {
150:     if (y < 0) {
151:       return;
152:     }
153:     table[y][x] = type;
154:   }
155: 
156:   startGame() {
157:     // virtualStageは、xxTableと異なり、[x][y]の形式でアクセスする。
158:     this.virtualStage = this.create2DArray(this.stageWidth, this.stageHeight);
159:     this.currentBlock = null;
160:     this.nextBlock = this.getRandomBlock();
161:     this.mainLoop();
162:   }
163: 
164:   mainLoop() {
165:     if (this.currentBlock == null) {
166:       if (!this.createNewBlock()) {
167:         return;
168:       }
169:     } else {
170:       this.fallBlock();
171:     }
172:     this.drawStage();
173:     if (this.currentBlock != null) {
174:       this.drawBlock(this.blockX, this.blockY,
175:           this.currentBlock, this.blockAngle, this.boardTable);
176:     }
177:     this.refresh();
178:     setTimeout(this.mainLoop.bind(this), 500);
179:   }
180: 
181:   refresh() {
182:     this.setState({
183:       boardTable: this.clone2DArray(this.boardTable),
184:       nextTable: this.clone2DArray(this.nextTable),
185:       deletedLines: this.deletedLines,
186:       message: this.message,
187:     });
188:   }
189: 
190:   createNewBlock() {
191:     this.currentBlock = this.nextBlock;
192:     this.nextBlock = this.getRandomBlock();
193:     this.blockX = Math.floor(this.stageWidth / 2 - 2);
194:     this.blockY = 0;
195:     this.blockAngle = 0;
196:     this.drawNextBlock();
197:     this.refresh();
198:     if (!this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, this.blockAngle)) {
199:       this.message = "GAME OVER";
200:       this.refresh();
201:       return false;
202:     }
203:     return true;
204:   }
205: 
206:   drawNextBlock() {
207:     this.clear(this.nextTable);
208:     this.drawBlock(2, 1, this.nextBlock, 0, this.nextTable);
209:   }
210: 
211:   getRandomBlock() {
212:     return  Math.floor(Math.random() * 7);
213:   }
214: 
215:   fallBlock() {
216:     if (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) {
217:         this.blockY++;
218:     } else {
219:         this.fixBlock(this.blockX, this.blockY, this.currentBlock, this.blockAngle);
220:         this.currentBlock = null;
221:     }
222:   }
223: 
224:   checkBlockMove(x, y, type, angle) {
225:     for (let i = 0; i < this.blocks[type].shape[angle].length; i++) {
226:       let cellX = x + this.blocks[type].shape[angle][i][0];
227:       let cellY = y + this.blocks[type].shape[angle][i][1];
228:       if (cellX < 0 || cellX > this.stageWidth - 1) {
229:           return false;
230:       }
231:       if (cellY > this.stageHeight - 1) {
232:           return false;
233:       }
234:       if (this.virtualStage[cellX][cellY] != null) {
235:           return false;
236:       }
237:     }
238:     return true;
239:   }
240: 
241:   fixBlock(x, y, type, angle) {
242:     for (let i = 0; i < this.blocks[type].shape[angle].length; i++) {
243:       let cellX = x + this.blocks[type].shape[angle][i][0];
244:       let cellY = y + this.blocks[type].shape[angle][i][1];
245:       this.virtualStage[cellX][cellY] = type;
246:     }
247:     for (let y = this.stageHeight - 1; y >= 0; ) {
248:       let filled = true;
249:       for (let x = 0; x < this.stageWidth; x++) {
250:         if (this.virtualStage[x][y] == null) {
251:           filled = false;
252:           break;
253:         }
254:       }
255:       if (filled) {
256:         for (let y2 = y; y2 > 0; y2--) {
257:           for (let x = 0; x < this.stageWidth; x++) {
258:             this.virtualStage[x][y2] = this.virtualStage[x][y2 - 1];
259:           }
260:         }
261:         this.deletedLines++;
262:       } else {
263:         y--;
264:       }
265:     }
266:   }
267: 
268:   drawStage() {
269:     this.clear(this.boardTable);
270: 
271:     for (let x = 0; x < this.virtualStage.length; x++) {
272:       for (let y = 0; y < this.virtualStage[x].length; y++) {
273:         if (this.virtualStage[x][y] != null) {
274:           this.drawCell(this.boardTable,
275:                         x, y, this.virtualStage[x][y]);
276:         }
277:       }
278:     }
279:   }
280: 
281:   moveLeft() {
282:     if (this.checkBlockMove(this.blockX - 1, this.blockY, this.currentBlock, this.blockAngle)) {
283:       this.blockX--;
284:       this.refreshStage();
285:     }
286:   }
287: 
288:   moveRight() {
289:     if (this.checkBlockMove(this.blockX + 1, this.blockY, this.currentBlock, this.blockAngle)) {
290:       this.blockX++;
291:       this.refreshStage();
292:     }
293:   }
294: 
295:   rotate() {
296:     let newAngle;
297:     if (this.blockAngle < 3) {
298:       newAngle = this.blockAngle + 1;
299:     } else {
300:       newAngle = 0;
301:     }
302:     if (this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, newAngle)) {
303:       this.blockAngle = newAngle;
304:       this.refreshStage();
305:     }
306:   }
307: 
308:   fall() {
309:     while (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) {
310:       this.blockY++;
311:       this.refreshStage();
312:     }
313:   }
314: 
315:   refreshStage() {
316:     this.clear(this.boardTable);
317:     this.drawStage();
318:     if (this.currentBlock != null) {
319:       this.drawBlock(this.blockX, this.blockY,
320:           this.currentBlock, this.blockAngle, this.boardTable);
321:     }
322:     this.refresh();
323:   }
324: 
325:   clear(table) {
326:     for (let y = 0; y < table.length; y++) {
327:       for (let x = 0; x < table[y].length; x++) {
328:         table[y][x] = null;
329:       }
330:     }
331:   }
332:   
333:   render() {
334:     return (
335:       <div className="wrapper-container">
336:         <span className="tetris-container">
337:           <Board
338:             table = {this.state.boardTable}
339:           />
340:           <span className="tetris-panel-container">
341:             <p>Next:</p>
342:             <Board
343:               table = {this.state.nextTable}
344:               />
345:             <TetrisInformationPanel
346:               lines = {this.state.deletedLines}
347:               message = {this.state.message}
348:             />
349:             <div className="tetris-panel-container-padding"></div>
350:             <table className="tetris-button-panel">
351:               <tbody>
352:               <tr>
353:                 <td></td>
354:                 <td id="tetris-rotate-button" className="tetris-button" onMouseDown = {this.rotate.bind(this)}>↻</td>
355:                 <td></td>
356:               </tr>
357:               <tr>
358:                 <td id="tetris-move-left-button"className="tetris-button" onMouseDown = {this.moveLeft.bind(this)}>←</td>
359:                 <td id="tetris-fall-button" className="tetris-button" onMouseDown = {this.fall.bind(this)}>↓</td>
360:                 <td id="tetris-move-right-button" className="tetris-button" onMouseDown = {this.moveRight.bind(this)}>→</td>
361:               </tr>
362:               </tbody>
363:             </table>
364:           </span>
365:         </span>
366:       </div>
367:     );
368:   }
369: }
370: 
371: // ========================================
372: 
373: ReactDOM.render(
374:   <Tetris />,
375:   document.getElementById('root')
376: );

CSS

CSSです。ここで、td要素に「block-type-(ブロック種別)」というクラスに色を与えることで、 各種のブロックを表示しています。

index.css

  1: html {
  2:     touch-action: manipulation;
  3: }
  4: 
  5: .wrapper-container {
  6:     display: inline-block;
  7: }
  8: 
  9: .tetris-container {
 10:     display: flex;
 11:     flex-direction: row;
 12:     margin: 10px;
 13:     background-color: #333333;
 14: }
 15: 
 16: .board-table {
 17:     border-collapse: separate;
 18:     border-spacing: 0;
 19: }
 20: .board-table td {
 21:     width: 25px;
 22:     height: 25px;
 23:     margin: 0px;
 24:     padding: 0px;
 25:     border: solid 1px black;
 26:     background-color: black;
 27: }
 28: 
 29: td.block-type-0 {
 30:     background-color: #00ffff;
 31:     border-top: 1px #ffffff solid;
 32:     border-left: 1px #ffffff solid;
 33:     border-right: 1px #008080 solid;
 34:     border-bottom: 1px #008080 solid;
 35: }
 36: 
 37: td.block-type-1 {
 38:     background-color: #ffff00;
 39:     border-top: 1px #ffffff solid;
 40:     border-left: 1px #ffffff solid;
 41:     border-right: 1px #808000 solid;
 42:     border-bottom: 1px #808000 solid;
 43: }
 44: 
 45: td.block-type-2 {
 46:     background-color: #00ff00;
 47:     border-top: 1px #ffffff solid;
 48:     border-left: 1px #ffffff solid;
 49:     border-right: 1px #008000 solid;
 50:     border-bottom: 1px #008000 solid;
 51: }
 52: 
 53: td.block-type-3 {
 54:     background-color: #ff0000;
 55:     border-top: 1px #ffffff solid;
 56:     border-left: 1px #ffffff solid;
 57:     border-right: 1px #800000 solid;
 58:     border-bottom: 1px #800000 solid;
 59: }
 60: 
 61: td.block-type-4 {
 62:     background-color: #0000ff;
 63:     border-top: 1px #ffffff solid;
 64:     border-left: 1px #ffffff solid;
 65:     border-right: 1px #000080 solid;
 66:     border-bottom: 1px #000080 solid;
 67: }
 68: 
 69: td.block-type-5 {
 70:     background-color: #ffa500;
 71:     border-top: 1px #ffffff solid;
 72:     border-left: 1px #ffffff solid;
 73:     border-right: 1px #ff5200 solid;
 74:     border-bottom: 1px #ff5200 solid;
 75: }
 76: 
 77: td.block-type-6 {
 78:     background-color: #ff00ff;
 79:     border-top: 1px #ffffff solid;
 80:     border-left: 1px #ffffff solid;
 81:     border-right: 1px #800080 solid;
 82:     border-bottom: 1px #800080 solid;
 83: }
 84: 
 85: .tetris-panel-container {
 86:     display: flex;
 87:     padding-left: 10px;
 88:     padding-right: 10px;
 89:     flex-direction: column;
 90:     color: white;
 91:     background-color: #333333;
 92: }
 93: 
 94: .tetris-panel-container-padding {
 95:     flex-grow: 1;
 96: }
 97: 
 98: .tetris-panel-container p {
 99:    margin: 0;
100:    padding: 0;
101: }
102: 
103: .tetris-button-panel {
104:     border-style: none;
105:     width: 100%;
106: }
107: 
108: .tetris-button {
109:     padding-top: 10px;
110:     padding-bottom: 10px;
111:     text-align: center;
112:     background: #444444;
113: }

やってみて勉強になったこと――というか疑問点


ひとつ上のページへ戻る | トップページへ戻る