0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-11-23

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

作ったもの

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

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スピン加点

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

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?