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 を回避するために TypeScript
の Union
type をつかうようにしました。
export type Square = "O" | "X" | undefined;
export type Squares = Square[];
styled-components で components をつくる
今回つくる App には css in js
の styled-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 を Grid
で n
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
とどちらが勝ったかの winner
は history
で 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);
});
}