JavaScript でテトリスを開発する その 5
第 5 回です。今回は仕様 ⑤ テトリミノは画面内でかつ他ブロックに干渉しなければ自由に回転できる を実装していきます。
① 画面サイズは縦 20 ブロック分、横 10 ブロック分である →その 1 参照
② 4 つの正方形ブロックで構成されるテトリミノがある →その 2 参照
③ テトリミノは 7 種類ある →その 3 参照
④ テトリミノは画面内でかつ他ブロックに干渉しなければ自由に動かせる →その 4 参照
⑤ テトリミノは画面内でかつ他ブロックに干渉しなければ自由に回転できる
⑥ テトリミノは画面最下部または他ブロックの上部に面したとき動かせなくなる
⑦ テトリミノは画面上部に生成される
⑧ テトリミノは一定間隔で下方向に移動する
⑨ テトリミノはランダムに生成される →その 3 参照
⑩ 横 10 ブロック分がそろった場合、そろった列は消えて上の列が下りてくる
⑪ テトリミノ生成場所にブロックがある場合、ゲームが終了する
テトリミノを画面内で回転させる
おそらくテトリス開発における最難関である回転処理を作ります。
まず、r キーを押したときに右回転させる処理を作っていきます。コードを書く前に回転について抑えていきましょう。
今回は Z 型のテトリミノで考えていきます。まず、生成時の Z 型テトリミノは下記の通りになっています。
さてここから右回転をする必要があります。右回転をすればいいのだから下記のような形になればよい と考えるかもしれません。
確かに Z 型が右回転しています。では、この回転方法を例えば I 型や L 型で見てみたらどうでしょうか。
注目してほしいのは青色の部分です。回転処理は当然関数として共通化したいので法則性が必要です。しかし青部分を見てみても特に法則性がなさそうです。・・・課題その 5
ここでブロックやテトリミノに着目するのを辞めてみましょう。代わりにテトリミノを構成している 4×4 の四角に着目してみます。この 4×4 の四角はどんなテトリミノが生成されても不変です。であれば、テトリミノを回転させるよりも四角そのものを回転させてみたほうが良いことが分かります。では実際に四角ごと回転させてみましょう。
青文字部分に着目します。まず回転後の(y-x) =(0-0)に当たる箇所には回転前の(3-0)が入っていることが分かります。(0-1)には(2-0)、(0-2)には(1-0)、(0-3)には(0-0)。さらに下の行の黄色部分にも着目してみましょう(1-0)に(3-1)、(1-1)に(2-1)、(1-2)に(1-1)、(1-3)に(0-1)。
ここまでで見えてきた法則性をまとめます。
①x 軸自体が 1 増えるたび(x 軸方向に 1 つ進むたび)に y 軸の値は 1 減る
②x 軸自体が 1 増えても(x 軸方向に 1 つ進んでも)に x 軸の値は変わらない
③y 軸自体が 1 増えるたび(y 軸方向に 1 つ進むたび)に x 軸の値は 1 増える
つまり下記のロジックが作れそうです。
for (let y = 0; y < 4; y++) {
for (let x = 0; x < 4; x++) {
回転後の4×4の二次元配列[y][x] = 回転前の4×4の二次元配列[4 -1 -x][y]
}
}
for ループの最大値が 4 となっているのは、今回の対象が 4×4 の四角だからです。また、[4 -1 -x]は配列のインデックス番号の最大値が 3 になるため 4 から 1 引いているというわけです。
では実際に実装していきましょう。
コード
・省略・
const createRightRotateTet = () => {
//回転後の新しいテトリミノ用配列
let newTet = [];
for (let y = 0; y < TET_SIZE; y++) {
newTet[y] = [];
for (let x = 0; x < TET_SIZE; x++) {
newTet[y][x] = tetroMino[TET_SIZE - 1 - x][y];
}
}
return newTet;
};
document.onkeydown = (e) => {
switch (e.code) {
case 'ArrowLeft':
if (canMove(-1, 0)) tetroMinoDistanceX--;
break;
case 'ArrowUp':
if (canMove(0, -1)) tetroMinoDistanceY--;
break;
case 'ArrowRight':
if (canMove(1, 0)) tetroMinoDistanceX++;
break;
case 'ArrowDown':
if (canMove(0, 1)) tetroMinoDistanceY++;
break;
case 'KeyR':
tetroMino = createRightRotateTet();
}
drawPlayScreen();
・省略・
};
解説
まず回転前のテトリミノから回転後のテトリミノに描画しなおします。そのために回転後ミノ用の二次元配列を用意します。作り方は前回行った方法と同じになります。二回目の for ループ内で実際に新しい回転後ミノ用の配列に回転前配列の値を格納していきます。そして新しい回転後ミノ用の配列が返却される という流れになっています。
関数の呼び元は onkeydown 側で実装します。戻り値の格納先は、テトリミノのデータが入る tetroMino 配列となります。そして drawPlayScreen で実際に描画行われるという流れになります。
上記で回転のロジックについて細かく説明したつもりですが、理解は簡単ではないと思います。エクセルなどで実際に図に起こしてみるとかなり分かりやすくなると思うのでいろいろ試してみてください。ポイントは回転後の座標に回転前のどの座標が来るのか を抑えることです。挑戦も兼ねて L キーを押したときに左回転する関数も作成してみましょう。回答例は一番最後に載せます。
回転に制限を付ける
さて、回転自体は実装できたもののこのままではブロックをすり抜けたり、画面外に干渉して回転が出来てしまいます。そこで回転にも制限を加えていきます。
コード
・省略・
const canMove = (moveX, moveY, newTet = tetroMino) => {
for (let y = 0; y < TET_SIZE; y++) {
for (let x = 0; x < TET_SIZE; x++) {
if (newTet[y][x]) {
// 現在のテトリミノの位置(tetroMinoDistanceX + x)に移動分を加える(=移動後の座標)
let nextX = tetroMinoDistanceX + x + moveX;
let nextY = tetroMinoDistanceY + y + moveY;
// 移動先にブロックがあるか判定
if (
nextY < 0 ||
nextX < 0 ||
nextY >= PLAY_SCREEN_HEIGHT ||
nextX >= PLAY_SCREEN_WIDTH ||
SCREEN[nextY][nextX]
) {
return false;
}
}
}
}
return true;
};
const createRightRotateTet = () => {
//回転後の新しいテトリミノ用配列
let newTet = [];
for (let y = 0; y < TET_SIZE; y++) {
newTet[y] = [];
for (let x = 0; x < TET_SIZE; x++) {
newTet[y][x] = tetroMino[TET_SIZE - 1 - x][y];
}
}
return newTet;
};
document.onkeydown = (e) => {
switch (e.code) {
case 'ArrowLeft':
if (canMove(-1, 0)) tetroMinoDistanceX--;
break;
case 'ArrowUp':
if (canMove(0, -1)) tetroMinoDistanceY--;
break;
case 'ArrowRight':
if (canMove(1, 0)) tetroMinoDistanceX++;
break;
case 'ArrowDown':
if (canMove(0, 1)) tetroMinoDistanceY++;
break;
case 'KeyR':
let newTet = createRightRotateTet();
if (canMove(0, 0, newTet)) {
tetroMino = newTet;
}
}
drawPlayScreen();
};
・省略・
解説
canMove メソッドに対して機能を追加しています。第三引数として、回転後のテトリミノ情報を受け取るようにしています。ただし移動処理の場合は不要な引数のため、デフォルト引数として tetroMino を受け取るようにしています。これで第三引数が設定されなかった場合はこれまでのテトリミノ情報がそのまま渡される形となります。そして、一度目の if で見る配列は newTet へと変更しています。
回転を実際にする側にも変更を加えています。一度変数 newTet に回転後のテトリミノ情報を格納しています。canMove メソッドを通った場合にのみ tetroMino へ回転後の情報を渡すような流れになっており、false が帰ってきた場合は newTet の情報はごみになります。
まとめ
さて、今回は回転機能を実装しました。恐らく今回のテトリス開発でもっとも躓きやすい箇所だと思います。今回のポイントは下記のとおりです。
- 関数化させるときは具体例をいくつか並べて法則性を見つける
- 関数の引数にはデフォルト引数を設定することができる
次回は自動で落ちる処理を実装していきます。
最後にこれまでのコードを掲載します。なお、気づいた方もいるかもしれませんがI型のテトリミノについて今回の回転機能追加によって挙動がおかしなところがあったため修正しています。また、左回転の機能もあわせて追加しています。
// 1ブロックの大きさ
const BLOCK_SIZE = 30;
// フィールドのサイズ
const PLAY_SCREEN_WIDTH = 10;
const PLAY_SCREEN_HEIGHT = 20;
// キャンバスIDの取得
const CANVAS = document.getElementById('canvas');
// 2dコンテキストの取得
const CANVAS_2D = CANVAS.getContext('2d');
// キャンバスサイズ(=プレイ画面のサイズ)
const CANVAS_WIDTH = BLOCK_SIZE * PLAY_SCREEN_WIDTH;
const CANVAS_HEIGHT = BLOCK_SIZE * PLAY_SCREEN_HEIGHT;
CANVAS.width = CANVAS_WIDTH;
CANVAS.height = CANVAS_HEIGHT;
// テトリミノの1辺の最長
const TET_SIZE = 4;
// 7種類のテトリミノ達
let TETRO_TYPES = [
[
// Z
[0, 0, 0, 0],
[1, 1, 0, 0],
[0, 1, 1, 0],
[0, 0, 0, 0],
],
[
// S
[0, 0, 0, 0],
[0, 0, 1, 1],
[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],
],
[
// J
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 1, 0],
[0, 0, 0, 0],
],
[
// L
[0, 0, 1, 0],
[0, 0, 1, 0],
[0, 1, 1, 0],
[0, 0, 0, 0],
],
[
// T
[0, 0, 0, 0],
[1, 1, 1, 0],
[0, 1, 0, 0],
[0, 0, 0, 0],
],
[
// O
[0, 0, 0, 0],
[0, 1, 1, 0],
[0, 1, 1, 0],
[0, 0, 0, 0],
],
];
// TETRO_TYPESのインデックス番号をランダム取得
let tetroTypesIndex = Math.floor(Math.random() * 7);
// テトロミノを取得する
let tetroMino = TETRO_TYPES[tetroTypesIndex];
// テトリミノの移動距離
let tetroMinoDistanceX = 0;
let tetroMinoDistanceY = 0;
// 画面本体
const SCREEN = [];
// テトリスプレイ画面描画処理
const drawPlayScreen = () => {
// 背景色を黒に指定
CANVAS_2D.fillStyle = '#000';
// キャンバスを塗りつぶす
CANVAS_2D.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// x,y =100, 100の場所に30×30のブロックを描画
//CANVAS_2D.fillRect(100, 100, BLOCK_SIZE, BLOCK_SIZE);
// 画面本体で動かせなくなったテトリミノを描画する
for (let y = 0; y < PLAY_SCREEN_HEIGHT; y++) {
for (let x = 0; x < PLAY_SCREEN_WIDTH; x++) {
if (SCREEN[y][x]) {
drawBlock(x, y);
}
}
}
// テトリミノを描画する
for (let y = 0; y < TET_SIZE; y++) {
for (let x = 0; x < TET_SIZE; x++) {
if (tetroMino[y][x]) {
drawBlock(tetroMinoDistanceX + x, tetroMinoDistanceY + y);
}
}
}
};
const drawBlock = (x, y) => {
let drawX = x * BLOCK_SIZE;
let drawY = y * BLOCK_SIZE;
// 塗りに赤を設定
CANVAS_2D.fillStyle = '#E33';
CANVAS_2D.fillRect(drawX, drawY, BLOCK_SIZE, BLOCK_SIZE);
// 線の色を黒に設定
CANVAS_2D.strokeStyle = 'black';
CANVAS_2D.strokeRect(drawX, drawY, BLOCK_SIZE, BLOCK_SIZE);
};
const canMove = (moveX, moveY, newTet = tetroMino) => {
for (let y = 0; y < TET_SIZE; y++) {
for (let x = 0; x < TET_SIZE; x++) {
if (newTet[y][x]) {
// 現在のテトリミノの位置(tetroMinoDistanceX + x)に移動分を加える(=移動後の座標)
let nextX = tetroMinoDistanceX + x + moveX;
let nextY = tetroMinoDistanceY + y + moveY;
// 移動先にブロックがあるか判定
if (
nextY < 0 ||
nextX < 0 ||
nextY >= PLAY_SCREEN_HEIGHT ||
nextX >= PLAY_SCREEN_WIDTH ||
SCREEN[nextY][nextX]
) {
return false;
}
}
}
}
return true;
};
// 右回転
const createRightRotateTet = () => {
//回転後の新しいテトリミノ用配列
let newTet = [];
for (let y = 0; y < TET_SIZE; y++) {
newTet[y] = [];
for (let x = 0; x < TET_SIZE; x++) {
newTet[y][x] = tetroMino[TET_SIZE - 1 - x][y];
}
}
return newTet;
};
// 左回転
const createLeftRotateTet = () => {
//回転後の新しいテトリミノ用配列
let newTet = [];
for (let y = 0; y < TET_SIZE; y++) {
newTet[y] = [];
for (let x = 0; x < TET_SIZE; x++) {
newTet[y][x] = tetroMino[x][TET_SIZE - 1 - y];
}
}
return newTet;
};
document.onkeydown = (e) => {
switch (e.code) {
case 'ArrowLeft':
if (canMove(-1, 0)) tetroMinoDistanceX--;
break;
case 'ArrowUp':
if (canMove(0, -1)) tetroMinoDistanceY--;
break;
case 'ArrowRight':
if (canMove(1, 0)) tetroMinoDistanceX++;
break;
case 'ArrowDown':
if (canMove(0, 1)) tetroMinoDistanceY++;
break;
case 'KeyR':
let newRTet = createRightRotateTet();
if (canMove(0, 0, newRTet)) {
tetroMino = newRTet;
}
break;
case 'KeyL':
let newLTet = createLeftRotateTet();
if (canMove(0, 0, newLTet)) {
tetroMino = newLTet;
}
break;
}
drawPlayScreen();
};
// 画面を真ん中にする
const CONTAINER = document.getElementById('container');
CONTAINER.style.width = CANVAS_WIDTH + 'px';
// 初期化処理
const init = () => {
for (let y = 0; y < PLAY_SCREEN_HEIGHT; y++) {
SCREEN[y] = [];
for (let x = 0; x < PLAY_SCREEN_WIDTH; x++) {
SCREEN[y][x] = 0;
}
}
// テスト用
SCREEN[4][6] = 1;
drawPlayScreen();
};