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

概要

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

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

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

環境構築(というほどのものでもない)

今回は、公式にあるとおりCDNからダウンロードする形で済ませています。開発版です。

<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>

React.jsの時の苦労を考えればずっと楽ですし、Vue.jsはページ内で部分的に適用していくこともできます。この点はReact.jsよりもずっとよいと思います。

ソース

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

https://github.com/kmaebashi/vuetetris

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

HTML

./tetris.html

  1: <!DOCTYPE html>
  2: <html>
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>Vue.js Tetris</title>
  6: <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  7: <link rel="stylesheet" type="text/css" href="tetris.css">
  8: <meta name="viewport" content="width=device-width,initial-scale=1">
  9: <script type="text/javascript" src="tetris.js"></script>
 10: </head>
 11:   <h1>Vue.js版 テトリス風ゲーム</h1>
 12:   <div class="wrapper-container" id="wrapper-container">
 13:     <span class="tetris-container">
 14:       <div id="tetris-main-board">
 15:         <tetris-table ref="mainBoard" v-bind:contents="mainTable"></tetris-table>
 16:       </div>
 17:       <span class="tetris-panel-container">
 18:         <p>Next:</p>
 19:         <div id="tetris-next-board">
 20:           <tetris-table ref="nextBoard" v-bind:contents="nextTable"></tetris-table>
 21:         </div>
 22:         <p>LINES:<span id="lines">0</span></p>
 23:         <p><span id="message"></span></p>
 24:         <div class="tetris-panel-container-padding"></div>
 25:         <table class="tetris-button-panel">
 26:           <tr>
 27:             <td></td>
 28:             <td id="tetris-rotate-button" class="tetris-button">↻</td>
 29:             <td></td>
 30:           </tr>
 31:           <tr>
 32:             <td id="tetris-move-left-button"class="tetris-button">←</td>
 33:             <td id="tetris-fall-button"class="tetris-button">↓</td>
 34:             <td id="tetris-move-right-button"class="tetris-button">→</td>
 35:           </tr>
 36:         </table>
 37:       </span>
 38:     </span>
 39:   </div>
 40:   <script>
 41:     const vm = new Vue({
 42:                    el: '#wrapper-container',
 43:                    data: {
 44:                      mainTable: create2DArray(20, 10),
 45:                      nextTable: create2DArray(6, 6)
 46:                    },
 47:                    methods: {
 48:                      update(mainTable, nextTable) {
 49:                        this.mainTable = mainTable;
 50:                        this.nextTable = nextTable;
 51:                        const main = this.$refs.mainBoard;
 52:                        main.update(mainTable);
 53:                        const next = this.$refs.nextBoard;
 54:                        next.update(nextTable);
 55:                      }
 56:                    }
 57:                  });
 58:     const tetris = new Tetris();
 59:     tetris.startGame();
 60:   </script>
 61:   <hr/>
 62:   <a href="./index.html">ひとつ上のページへ戻る</a>
 63: | <a href="../../index.html">トップページへ戻る</a>
 64: <body>
 65: </html>

12行目のwrapper-containerというIDのdivにマウントする形で、41行目でVueオブジェクトを作っています。この要素内が、Vue.jsが管轄する領域になります。elというのは、どうもelementの省略形のようです。

14行目のtetris-main-board、19行目のtetris-next-boardというふたつのdivは、それぞれメインのテトリスのゲームエリアと、次に出てくるブロックを表示するエリアを意味しますが、どちらも中にtetris-tableという要素を持っています。これがtetris.jsに出てくるtetris-tableというコンポーネントです。

JavaScript

./tetris.js

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

プログラム本体の大半はCanvas版やReact版と共通ですが、9~33行目で、ゲームエリアと次のブロックを表示するエリアのコンポーネントを定義しています。24行目からのテンプレート定義がミソで、ここで、table内のtrとtdをそれぞれv-forによるfor文でループさせ、2重ループでtableを作り出しています。このループの内側に「class="'block-type-' + cell">」と書いてあり、このcellがブロックの種別を表しています。これに対しCSSで色を与えることで、ブロックを描画しています。現状、ブロックのないところは、「block-type-null」となってしまいますが、気にしないことにします。

ここで、テンプレートをこんな形で書けるのは、ES6から導入されたテンプレートリテラルの機能のおかげですね。

CSS

CSSです。ここでblock-type-1block-type-6というクラスを定義しており、これをtdに指定することで色を付けることができます。

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: }

やってみてはまったこと

最初作ったとき、ブロックが初回以外描画されませんでした。

どうもVue.jsというのは、配列を変更したことを検知するのを、push()とかsplice()とかのメソッドにフックを入れることで実現しているようで、配列の要素にarray[i] = 5;のような形で直接代入すると、変更が検知されずDOMに反映されないようです。みんなはまっているようで、たとえばQiitaのこのページなんかでも解説されています。

テトリスなんて仮想画面は当然2次元配列で、その中身を更新するときは直接代入するに決まっています。こんなの知らなきゃわかんねーよ……というわけで私もはまりました。結局splice()に置き換えて解決(tetris.jsの157行目。$setは、ないと言われて呼べなかった)。


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