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は、ないと言われて呼べなかった)。