Skip to main content

React tutorial

onboarding で React tutorial をやってみたときの memo です。 今回は、 tic tac toe の盤の大きさを自由に選択できるようにしてみました。 codesandbox であそぶことができます。


これから作るもの

今回は、 interactive な五目並べゲーム (tic-tac-toe) をつくります。 tutorial とちがい、五目並べなので、 calculateWinner を最後に実装しています。 range function については range function in JavaScript | TSEI.JP をご覧ください。

import * as React from "react";
import styled from "styled-components";
import { createRoot } from "react-dom/client";
import { Leva, useControls } from "leva";
import { calculateWinner, range } from "./utils";
import type { Square, Squares } from "./utils";

function App() {
/* ☆ */
return (
<>{/* ❤ */}</>
)
}

createRoot(document.getElementById("root")).render(<App />);

range
function range (n=0) {
const ret = new Array(n)
for (;n--;) ret[n] = n
return ret
}

Union type について

index.tsx で import している Square, Squares についてですが、 bug を回避するために TypeScriptUnion type をつかうようにしました。

export type Square = "O" | "X" | undefined;

export type Squares = Square[];

styled-components で components をつくる

今回つくる App には css in jsstyled-components をつかっています。 まず、Game state を表示する Header と、Game全体の Main をつくります。

App.Header = styled.h1`
font-size: 2.5rem;
writing-mode: vertical-rl;
`;

App.Main = styled.main`
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
position: absolute;
align-items: center;
justify-content: center;
background: #212121;
color: #ffffff;
`;

styled-componentsにdataを Props 経由で渡す

Props から渡された size n x n の Board をつくります。 作成した Button を Gridn x n にならべます。

App.Button = styled.button<{ n: number }>`
overflow: hidden;
text-align: center;
width: ${({ n }) => `calc(min(60vw, 60vh) / ${n})`};
height: ${({ n }) => `calc(min(60vw, 60vh) / ${n})`};
font-size: ${({ n }) => `calc(min(30vw, 30vh) / ${n})`};
`;

App.Grid = styled.div<{ n: number }>`
display: grid;
grid-template-columns: ${({ n }) => range(n).fill("1fr").join(" ")};
`;

最初の を次のように追加すると、全体の Outline が作成できます。 n の値を変更するために、 leva という library をつかっています。 ここまでについて codesandbox から確認できます。

const { n } = useControls({ n: { value: 16, min: 1, max: 20, step: 1 } });
return (
<App.Main>
<Leva flat />
<App.Header>
Next player: O
</App.Header>
<App.Grid n={n}>
{range(n ** 2).map((i) => (
<App.Button n={n} key={i} />
))}
</App.Grid>
<ul>
<App.Button children="⏮" n={Math.max(6, n)} />
<App.Button children="⏪" n={Math.max(6, n)} />
<li>
<App.Button n={16}>O</App.Button>
1 via set 1, 1
</li>
</ul>
</App.Main>
)

State のリフトアップ

盤面の squares とどちらが勝ったかの winnerhistory で update されるので、 useRef をつかうと rerender を防いでperformance tuning できます。

const winner = useRef<Square>(void 0);
const squares = useRef<Squares>(range(n ** 2).fill(void 0));
const [history, set] = useState<number[]>([]);

また、nが update されるたびに initialize する必要があるので、 につぎのように追加します。

useEffect(() => {
winner.current = void 0;
squares.current = range(n ** 2).fill(void 0);
set([]);
}, [n]);

インタラクティブなコンポーネントを作る

button が click されたときに実行する function を n x n つくります。 すでに click されていたり、勝敗が決まっているときは実行されないようにします。

const handlesSquares = useMemo(() => {
return range(n ** 2).map((i) => () => {
set((_history) => {
if (squares.current[i] || winner.current) return _history;
squares.current[i] = _history.length % 2 ? "X" : "O";
winner.current = calculateWinner(squares.current, n);
return [..._history, i];
});
});
}, [n]);

history の button が clickされたときに実行される n x n の functionをつくります。

const handlesHistory = useMemo(() => {
return range(n ** 2).map((i) => () => {
set((_history) => {
_history.slice(i).forEach((j) => void (squares.current[j] = void 0));
winner.current = calculateWinner(squares.current, n);
return _history.slice(0, i);
});
});
}, [n]);

ゲームを完成させる

これらを 最初の Outline に merge させると、Game が完成します。 まず、 Game の state を表示させる Header をつぎのようにします。

return (
<App.Main>
<App.Header>
{winner.current
? "Winner: " + winner.current
: "Next player: " + (history.length % 2 ? "X" : "O")}
</App.Header>
{/* ... */}
</App.Main>
);

つぎに、Game Grid をつぎのように click できるようにします。

return (
<App.Main>
{/* ... */}
<App.Grid n={n}>
{range(n ** 2).map((i) => (
<App.Button n={n} key={i}
onClick={handlesSquares[i]}
children={squares.current[i]}
/>
))}
</App.Grid>
{/* ... */}
</App.Main>
);

最後に、Game の history の表示を次のようにすると、Gameが完成します。

return (
<App.Main>
{/* ... */}
<ul>
<App.Button n={16} children="" onClick={handlesHistory[0]} />
<App.Button n={16} children="" onClick={handlesHistory[history.length - 1]} />
{history.map((key, i) => (
<li key={key}>
<App.Button n={16} onClick={handlesHistory[i + 1]}>
{i % 2 ? "X" : "O"}
</App.Button>
{i + 1} via set {(key % n) + 1}, {~~(key / n) + 1}
</li>
))}
</ul>
</App.Main>
);

calculateWinner について

まだ bug がのこっていますが、 n が任意の大きさで勝利判定します。

export function calculateWinner(squares: Squares, n = 5): Square {
const len = Math.sqrt(squares.length) << 0;
const calc = (_n = 0, i = 0, di = 0): Square => {
if (_n <= 1) return squares[i];
if (squares[i] !== squares[i + di]) return void 0;
return calc(_n - 1, i + di, di);
};
return squares.find((square, i) => {
return !square
? void 0
: calc(n, i, 1) ||
calc(n, i, len) ||
calc(n, i, len + 1) ||
calc(n, i, len - 1);
});
}