React 19 RC でテトリスを作り、ライブラリとして公開しました
はじめに
この記事では、先日公開したテトリス風のパズルゲーム「TsumiZare」と、React 19 で新しくなった ref を活用した例などを紹介します。
ブラウザゲーム「TsumiZare」
TsumiZare は、テトリス風のパズルゲームです。ブラウザゲームであり、最低限ですがPWAにも対応しているためアプリとしてもインストールできます。この手のパズルゲームが難しい子どもでも楽しめるモードを用意しています。
ゲームプレイは以下のURLにアクセスするだけで可能です。ユーザー登録すら不要です。
開発の背景などについては note へ投稿しましたので、そちらもご覧いただけると嬉しいです
このリポジトリで公開しています
技術スタック
興味あるライブラリなどを天こ盛りにしたような構成です。
利用した React 19 RC の新機能
TsumiZare では、以下の新機能と改善を利用しています。
- Server Components
ref
as a prop- Cleanup functions for refs
<Context>
as a provider- React Compiler (Next.js 15 RC として)
今回は私の観測できる範囲ではあまり関心が集まっていない ref 周りの変更について、その実例と併せて紹介します
ref の実装例
テトリスのアクティブなブロック (テトリミノ) の自然落下を実現するために、ゲームのチック (ゲームの状態を更新するための定期的な処理。もしくはその時間単位) を実装しました。
React 18 までの実現方法の一つは useEffect の利用です。
export const useBlockyGame = (option?: {
upAction?: UpAction;
}) => {
const { board, setBoard, hasCollision } = useContext(BlockyGameContext);
.
.
.
// Game loop
useEffect(() => {
if (board.status !== "playing") return;
const interval = setInterval(runTick, board.config.dropInterval);
return () => clearInterval(interval);
}, [board.status, runTick, board.config.dropInterval]);
}
これでも設計によっては問題にならない場合もありますが、TsumiZare では board
を <Context>
で管理しており、この useEffect が定義されるカスタムフック useBlockyGame
はコンテキスト内でどこでも呼び出せるようにしました。
そうなると、useBlockyGame
を呼び出したそれぞれの場所で useEffect が走ることになり、board
はコンテキスト内で共通しているため、チック処理が重複実行されてしまいます。
この状態を回避するために、「チック処理を実行する責務」を hook からコンポーネントへ委譲するように設計しました。
useEffect を ref Callback へ置き換える
コンポーネントの ref Callback でチック処理とクリーナップを定義しておき、
export const useBlockyGame = (option?: {
upAction?: UpAction;
}) => {
const { board, setBoard, hasCollision } = useContext(BlockyGameContext);
.
.
.
// Game loop
const tickRunnerRef = (_ref: HTMLDivElement) => {
if (board.status !== "playing") return;
const interval = setInterval(runTick, board.config.dropInterval);
return () => clearInterval(interval);
};
return { tickRunnerRef };
}
コンポーネントに ref を渡すだけです。
export const Game = () => {
const {
board,
tickRunnerRef,
.
.
} = useBlockyGame();
return <TickRunner ref={tickRunnerRef} /
export const TickRunner = ({ ref }: { ref: Ref<HTMLDivElement> }) => {
return <div ref={ref} />;
};
このようにすることで、ref を持つコンポーネントがレンダリングされない限りチックは走らなくなります。また、useEffect が定義されたコンポーネントのレンダリングに依存する状態から、特定のコンポーネントの存在に依存するようになり、処理をコントロールしやすくなりました。
ref Callback のクリーナップ追加により、使い心地はそのままに useEffect に頼らくて済む場面もでてきそうです。
(とはいえ useEffect を減らすために無関係な ref をばらまくのは別の意味で複雑になりそうなため、私はあくまで表現方法が増えたくらいに考えています)
リザルト共有機能の紹介
プレイ後にリザルト画面を表示し、その結果をSNSへ投稿したり画像として保存できる機能を提供しています。
以下のように画像として共有できます
この機能は、@vercel/og 内部で使用されている next/satori を使用しています。
Satori: Enlightened library to convert HTML and CSS to SVG.
リザルト画面に表示しているのは React コンポーネントで、ボタン押下時に画像へと変換しています。手順は次の通りです。
- 共有ボタン押下
- next/satori でリザルトコンポーネントをSVGへ変換
- SVG を canvas へ変換
- canvas を PNG Blob に変換
- 後処理
- 共有: navigator.share を呼び出し
- 保存: a タグに埋め込んでクリック処理
現時点では、実装を簡単にするためにクライアントで実行しています。しかし、next/satori の変換時にフォントを読み込む必要があり、アプリで使用している Web フォント (Inter-Black 317 KB) を改めてリクエストしています。また、next/satori が原因かまでは調べきれていませんが、端末やブラウザによって挙動に差があることを確認しています。
そのため、画像描画は素直にサーバー側で実装するべきだったと反省しており、今後改修する予定です。また、SNSへの共有では、リザルト画像をOGPイメージとして表示する方針も考えています。
blocky-game について
ここまで紹介してきたブロックゲームのコア機能である React カスタムフックを npm で公開しています。なんと React 19 RC にのみ対応しています。
セットアップは簡単です。
まず React 19 RC をセットアップします。
その後、依存関係をインストールして、
npm install blocky-game
# or
bun add blocky-game
コンテキストを定義します。
import { BlockyGameProvider } from "blocky-game";
import { BlockyGame } from "@/components/BlockyGame";
export default function HomePage() {
return (
<BlockyGameProvider>
<BlockyGame />
</BlockyGameProvider>
);
}
アプリケーションのコードも同リポジトリで公開しているため、触られる際にはコンポーネントなどの参考にしてください。
終わりに
TsumiZare はまだ公開したてのアプリです。今後もより良いゲームを目指して改良を続けていきたいと考えています。
SNSやリポジトリを通してフィードバックいただけたら嬉しいです。ご意見、ご感想をお待ちしております。
Discussion