JavaScriptコンソールで遊べるテトリスを作成して公開してみた


このような感じで簡単にテトリスを作成してみました。

<img width="1094" alt="Screen Shot 2019-08-14 at 0.01.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/11a4b15e-24e8-97c9-8bd9-b20a74322ede.png">

---

結構前にいいねさせていただいた、こちらの記事をみて、私もコンソールで動くゲームを作ってみようと思い、投稿させていただきました。

- [console.log()でスネークゲームを出力する - Qiita](https://qiita.com/prononami/items/7571ce9bb382981a56bb)

---

コードはこちらです。コードは結構雑です。:bow:

- https://github.com/okumurakengo/console-tetoris

github pagesで公開しましたので、こちらで遊べます。

- https://okumurakengo.github.io/console-tetoris/

---

[npmでも公開](https://www.npmjs.com/package/console-tetoris)しましたので、インストールしてjsでも動かせました。

```bash
yarn add console-tetoris
```

```js
import "console-tetoris"
```

またはhtmlで以下のようにcdnで読み込んでもコンソールに表示されます。

```html
<script src="https://unpkg.com/console-tetoris/dist/main.js"></script>
```

---

今回npmで公開など初めてだったので、こちらのリンクを参考にしました。ありがとうございます。m(_ _)m

- [TypeScriptでnpmライブラリ開発ことはじめ - Qiita](https://qiita.com/saltyshiomix/items/d889ba79978dadba63fd)
- [npmでパッケージを公開してみた手順の記録 - Qiita](https://qiita.com/hoshimado/items/c6f1484297d974f44f19)

# 作成したコード

結構雑ですが、自分が作成したコードをさらそうと思います。よろしくお願いします。

定数や型定義をglobal.tsに全部書きました。自分にとってはテトリスのロジックを考えるのに型定義があるとすごく助かりました。

```src/global.ts
export const NONE_Y = 0;
export const NONE_X = 0;
export const INIT_Y = 1;
export const INIT_X = 5;
export const MAX_X = 11;
export const MAX_Y = 17;

export type TRow = [
    number,
    number,
    number,
    number,
    number,
    number,
    number,
    number,
    number,
    number,
    number,
];

export type TBoard = [
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
    TRow,
];

export interface IBoardState {
    board: TBoard;
    currentPositions: [number, number][][];
}

export enum Colors {
    NONE,
    GREEN,
    BLUE,
    RED,
    YELLOW,
    GREEN_BLIGHT,
    BLUE_BLIGHT,
}

export enum Shapes {
    SQUARE,
    LINE,
    S,
    Z,
    L,
    T,
}

export type TShapeSquare = [[true, true], [true, true]];
export type TShapeLine = [[true], [true], [true], [true]];
export type TShapeS = [[false, true], [true, true], [true, false]];
export type TShapeZ = [[true, true, false], [false, true, true]];
export type TShapeL = [[true, false], [true, false], [true, true]];
export type TShapeT = [[true, true, true], [false, true, false]];

export type TShape = TShapeSquare | TShapeLine | TShapeS | TShapeZ | TShapeL | TShapeT;

export type TSquare = { color: Colors.GREEN; shape: TShapeSquare; };
export type TLine = { color: Colors.BLUE, shape: TShapeLine; };
export type TS = { color: Colors.RED, shape: TShapeS; }
export type TZ = { color: Colors.YELLOW, shape: TShapeZ; }
export type TL = { color: Colors.GREEN_BLIGHT, shape: TShapeL; }
export type TT = { color: Colors.BLUE_BLIGHT, shape: TShapeT; }

export type TPiece = TSquare | TLine | TS | TZ | TL | TT;

export enum Moves {
    DOWN,
    LEFT,
    RIGHT,
}
```

---

webpackで読み込む起点になるファイルです。

`console.clear()` を使って再描画しています。
ロジックは全て `./lib` で書いて、このファイルでは処理の流れだけになるようなイメージでやりました。

```src/index.ts
import {
    initBoard,
    newPiece,
    movePiece,
    checkTouch,
    rotatePiece,
    deleteRow,
    render,
} from "./lib";
import { TBoard, Moves } from "./global";

let board: TBoard = initBoard();
let currentPositions: [number, number][][];

// 新しいピースの作成
console.clear();
({ board, currentPositions } = newPiece(board));
render(board);

const timer = setInterval(() => {
    console.clear();
    try {
        if (checkTouch(board, currentPositions, Moves.DOWN)) {
            // 揃っている行がある場合は削除する
            board = deleteRow(board);
            // 新しいピースの作成
            ({ board, currentPositions } = newPiece(board));
        } else {
            // ピースを1つ下に移動
            ({ board, currentPositions } = movePiece(board, currentPositions, Moves.DOWN));
        }
        render(board);
    } catch(err) {
        clearTimeout(timer);
        console.error(err)
    }
}, 700);

window.addEventListener("keydown", ({ keyCode }) => {
    // 左
    if ((keyCode === 37) && !checkTouch(board, currentPositions, Moves.LEFT)) {
        // ピースを1つ左に移動
        ({ board, currentPositions } = movePiece(board, currentPositions, Moves.LEFT));
    }
    // 右
    if ((keyCode === 39) && !checkTouch(board, currentPositions, Moves.RIGHT)) {
        // ピースを1つ右に移動
        ({ board, currentPositions } = movePiece(board, currentPositions, Moves.RIGHT));
    }
    // a
    if (keyCode === 65) {
        // 左回転
        ({ board, currentPositions } = rotatePiece(board, currentPositions, Moves.LEFT));
    }
    // f
    if (keyCode === 70) {
        // 右回転
        ({ board, currentPositions } = rotatePiece(board, currentPositions, Moves.RIGHT));
    }
});
```

---

最初は `lib.ts` というファイルだけで全てやろうと思ったのですが、つらくなったため分けました。
こうすればimport->即exportみたいなことができるんですね、知らなかった

```src/lib/index.ts
export { initBoard } from "./initBoard";
export { newPiece } from "./newPiece";
export { movePiece } from "./movePiece";
export { checkTouch } from "./checkTouch";
export { rotatePiece } from "./rotatePiece";
export { deleteRow } from "./deleteRow";
export { render } from "./render";
```

---

実際にログに出力する部分です。

```src/lib/render.ts
import { MAX_X, TRow, TBoard, Colors } from "../global";

function getStyles(color: Colors): string {
    switch (color) {
        case Colors.GREEN:
            return "color: green; background: green;";
        case Colors.BLUE:
            return "color: blue; background: blue;";
        case Colors.RED:
            return "color: red; background: red;";
        case Colors.YELLOW:
            return "color: gold; background: gold;";
        case Colors.GREEN_BLIGHT:
            return "color: yellowgreen; background: yellowgreen;";
        case Colors.BLUE_BLIGHT:
            return "color: skyblue; background: skyblue;";
        default:
            return "color: black; background: white;";
    }
}

export function render(board: TBoard): void {
    let styles: string[] = [];

    const draw = [
        `+${"--".repeat(MAX_X)}+`,
        board.map((row: TRow, i: number) => {
            styles.push(...row.map(getStyles), "");
            return `|${"%c .".repeat(MAX_X)}%c|`;
        }).join("\n"),
        `+${"--".repeat(MAX_X)}+`,
    ].join("\n");

    console.log(draw, ...styles)
}
```

%cとかくとstyleを指定することができました。

```js
console.log("Example %cCSS-styled%c %clog!",
    "color: red; font-family: monoscope;",
    "", "color: green; font-size: large; font-weight: bold");
```

<img width="964" alt="Screen Shot 2019-08-13 at 12.30.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/0ced2716-b44f-3064-cbf2-751602309d0d.png">

---

縦横で２次元で配列を作成して、これを全体のボードとして使うことにしました。

```src/lib/initBoard.ts
import { range } from "lodash";
import { MAX_X, MAX_Y, TRow, TBoard, Colors } from "../global";

/**
 * 行を初期化して取得
 */
export function initRow(): TRow {
    return range(MAX_X).fill(Colors.NONE) as TRow;
}

/**
 * テトリスのボードを初期化して取得
 */
export function initBoard(): TBoard {
    const row: TRow = initRow();
    return range(MAX_Y).map(() => [...row]) as TBoard;
}
```

---

新しいピースを作成している部分です。ピースをtrue/falseの配列で表現してみましたが、もっといいやり方がありそうです。

```src/lib/newPiece.ts
import { cloneDeep } from "lodash";
import { NONE_Y, NONE_X, INIT_Y, INIT_X, TBoard, IBoardState, Colors, Shapes, TPiece } from "../global";

/**
 * ランダムにピースを取得
 */
function getRandamPiece(): TPiece {
    switch (Math.floor(Math.random() * 6)) {
        case Shapes.SQUARE:
            return { color: Colors.GREEN, shape: [[true, true], [true, true]] };
        case Shapes.LINE:
            return { color: Colors.BLUE, shape: [[true], [true], [true], [true]] };
        case Shapes.S:
            return { color: Colors.RED, shape: [[false, true], [true, true], [true, false]] };
        case Shapes.Z:
            return { color: Colors.YELLOW, shape: [[true, true, false], [false, true, true]] };
        case Shapes.L:
            return { color: Colors.GREEN_BLIGHT, shape: [[true, false], [true, false], [true, true]] };
        case Shapes.T:
            return { color: Colors.BLUE_BLIGHT, shape: [[true, true, true], [false, true, false]] };
        default:
            throw new Error("Incorrect shape");
    }
}

/**
 * ピースの最初のポジションを取得
 */
function getNewPiecePositions(shape: boolean[][]): [number, number][][] {
    return cloneDeep(shape).map((row: boolean[], i: number) => {
        return row.map((cell: boolean, j: number) => {
            return [cell ? INIT_Y + i : NONE_Y, cell ? INIT_X + j : NONE_X];
        });
    })
}

/**
 * 新しいピースが追加された時の盤面の配列を再作成する
 */
function getNewPieceBoard(
    board: TBoard,
    color: Colors,
    positions: [number, number][][]
): TBoard {
    board = cloneDeep(board);

    cloneDeep(positions).forEach((row: [number, number][]) => {
        row.forEach(([y, x]: [number, number]) => {
            if (y !== NONE_Y || x !== NONE_X) {
                board[y - 1][x - 1] = color;
            }
        })
    })
    return board;
}

/**
 * 新しいピースを作成
 */
export function newPiece(board: TBoard): IBoardState {
    const { color, shape } = getRandamPiece();
    const currentPositions: [number, number][][] = getNewPiecePositions(shape);
    board = getNewPieceBoard(board, color, currentPositions);
    return { board, currentPositions }
}
```

---

ピースを1マス動かす処理です。テトリスなので右左下の3方向に動かせます。

```src/lib/movePiece.ts
import { cloneDeep } from "lodash";
import { NONE_Y, NONE_X, TBoard, IBoardState, Colors, Moves } from "../global";

/**
 * ピースが1マス分動かして、ポジションを取得
 */
function getMovePiecePositions(
    positions: [number, number][][],
    moves: Moves
): [number, number][][] {
    return cloneDeep(positions).map((row: [number, number][]) => {
        return row.map(([y, x]: [number, number]) => {
            if (y !== NONE_Y || x !== NONE_X) {
                switch (moves) {
                    case Moves.DOWN:
                        return [y + 1, x];
                    case Moves.LEFT:
                        return [y, x - 1];
                    case Moves.RIGHT:
                        return [y, x + 1];
                    default:
                        throw new Error("Incorrect moves")
                }
            } else {
                return [NONE_Y, NONE_X];
            }
        })
    })
}

/**
 * ピースが1マス分動かした盤面を取得
 */
function getMovePieceBoard(
    board: TBoard,
    positions: [number, number][][],
    moves: Moves
): TBoard {
    board = cloneDeep(board);

    cloneDeep(positions).reverse().forEach((row: [number, number][]) => {
        if (moves === Moves.RIGHT)
            row = row.reverse();

        row.forEach(([y, x]: [number, number]) => {
            if (y !== NONE_Y || x !== NONE_X) {
                const color = board[y - 1][x - 1];
                board[y - 1][x - 1] = Colors.NONE;
                board[moves === Moves.DOWN ? y : y - 1][moves === Moves.DOWN ? x - 1 : (moves === Moves.LEFT ? x - 2 : x)] = color;
            }
        })
    })
    return board;
}

/**
 * ピースを1マス動かす
 */
export function movePiece(
    board: TBoard,
    currentPositions: [number, number][][],
    moves: Moves
): IBoardState {
    let movedPositions = getMovePiecePositions(currentPositions, moves);
    board = getMovePieceBoard(board, currentPositions, moves)
    return { board, currentPositions: movedPositions }
}
```

---

ピースを動かす時に、壁か他のピースがある場合は何もしないように判定する処理です。

```src/lib/checkTouch.ts
import { isEqual } from "lodash";
import { NONE_Y, NONE_X, MAX_Y, MAX_X, TBoard, Colors, Moves } from "../global";

/**
 * ピースが壁か他のピースと隣接していたらtrue
 */
export function checkTouch(
    board: TBoard,
    currentPositions: [number, number][][],
    moves: Moves
): boolean {
    return currentPositions.some((row: [number, number][]) => {
        return row.some(([y, x]: [number, number]) => {
            if ((y === NONE_Y) || (x === NONE_X)) return false;
            if ((moves === Moves.DOWN) && (y >= MAX_Y)) return true;
            if ((moves === Moves.LEFT) && (x <= 1)) return true;
            if (moves === Moves.RIGHT && x >= MAX_X) return true;

            const isSelfPieceTouch = currentPositions.some((tmpRow: [number, number][]) => {
                return tmpRow.some(([tmpY, tmpX]: [number, number]) => {
                    return isEqual([moves === Moves.DOWN ? y + 1 : y, moves === Moves.DOWN ? x : (moves === Moves.LEFT ? x - 1 : x + 1)], [tmpY, tmpX])
                });
            })
            if (!isSelfPieceTouch && (board[moves === Moves.DOWN ? y : y - 1][moves === Moves.DOWN ? x - 1 : (moves === Moves.LEFT ? x - 2 : x)] !== Colors.NONE)) {
                return true;
            }
        });
    });
}
```

---

ピースを回転させる処理なのですが、ここでも壁か他のピースにぶつかった場合の判定をしてしまっています。

行き当たりばったりで作成した部分が多いので、
ピースを左右に動かすときは、1マス進めた状態のピースの位置、
ピースを回転させた場合は、回転後のピースの位置が
壁などに触れていないかを判定させたいわけなのですが、そこまで考えが及ばず共通処理にせず、別々に書いてしまいました。
またやる機会あればこの辺を考えながらやりたいと思いました。

```src/lib/rotatePiece.ts
import { range, cloneDeep, isEqual } from "lodash";
import { NONE_Y, NONE_X, MAX_Y, MAX_X, TBoard, IBoardState, Colors, Moves } from "../global";

/**
 * ピースを回転し、盤面を取得
 */
function getRotatePieceBoard(
    board: TBoard,
    currentPositions: [number, number][][],
    newPositions: [number, number][][],
): TBoard {
    let cloneBoard = cloneDeep(board);
    const [currentY, currentX] = currentPositions[0].find(([findY, finbX]: [number, number]) => {
        return !isEqual([findY, finbX], [0, 0]);
    });

    currentPositions.forEach((row: [number, number][]) => {
        row.forEach(([oldY, oldX]: [number, number]) => {
            if (oldY !== NONE_Y || oldX !== NONE_X) {
                cloneBoard[oldY - 1][oldX - 1] = Colors.NONE;
            }
        });
    });

    newPositions.forEach((row: [number, number][]) => {
        row.forEach(([newY, newX]: [number, number]) => {
            if (newY !== NONE_Y || newX !== NONE_X) {
                cloneBoard[newY - 1][newX - 1] = board[currentY - 1][currentX - 1];
            }
        });
    });

    return cloneBoard;
}

/**
 * 回転可能であれば回転
 */
export function rotatePiece(
    board: TBoard,
    currentPositions: [number, number][][],
    moves: Moves
): IBoardState {
    const newY = currentPositions[0].length;
    const newX = currentPositions.length;

    let findIndex: number;
    const [currentY, currentX] = currentPositions[0].find((
        [findY, finbX]: [number, number],
        index: number
    ) => {
        findIndex = index;
        return !isEqual([findY, finbX], [0, 0]);
    });

    let newPosition: [number, number][][] = range(newY).map((val, i) => {
        return range(newX).map((val, j) => {
            if (moves === Moves.LEFT) {
                return currentPositions[j].slice(-(i + 1))[0].some(Boolean);
            } else {
                return currentPositions.slice(-(j + 1))[0][i].some(Boolean);
            }
        });
    }).map((row, k) => {
        return row.map((piece, l) => {
            return [piece ? currentY + k : 0, piece ? currentX + l - findIndex : 0];
        });
    });

    const isTouch = newPosition.some((someRow: [number, number][]) => {
        return someRow.some(([someY, someX]: [number, number]) => {
            if (someY > MAX_Y || someX > MAX_X) {
                return true;
            }
            const isSelfPiece = currentPositions.some((tmpRow: [number, number][]) => {
                return tmpRow.some(([tmpY, tmpX]: [number, number]) => isEqual([someY, someX], [tmpY, tmpX]))
            })
            if (!isSelfPiece && board[someY - 1][someX - 1] !== Colors.NONE) {
                return true;
            }
        });
    });

    if (isTouch) {
        // 回転した結果、壁か他のピースにぶつかる場合は回転しない
        return { board, currentPositions };
    } else {
        // 回転した結果を返す
        return {
            board: getRotatePieceBoard(board, currentPositions, newPosition),
            currentPositions: newPosition,
        };
    }
}
```

---

行の削除をする処理です。
ループで毎回全行検証して、揃っていたら消すとしていますが、もっと効率的に消す方法がありそう、思いつかなかったので今回はこれで(^ ^;)

```src/lib/deleteRow.ts
import { cloneDeep, range, remove } from "lodash";
import { MAX_Y, TRow, TBoard } from "../global";
import { initRow } from "./initBoard";

/**
 * 揃った行を削除して、盤面を取得
 */
export const deleteRow = (borad: TBoard) => {
    borad = cloneDeep(borad);
    remove(borad, row => {
        return row.every(Boolean);
    });
    range(MAX_Y - borad.length).map(initRow).forEach((newRow: TRow) => {
        borad.unshift(newRow);
    });
    return borad;
}
```

---

実はテトリスのロジックは前に投稿した[こちら](https://qiita.com/okumurakengo/items/4703887ffff9cabb5827)の焼き直しですが、あの時よりはちょっとはよくかけたかなと思いました。(^ ^;)

---

最後まで読んでいただいてありがとうございました。m(_ _)m