この記事は コラボフロー Advent Calendar 2022 の17日目の記事です。
(追記:コラボフロー Advent Calendar 2023 で、続編を書きました)
「あ゙ぁ…ブロッ…ク消したぃ…」
「ゔぁーっ、ブロック消じたいぃーーーーっ!」
と、禁断症状におそわれる人がいるくらい、テトリスには中毒性があります。
「テトリス効果」の症状を持つ人も、意外に身近にいるかもしれません。
一方でテトリスによりストレスが解消され、救われた人もいるとかいないとか。
(知らんけどっ!!)
とにかくテトリス好きの私が、コラボフロー上でテトリスができるよう
カスタマイズして「テトリス申請」を作ってみました!
申請書のフォーム設定や、カスタマイズ内容などの一部をご紹介します。
Excel方眼紙で画面作成
コラボフローはExcelファイルを取り込んで申請書フォームを作れるので、
テトリス申請のレイアウトをExcel方眼紙で簡単に変えられます
※Excelフォームを作成する – コラボフローサポート
縦長にしたり横長にしたり、色を変えたり。
(50列もあると1段消すのも大変)
フォームパーツ設定とブロック表示
フォーム設定で、テトリスのブロック表示エリアには表形式の
「テキスト(一行)」パーツを配置し、値の手入力は不可の設定にしました。
パーツIDはカスタマイズで扱いやすいよう、
列の左からfidColumn1~fidColumn10と連番で設定しています。
そしてテトリスのブロックは「テキスト(一行)」パーツに
JavaScript APIで「■」の文字を値としてセットすることで表現しています。
落下中は普通の「■」、着地したら少し大きい「█」、
消す直前は白抜き「□」をセットと、状態により切り替えています。
(ブロックの表示を切り替えたら、プレイしやすくなりました)
ブロックの配置管理
ブロック表示エリアは、カスタマイズプログラム内で
以下のように縦横の数値配列で管理しています。
0のところは空欄で落下ブロックを移動できるけど、
0以外は壁や着地済みブロックで移動できない場所になります。
また、JavaScript APIでは表形式のパーツオブジェクトも配列なので、
配置管理配列と連動させてパーツに値をセットすることができます。
例えば、1行1列目にブロックを配置する場合は、
parts.tbl_1.value[0].fidCloumn1.value = '■'
といった具合です。
ブロックの落下
setInterval()関数を使い、一定間隔でブロックを下段に移動させています。
引数「delay」の値を小さくすることで、落下を速くすることができます。
出現するブロックも、カスタマイズ次第で自由に作れます。
(どデカいブロック落ちて来た)
おわりに
@miko3 さんから教えてもらった紙吹雪ライブラリを使ったら、画面が賑やかになりました
(連続で消せると爽快っ)
キー入力イベントの話とか、ブロック回転の話とか、Tスピンの話とか、
得点加算の話とかいろいろあるけど、面倒になったので今回はここまで!
カスタマイズのデバッグと称してテトリスやり過ぎました
以下がテトリスカスタマイズのサンプルコードです。
テトリス狂のみなさんの参考になれば幸いです。
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 さんです!