LoginSignup
12
4

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

Last updated at Posted at 2022-12-17

この記事は コラボフロー Advent Calendar 2022 の17日目の記事です。
(追記:コラボフロー Advent Calendar 2023 で、続編を書きました)


「あ゙ぁ…ブロッ…ク消したぃ…」
「ゔぁーっ、ブロック消じたいぃーーーーっ!:scream:
と、禁断症状におそわれる人がいるくらい、テトリスには中毒性があります。
テトリス効果」の症状を持つ人も、意外に身近にいるかもしれません。

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

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

そしてプレイしてみた様子がこちら:tada:
tetris_1.gif

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

Excel方眼紙で画面作成

コラボフローはExcelファイルを取り込んで申請書フォームを作れるので、
テトリス申請のレイアウトをExcel方眼紙で簡単に変えられます :heart_eyes:
Excelフォームを作成する – コラボフローサポート

縦長にしたり横長にしたり、色を変えたり。
tetris_2.gif
(50列もあると1段消すのも大変:sweat_drops:

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

フォーム設定で、テトリスのブロック表示エリアには表形式の
「テキスト(一行)」パーツを配置し、値の手入力は不可の設定にしました。
パーツIDはカスタマイズで扱いやすいよう、
列の左からfidColumn1~fidColumn10と連番で設定しています。
tetris_3.jpg

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

落下中は普通の「■」、着地したら少し大きい「█」、
消す直前は白抜き「□」をセットと、状態により切り替えています。
tetris_4.gif
(ブロックの表示を切り替えたら、プレイしやすくなりました:thumbsup:

ブロックの配置管理

ブロック表示エリアは、カスタマイズプログラム内で
以下のように縦横の数値配列で管理しています。
tetris_5.jpg

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

また、JavaScript APIでは表形式のパーツオブジェクトも配列なので、
配置管理配列と連動させてパーツに値をセットすることができます。

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

ブロックの落下

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

出現するブロックも、カスタマイズ次第で自由に作れます。
tetris_6.gif
(どデカいブロック落ちて来た:bomb:

おわりに

@miko3 さんから教えてもらった紙吹雪ライブラリを使ったら、画面が賑やかになりました:sparkles:
tetris_7.gif
(連続で消せると爽快っ:star:

キー入力イベントの話とか、ブロック回転の話とか、Tスピンの話とか、
得点加算の話とかいろいろあるけど、面倒になったので今回はここまで!

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

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

collaboflow_tetris.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);
		}
	}
}
collaboflow_events.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();
	});

})();

明日のコラボフロー Advent Calendar 2022は、@hatanowf さんです!

12
4
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
4