ReactとNext.js、TypeScriptを少しばかり勉強したので、何か作ろうと悩んでいました。そして、選ばれたのはテトリスでした。なお、Next.jsでプロジェクトを作成しましたが、ただのReactです。
:::note warn
ベストプラクティスとは程遠いものです。ご指摘いただけると幸いです。
:::
作ったもの
作成中の気づき・つまづき
setIntervalの中ではstateが更新されない
setInterval
の中で、useState
で作成したgrid
を変更しようと考えましたが、思い通りの挙動になりませんでした。
どうやらsetInterval
が呼び出された時点でgrid
の値が固定されてしまうようです。
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
で取り出したいという気持ちです。
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°)とブロックの種類からパターンを決定して、パターンの先頭から「回してダメならずらしてみよ」を試していくようにしました。
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;
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
などにまとめるとさらに再利用性が増してよさそう…)
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のコード補完やエラーメッセージによりコーディングが非常にスムーズでした。
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
を減らす
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スピン加点