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
別段動かしたいわけでもないけれど、ソースをざっと眺めたい、という人は以下を参照してください。
./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というコンポーネントです。
./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です。ここでblock-type-1~block-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は、ないと言われて呼べなかった)。