React初心者がテトリスを作った話

ReactとNext.js、TypeScriptを少しばかり勉強したので、何か作ろうと悩んでいました。そして、選ばれたのはテトリスでした。なお、Next.jsでプロジェクトを作成しましたが、ただのReactです。
※ベストプラクティスとは程遠いものです。ご指摘いただけると幸いです

## 作ったもの
https://next-tetris-six.vercel.app/

https://github.com/m-tange-ppp/next-tetris

# 作成中の気づき・つまづき

## setIntervalの中ではstateが更新されない
`setInterval`の中で、`useState`で作成した`grid`を変更しようと考えましたが、思い通りの挙動になりませんでした。
どうやら`setInterval`が呼び出された時点で`grid`の値が固定されてしまうようです。
```sample.tsx
const [count, setCount] = useState<number>(0);
/// countは0なので、count + 1は常に1となる。
const interval: NodeJS.Timeout = setInterval(() => {setCount(count + 1)}, 500} ;
```
そこで、これを逆手にとることにしました。ブロックの移動時は固定された`grid`に対して`setGrid`で更新して、ブロックの配置・消去時のみ`useRef`で作成した`gridRef.current`の値を更新し、`setGrid`を呼び出しました。`gridRef.current`に永続的な状態を保存して`setGrid`で取り出したいという気持ちです。
``` GameBoard.tsx
const [grid, setGrid] = useState<Grid>(() => createGrid());
const gridRef = useRef<Grid>(grid);

const renderTetromino = (): void => {
    const tetromino: Tetromino = activeTetromino.current;
    const width: number = tetromino.shape[0].length;
    const height: number = tetromino.shape.length;
    const newGrid: Grid = gridRef.current.map(row => row.map(cell => ({...cell})));

    for (let i = 0; i < height; i++) {
        for (let j = 0; j < width; j++) {
            if (tetromino.shape[i][j]) {
                // 現在のテトロミノを設定する。
                newGrid[positionRef.current.y + i][positionRef.current.x + j] = {filled: 1, type: tetromino.type};
            }
        }
    }
    // gridを更新して、再レンダリングする。
    setGrid(newGrid);
};

const placeTetromino = (): void => {
    const tetromino: Tetromino = activeTetromino.current;
    const width: number = tetromino.shape[0].length;
    const height: number = tetromino.shape.length;
    const newGrid: Grid = gridRef.current.map(row => row.map(cell => ({...cell})));

    for (let i = 0; i < height; i++) {
        for (let j = 0; j < width; j++) {
            if (tetromino.shape[i][j]) {
                newGrid[positionRef.current.y + i][positionRef.current.x + j] = {filled: 1, type: tetromino.type};
            }
        }
    }
    // gridRefを更新して置いた状態を保存する。
    gridRef.current = newGrid;
    setGrid(newGrid);
};

useEffect(() => {
    renderTetromino();
    const intervalDuration = hasLanded ? DEFAULT_INTERVAL : Math.max(50, DEFAULT_INTERVAL - (level - 1) * 100);
    // setInterval内ではgridは更新されない。
    // gridRefを更新することで盤面を保存する。
    const interval: NodeJS.Timeout = setInterval(() => {
        if (isLockedRef.current) {
            handleEndOfTurn();
        } else {
            moveTetromino("down");
        }
        renderTetromino();
    }, intervalDuration);

    return () => clearInterval(interval);
}, [level, hasLanded]);
```

## SRSの実装
ブロックを中心を軸として回転させるとき、回転後のブロックが壁と重なる場合、回転できないように思われます。しかし実際には、例えば I ブロックが壁と接していても回転できるように、直感的に回転できます。これを可能にしているのが、SRS（Super Rotation System）だそうです。「回してダメならずらしてみよ」という気持ちです。
現在の回転状態（0°・90°・180°・270°）とブロックの種類からパターンを決定して、パターンの先頭から「回してダメならずらしてみよ」を試していくようにしました。
```constants.ts
export const WALLKICKDATA: {[key: string]: number[][]} = {
    // 0123は順に反時計回りで0度から順に回転した状態。
    // L3は現在状態3で左回転したいということ。
    "L0": [
        [0, 0], [1, 0], [1, 1], [0, -2], [1, -2]
    ], 
    "L1": [
        [0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]
    ], 
    "L2": [
        [0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]
    ], 
    "L3": [
        [0, 0], [1, 0], [1, -1], [0, 2], [1, 2]
    ], 
    // R3は現在状態3で右回転したいということ。
    "R0": [
        [0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]
    ], 
    // 以下略
    
    // Iミノだけ別の法則でずらしていく
    // IL3は現在状態3で左回転したいということ。
    "IL0": [
    [0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]
    ], 
    // 以下略
} as const;
```

``` GameBoard.tsx
const calculateSRSPosition = (tetromino: Tetromino, position: Position, direction: string): Position|undefined => {
    let wallKickRule: number[][] = [];
    let initial: string = "L";
    if (direction === "right") {
        initial = "R";
    }
    if (tetromino.type === "I") {
        wallKickRule = WALLKICKDATA[`I${initial}${rotationAngleRef.current}`];
    } else {
        wallKickRule = WALLKICKDATA[`${initial}${rotationAngleRef.current}`];
    }
    // wallKickTypeを先頭から試して、可能であれば回転する。
    for (let i = 0; i < wallKickRule.length; i++) {
        const div = wallKickRule[i];
        const newPosition = {x: position.x + div[0], y: position.y + div[1]};
        if (canMove(tetromino, newPosition)) {
            return newPosition;
        }
    }
    return undefined;
};
```
## コンポーネントの再利用
次のブロック、そのまた次のブロックについては同じコンポーネントを再利用しています。渡すindexのみ変えることでこれを実現できるのはReactの利点と感じました。（`renderTetromino()`など、様々なコンポーネントで使いまわす関数を`function.ts`などにまとめるとさらに再利用性が増してよさそう…）
```ShowNextTetromino.tsx
interface ShowNextTetrominoProps {
    typesArray: string[]|null;
    typesArrayIndex: number;
};

const ShowNextTetromino: React.FC<ShowNextTetrominoProps> = ({ typesArray, typesArrayIndex: IndexOfTypesArray }) => {
    const createGrid = (): Grid => {
        let width: number = 0;
        if (typesArray != null) {
            width = TETROMINOES[typesArray[typesArray.length - IndexOfTypesArray]].shape.length;
        }
        return Array(width).fill(null).map(() => Array(width).fill({ filled: 0 }));
    };

    const [grid, setGrid] = useState<Grid>(() => createGrid());


    const renderTetromino = (): void => {
        const newGrid: Grid = createGrid();
        const width: number = newGrid.length;
        if (typesArray != null) {
            const tetromino: Tetromino = TETROMINOES[typesArray[typesArray.length - IndexOfTypesArray]];

            for (let i = 0; i < width; i++) {
                for (let j = 0; j < width; j++) {
                    if (tetromino.shape[i][j]) {
                        newGrid[i][j] = {filled: 1, type: tetromino.type};
                    }
                }
            }
        }
        setGrid(newGrid);
    };

    useEffect(() => {
        renderTetromino();
    }, [typesArray]);
```

# TypeScriptの型定義のありがたみ
正直なところ、JavaScriptに型定義を追加するのは冗長では？と少しばかり思っていました。しかし実際に使ってみると、Vscodeのコード補完やエラーメッセージによりコーディングが非常にスムーズでした。
```types.ts
export interface Cell {
    filled: 0|1;
    type?: string;
    }

export type Grid = Cell[][];

export type Shape = number[][];

export type Tetromino = {type: string, shape: Shape};

export type Tetrominoes = {[key: string]: Tetromino};

export type Position = {x: number, y: number};
```
「コードを短くするよりコードを理解するまでにかかる時間を短くせよ」という言葉がありますが、コードを書く時間についても同様のことが言えると感じました。
## のびしろ
要改善点と追加してみたい機能です。
- クールなUI
- 多すぎる`useRef`を減らす
```GameBoard.tsx
const typesRef = useRef<string[]>(null!);
if (typesRef.current === null) {
    typesRef.current = addTypesArray([]);
}
const activeTetromino = useRef<Tetromino>(null!);
if (activeTetromino.current === null) {
    activeTetromino.current = initializeNextTetromino();
}
const positionRef = useRef<Position>(null!);
if (positionRef.current === null) {
    positionRef.current = initializePosition();
}

const gridRef = useRef<Grid>(grid);
const isLockedRef = useRef<boolean>(false);
const rotationAngleRef = useRef<number>(0);
const countComboRef = useRef<number>(0);
const hasHeldRef = useRef<boolean>(false);
const countFullRowsRef = useRef<number>(0);

```
- モバイル対応
- スコアランキング
- Tスピン加点


# 最後まで閲覧ありがとうございました！