ExcelとJSカスタマイズでテトリス申請を作ってみた

この記事は [コラボフロー Advent Calendar 2022](https://qiita.com/advent-calendar/2022/collaboflow) の17日目の記事です。
（追記：[コラボフロー Advent Calendar 2023](https://qiita.com/tetrer/items/21b5670740cb5ebd3a12) で、続編を書きました）
（追記：[コラボフロー Advent Calendar 2024](https://qiita.com/tetrer/items/43046d99f9841a7337cd) で、続編を書きました）

- - -

**「あ゙ぁ…ブロッ…ク消したぃ…」**
**「ゔぁーっ、ブロック消じたいぃーーーーっ！:scream:」**
と、禁断症状におそわれる人がいるくらい、テトリスには中毒性があります。
「[テトリス効果](http://ja.wikipedia.org/w/index.php?curid=1537871)」の症状を持つ人も、意外に身近にいるかもしれません。

一方でテトリスによりストレスが解消され、救われた人もいるとかいないとか。
（知らんけどっ！！）

とにかくテトリス好きの私が、コラボフロー上でテトリスができるよう
カスタマイズして「テトリス申請」を作ってみました！

そしてプレイしてみた様子がこちら:tada:
![tetris_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953201/4ca41749-f5c7-590b-de06-7d605ab6b249.gif)

申請書のフォーム設定や、カスタマイズ内容などの一部をご紹介します。

# Excel方眼紙で画面作成

コラボフローはExcelファイルを取り込んで申請書フォームを作れるので、
テトリス申請のレイアウトをExcel方眼紙で簡単に変えられます :heart_eyes:
※[Excelフォームを作成する &ndash; コラボフローサポート](https://collaboflow.zendesk.com/hc/ja/articles/360000415915)

縦長にしたり横長にしたり、色を変えたり。
![tetris_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953201/bf278a79-68c6-2e9a-1cf5-4f977da3e2d0.gif)
（50列もあると1段消すのも大変:sweat_drops:）

# フォームパーツ設定とブロック表示

フォーム設定で、テトリスのブロック表示エリアには表形式の
「テキスト（一行）」パーツを配置し、値の手入力は不可の設定にしました。
パーツIDはカスタマイズで扱いやすいよう、
列の左からfidColumn1～fidColumn10と連番で設定しています。
![tetris_3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953201/2c4fdc51-45d0-67ae-9cba-0a044099d945.jpeg)

そしてテトリスのブロックは「テキスト（一行）」パーツに
JavaScript APIで「■」の文字を値としてセットすることで表現しています。

落下中は普通の「■」、着地したら少し大きい「█」、
消す直前は白抜き「□」をセットと、状態により切り替えています。
![tetris_4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953201/5e9a9453-6715-3297-5e40-616e02a1f119.gif)
（ブロックの表示を切り替えたら、プレイしやすくなりました:thumbsup:）

# ブロックの配置管理

ブロック表示エリアは、カスタマイズプログラム内で
以下のように縦横の数値配列で管理しています。
![tetris_5.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953201/cb121f5e-c306-4d37-0f9a-3c24d597bbd7.jpeg)

0のところは空欄で落下ブロックを移動できるけど、
0以外は壁や着地済みブロックで移動できない場所になります。

また、JavaScript APIでは表形式の[パーツオブジェクト](https://collaboflow.zendesk.com/hc/ja/articles/360000266835)も配列なので、
配置管理配列と連動させてパーツに値をセットすることができます。

例えば、1行1列目にブロックを配置する場合は、
`parts.tbl_1.value[0].fidCloumn1.value = '■'`
といった具合です。

# ブロックの落下

setInterval()関数を使い、一定間隔でブロックを下段に移動させています。
引数「delay」の値を小さくすることで、落下を速くすることができます。

https://developer.mozilla.org/ja/docs/Web/API/setInterval

出現するブロックも、カスタマイズ次第で自由に作れます。
![tetris_6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953201/a150bc3c-7f57-e9b4-2ee7-da95db6325ba.gif)
（どデカいブロック落ちて来た:bomb:）

# おわりに

@miko3 さんから教えてもらった[紙吹雪ライブラリ](https://party.js.org/)を使ったら、画面が賑やかになりました:sparkles:
![tetris_7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953201/75e30cea-f51e-de96-e8cc-6ff5b9720c4f.gif)
（連続で消せると爽快っ:star:）

キー入力イベントの話とか、ブロック回転の話とか、[Tスピン](https://w.atwiki.jp/tetrismaps/pages/106.html)の話とか、
得点加算の話とかいろいろあるけど、面倒になったので今回はここまで！

カスタマイズのデバッグと称してテトリスやり過ぎました:laughing::boom:

以下がテトリスカスタマイズのサンプルコードです。
テトリス狂のみなさんの参考になれば幸いです。
<details><summary>collaboflow_tetris.js</summary>

```js
class collaboTetris {
	'use strict';

	constructor() {
		this.fallingMino = {
			type: null,
			length: 0,
			y:0,
			x:0,
			direction: 0
		};
		this.nextMinoType = null;

		this.outputText = ['','■','█','□'];
		this.minoType = {
			empty: 0,
			move: 1,
			landed: 2,
			remove: 3
		};

		this.minoShapes = {
			T: [[0, 1, 0],[1, 1, 1],[0, 0, 0]],
			S: [[0, 1, 1],[1, 1, 0],[0, 0, 0]],
			Z: [[1, 1, 0],[0, 1, 1],[0, 0, 0]],
			L: [[0, 0, 1],[1, 1, 1],[0, 0, 0]],
			J: [[1, 0, 0],[1, 1, 1],[0, 0, 0]],
			O: [[0, 0, 0, 0],[0, 1, 1, 0],[0, 1, 1, 0],[0, 0, 0, 0]],
			I: [[0, 0, 0, 0],[1, 1, 1, 1],[0, 0, 0, 0],[0, 0, 0, 0]]
		};

		this.isPlaying = false;
		this.score = 0;
		this.startTime = 0;
		this.baseDelay = 300;
		this.currentDelay = 300;
		this.minDelay = 100;
		this.timerId = '';
	}

	set scorePartsId(scorePartsId) {
		this._scorePartsId = scorePartsId;
	}

	set stagePartsId(stagePartsId) {
		this._stagePartsId = stagePartsId;
	}

	set nextMinoPartsId(nextMinoPartsId) {
		this._nextMinoPartsId = nextMinoPartsId;
	}

	set parts(parts) {
		this.scoreParts = parts[this._scorePartsId];
		this.tableParts = parts['tbl_1'].value;
	}

	init() {
		if (!this._stagePartsId) {
			alert('Part ID prefix is undefined.');
			return;
		}

		this.rowLength = this.tableParts.length;
		const stagePartsId = this._stagePartsId;
		const stageParts = Object.keys(this.tableParts[0]).filter(function(partsId){
			return partsId.startsWith(stagePartsId);
		});
		this.columnLength = stageParts.length;
		this.stageMinos = this.createStage();
		this.emptyRow = this.stageMinos[1].concat();
		this.setCSS();
	}

	createStage() {
		let minos = [];
		const rowMaxIndex = this.rowLength + 1;
		const columnMaxIndex = this.columnLength + 1;
		for (let rowIndex = 0; rowIndex <= rowMaxIndex; rowIndex++) {
			minos[rowIndex] = [];
			for (let columnIndex = 0; columnIndex <= columnMaxIndex; columnIndex++) {
				let value = this.minoType.empty;
				if (rowIndex == 0 || rowIndex == rowMaxIndex) {
					value = 1;
				}
				else if (columnIndex == 0 || columnIndex == columnMaxIndex) {
					value = 1;
				}
				minos[rowIndex][columnIndex] = value;
			}
		}

		return minos;
	}

	setCSS() {
		const style = document.createElement('style');
		document.head.appendChild(style);
		const sheet = style.sheet;
		sheet.insertRule('.mino_container {height:20px; width:20px; font-size:18px;}', 0 );

        let elements = document.querySelectorAll('div[id^="div_' + this._stagePartsId + '"]');
		Array.prototype.forEach.call(elements, function (element) {
			element.classList.add('mino_container');
		});

		if (!this._nextMinoPartsId) {
			return;
		}
		elements = document.querySelectorAll('div[id^="div_' + this._nextMinoPartsId + '"]');
		Array.prototype.forEach.call(elements, function (element) {
			element.classList.add('mino_container');
		});
	}

	play() {
		this.init();
		document.onkeydown = this.keydownEvent.bind(this);
	}

	show() {
		this.setCSS();
	}

	keydownEvent(event) {
		const keyCode = event.code;
		if (!this.isPlaying && keyCode === 'Enter') {
			this.gameStart();
			return;
		}

		if (this.fallingMino.type === null) {
			return;
		}

		let destination = {y:0, x:0};
		let isRotate = false;
		switch (keyCode) {
			case 'Enter':
				console.log('STOP');
				clearInterval(this.timerId);
				this.isPlaying = false;
				return;
			case 'ArrowUp':
				this.hardDropMino();
				return;
			case 'ArrowDown':
				destination.y = 1;
				break;
			case 'ArrowLeft':
				destination.x = -1;
				break;
			case 'ArrowRight':
				destination.x = 1;
				break;
			case 'Space':
				isRotate = true;
				break;
			default:
				return;
		}

		this.setFallingMino(destination.y, destination.x, isRotate);
	}

	setNextMino() {
		const shape = Object.keys(this.minoShapes);
		let shapeIndex = Math.floor(Math.random() * shape.length);
		const minoType = this.nextMinoType = shape[shapeIndex];

		if (this.fallingMino.type === null || !this._nextMinoPartsId) {
			return;
		}

		const minoLength = this.minoShapes[minoType].length;
		for (let rowIndex = 0; rowIndex <= 3; rowIndex++) {
			for (let columnIndex = 0; columnIndex <= 3; columnIndex++) {
				let value = 0;

				if (rowIndex < minoLength && columnIndex < minoLength) {
					value = this.minoShapes[minoType][rowIndex][columnIndex];
				}

				let columnName = this._nextMinoPartsId + (columnIndex + 1);
				this.tableParts[rowIndex][columnName].value = this.outputText[value];
			}
		}
	}

	createNewMino() {
		const minoType = this.nextMinoType;
		const minoLength = this.minoShapes[minoType].length;
		const y = minoLength === 4 ? 0 : 1;
		const x = Math.round((this.columnLength - minoLength + 1) / 2);
		this.fallingMino = {type:minoType, length:minoLength, y:y, x:x, direction:0};

		if(!this.setFallingMino(0, 0)) {
			this.gameOver();
			return;
		}
	}

	gameStart() {
		if (this.fallingMino.type === null) {
			console.log('START');
			this.startTime = new Date();
			this.setNextMino();
			this.showMino();
		}
		else {
			console.log('RESTART');
		}
		this.isPlaying = true;
		this.timerId = setInterval(this.autoDownMino, this.currentDelay, this);
	}

	gameOver() {
		console.log('GAME OVER');
		clearInterval(this.timerId);
		this.isPlaying = false;
		this.moveMino(0, 0, 0);
		document.onkeydown = null;
	}

	setFallingMino(destinationY, destinationX, isRotate) {
		let direction = this.fallingMino.direction;
		if (isRotate) {
			direction = (direction + 1) % 4;
		}

		if (this.isCollision(destinationY, destinationX, direction)) {
			if (!isRotate) {
				return false;
			}

			const simulated = this.simulateRotate(direction);
			if (!simulated.success) {
				return false;
			}
			destinationY = simulated.destinationY
			destinationX = simulated.destinationX;
		}

		this.moveMino(destinationY, destinationX, direction);

		return true;
	}

	rotateMino(mino) {
		const minoLength = mino.length;
		let rotatedMino = [];
		for (let rowIndex = 0; rowIndex < minoLength; rowIndex++) {
			rotatedMino[rowIndex] = [];
			for (let columnIndex = 0; columnIndex < minoLength; columnIndex++) {
				rotatedMino[rowIndex][columnIndex] = mino[minoLength - 1 - columnIndex][rowIndex];
			}
		}

		return rotatedMino;
	};

	isCollision(destinationY, destinationX, direction) {
		const mino = this.getMinoArray(direction);
		const minoLength = this.fallingMino.length;

		for (let minoRowIndex = 0; minoRowIndex < minoLength; minoRowIndex++) {
			let stageY = this.fallingMino.y + minoRowIndex + destinationY;
			if (this.stageMinos.length <= stageY) {
				break;
			}

			for (let minoColumnIndex = 0; minoColumnIndex < minoLength; minoColumnIndex++) {
				let minoValue = mino[minoRowIndex][minoColumnIndex];
				let stageX = this.fallingMino.x + minoColumnIndex + destinationX;
				let stageValue = this.stageMinos[stageY][stageX];

				if (minoValue && stageValue) {
					return true;
				}
			}
		}

		return false;
	}

	getMinoArray(direction) {
		let mino = this.minoShapes[this.fallingMino.type];
		if (direction === 0) {
			return mino;
		}

		for (let rotateIndex = 0; rotateIndex < direction; rotateIndex++) {
			mino = this.rotateMino(mino);
		}

		return mino;
	}

	moveMino(destinationY, destinationX, direction) {
		this.resetStageMino();
		this.fallingMino.y += destinationY;
		this.fallingMino.x += destinationX;
		this.fallingMino.direction = direction;
		const mino = this.getMinoArray(direction);
		const minoLength = mino.length;

		for (let minoRowIndex = 0; minoRowIndex < minoLength; minoRowIndex++) {
			let setY = this.fallingMino.y + minoRowIndex;
			if (this.rowLength < setY) {
				return;
			}

			for (let minoColumnIndex = 0; minoColumnIndex < minoLength; minoColumnIndex++) {
				let value = mino[minoRowIndex][minoColumnIndex];

				if (!value) {
					continue;
				}

				let setX = this.fallingMino.x + minoColumnIndex;
				if (setX <= 0 || this.columnLength < setX) {
					continue;
				}

				let columnName = this._stagePartsId + setX;
				this.tableParts[setY - 1][columnName].value = this.outputText[value];
			}
		}
	}

	resetStageMino() {
		const endY = this.fallingMino.y + this.fallingMino.length;
		const endX = this.fallingMino.x + this.fallingMino.length;

		for (let rowIndex = this.fallingMino.y; rowIndex < endY; rowIndex++) {
			if (this.rowLength < rowIndex) {
				return;
			}

			if (rowIndex <= 0) {
				continue;
			}

			for (let columnIndex = this.fallingMino.x; columnIndex < endX; columnIndex++) {
				if (columnIndex <= 0 || this.columnLength < columnIndex) {
					continue;
				}

				let columnName = this._stagePartsId + columnIndex;
				let value = this.stageMinos[rowIndex][columnIndex];
				this.tableParts[rowIndex - 1][columnName].value = this.outputText[value];
			}
		}
	};

	hardDropMino() {
		const destinationX = 0;
		const direction = this.fallingMino.direction;
		let destinationY = 0;

		for (destinationY; destinationY < this.rowLength; destinationY++) {
			if (this.isCollision(destinationY, destinationX, direction)) {
				break;
			}
		}

		this.moveMino(destinationY - 1, destinationX, direction);
	}

	simulateRotate(direction) {
		let result = {
			success: false,
			y: 0,
			x: 0
		};

		for (let destinationY of [0, 1]) {
			for (let destinationX of [-1, 1, -2, 2]) {
				if (!this.isCollision(destinationY, destinationX, direction)) {
					result = {
						success: true,
						destinationY: destinationY,
						destinationX: destinationX
					};
					return result;
				}
			}
		}

		return result;
	}

	autoDownMino(tetris) {
		if (!tetris.isPlaying) {
			clearInterval(tetris.timerId);
			return;
		}

		if (tetris.fallingMino.type === null) {
			tetris.createNewMino();
			tetris.setNextMino();
			return;
		}

		if(!tetris.setFallingMino(1, 0, false)) {
			tetris.landMino();
			tetris.checkFilledRow();
			tetris.showMino();
			tetris.fallingMino.type = null;
		}

		tetris.controlSpeed();
	}

	landMino() {
		const mino = this.getMinoArray(this.fallingMino.direction);
		const minoLength = mino.length;
		this.fallingMino.type = null;

		for (let minoRowIndex = 0; minoRowIndex < minoLength; minoRowIndex++) {
			let setY = this.fallingMino.y + minoRowIndex;
			for (let minoColumnIndex = 0; minoColumnIndex < minoLength; minoColumnIndex++) {
				let value = mino[minoRowIndex][minoColumnIndex];
				if (!value) {
					continue
				}

				let setX = this.fallingMino.x + minoColumnIndex;
				this.stageMinos[setY][setX] = this.minoType.landed;
			}
		}
	}

	showMino() {
		for (let rowIndex = 1; rowIndex <= this.rowLength; rowIndex++) {
			for (let columnIndex = 1; columnIndex <= this.columnLength; columnIndex++) {
				let columnName = this._stagePartsId + columnIndex;
				let value = this.stageMinos[rowIndex][columnIndex];
				this.tableParts[rowIndex - 1][columnName].value = this.outputText[value];
			}
		}
	};

	checkFilledRow() {
		const top = this.fallingMino.y;
		let bottom = top + this.fallingMino.length - 1;
		bottom = Math.min(bottom, this.rowLength);

		let removeRow = [];
		for (let rowIndex = top; rowIndex <= bottom; rowIndex++) {
			if (!this.stageMinos[rowIndex].includes(0)) {
				removeRow.push(rowIndex);
				for (let columnIndex = 1; columnIndex <= this.columnLength; columnIndex++) {
					this.stageMinos[rowIndex][columnIndex] = this.minoType.remove;
				}
			};
		}

		if (removeRow.length) {
			clearInterval(this.timerId);
			this.isPlaying = false;
			setTimeout(this.removeFilledRow.bind(this, removeRow), 200);
		}
	}

	removeFilledRow(removeRow) {
		for (let rowIndex of removeRow) {
			this.stageMinos.splice(rowIndex, 1);
			this.stageMinos.splice(1, 0, this.emptyRow.concat());
		}
		this.addScore(removeRow.length);

		this.isPlaying = true;
		this.showMino();
		this.timerId = setInterval(this.autoDownMino, this.currentDelay, this);
	}

	addScore(removeRow) {
		if (!removeRow || !this.scoreParts) {
			return;
		}

		const now = new Date();
		const passedTime = parseInt((now.getTime() - this.startTime.getTime()) / 1000);
		const passedTimeScore = Math.floor(passedTime / (5 - removeRow));

		const scoreTable = [0, 100, 300, 500, 800];
		this.score += scoreTable[removeRow] + passedTimeScore;
		this.scoreParts.value = this.score;
	}

	controlSpeed() {
		if (this.currentDelay <= this.minDelay) {
			return;
		}

		const now = new Date();
		const passedTime = parseInt((now.getTime() - this.startTime.getTime()) / 1000);
		const delayCoefficient = 10;

		if (!(passedTime % delayCoefficient)) {
			const delay = this.baseDelay - Math.floor(passedTime / delayCoefficient) * 10;
			if (delay === this.currentDelay) {
				return;
			}

			clearInterval(this.timerId);
			this.currentDelay = delay;
			this.timerId = setInterval(this.autoDownMino, delay, this);
		}
	}
}
```
</details>

<details><summary>collaboflow_events.js</summary>

```js
(function () {
	'use strict';

	const tetris = new collaboTetris();
	tetris.stagePartsId = 'fidColumn';
	tetris.nextMinoPartsId = 'fidNextMino';
	tetris.scorePartsId = 'fidSCORE';

	collaboflow.events.on('request.input.show', function (data) {
		tetris.parts = data.parts;
		tetris.play();
	});

	const showEvents = ['request.confirm.show', 'request.detail.show', 'request.judgement.show'];
	collaboflow.events.on(showEvents, function (data) {
		tetris.show();
	});

})();
```
</details>


明日の[コラボフロー Advent Calendar 2022](https://qiita.com/advent-calendar/2022/collaboflow)は、@hatanowf さんです！