Next.jsのApp Routerを使ってマインスイーパーを作った
できたのがコレ
JavaScriptをオフにしていてもちゃんと動きます。
背景
Next.jsのApp Routerをあんまり使ってこなかったので練習がてら何か作ろうと思い、マインスイーパーを作ることにした。
過去に素のReactでマインスイーパーを作った実績があるので、ロジックやコンポーネントを結構使い回して気軽に作れるんじゃないかと思った。
レギュレーション
開発するにあたり、以下のルールで作ることにした。
- Next.jsのApp Routerを使ってマインスイーパーを作成する
- "use client"は利用禁止
- 旗を立てる機能は実装しなくてもよい
- クリアタイム計測機能は実装しなくてもよい
クライアント側で動くコンポーネントは一切禁止。つまりonClickイベントやuseStateは利用できない。
過去に作ったReact版では旗を立てる機能があったが、これは右クリックが必須なので今回は実装しないで良いということにした。あとクリアタイムもあきらめた。
もともとフロントエンドだけで完結していたものをバックエンドに寄せることにメリットはないが、今回はどうせ遊びで作るものなので全力で地雷を踏みに行く。
実装方針
状態はURLに持たせる
Pure React版マインスイーパーはuseStateをフル活用して状態管理していたが、use client禁止というルールがあったので今回は状態をURLに持たせる必要があった。
こんな感じで管理することにした。
http://<BASE_URL>/[seed]/[width]/[bomb]/[open]
パラメータ | 意味 |
---|---|
seed | 初期マップを決めるシード値 |
width | 横x縦が何マスのマップか |
bomb | 爆弾の数 |
open | 開いたマス |
トップページにアクセスされた場合はランダムなシード値を生成して初期画面にリダイレクトする。
その後状態が更新されるたびに新しいURLに遷移する。
例えばシード値が2029880524で10x10のマップで爆弾が10個あり、一番左上のマスを開いた状態だとこんなURLになる。
https://next-minesweeper-omega.vercel.app/2029880524/10/10/1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000がマス目の状態。100マスあるから数字が100個並んでいて、一番左上のビットだけ1が立っている。
ただしマインスイーパーは1マス開いただけで自動で隣接するマスが開く仕様のため、実際はプレイヤーが開いたマス以外のマスも開かれて表示される。
ユーザー操作はボタンのsubmitのみ
Pure React版マインスイーパーはonClickイベントを拾ってマスを開いていたが、今回はそれが使えない。なのでマスの数だけsubmitボタンを配置し、formからPOSTすることにした。
900個のsubmitボタンが並んだform。眩暈がしそう。
状態更新はServer Actionsを使う
formから「seed」「width」「bomb」「open」そして「どのボタンを押したか」を送る。
どのボタンを押したかはbuttonのvalueに持たせる。
こういったボタンがあった時、
<button
type="submit"
name="hit"
value={props.index.toString()}
...
>
...
</button>
Server Actionsにこう書くとどのボタンが押されたのかindexが分かる。
const hit = formData.get("hit");
状態更新のServer Actionsはこんな感じになる。
リセット処理など一部省略しているが、マスを開く処理は基本的にたったこれだけ。
クリックしたところのビットに1を立ててリダイレクトしているだけ。とてもシンプル。
export async function actionFunction(formData: FormData) {
const seed = formData.get("seed");
const width = formData.get("width");
const bomb = formData.get("bomb");
const open = formData.get("open");
const hit = formData.get("hit");
~中略~
if (open && hit) {
const opened = open.toString().split("");
opened[parseInt(hit.toString())] = "1";
revalidatePath(`/${seed}/${width}/${bomb}/${opened.join("")}`);
redirect(`/${seed}/${width}/${bomb}/${opened.join("")}`);
}
}
現在のゲーム状態は毎回計算しなおす
URLで分かる情報は以下の通り。
- シード値
- マップの広さ
- 爆弾の数
- 開いてるマス目のビット値
これだけあれば現在のゲーム状態は計算で求めることができる。
幸いなことにPure React版マインスイーパーの処理をほとんどそのまま使い回すことができた。
Pure React版マインスイーパーにはこんなクラスがあった。
class Field {
// 現在のセルの配列を返す
get Cells(): Readonly<Cell[]>
// 指定したindexのマスを開いたFieldを返す
Open(index: number): Field
// ゲームをクリアしたかどうかを返す
IsComplete(): boolean
// ゲームオーバーしたかどうかを返す
IsGameOver(): boolean
// 指定された設定値をもとにランダムなマップを生成する
GetRandomField(size: number, bomb: number, random = new Random()): Field
}
それぞれセルは開いているかどうか、爆弾があるかどうか、周囲に爆弾が何個あるかの情報を持っている。
export type Cell = {
Open: boolean
Bomb: boolean
Count: number
}
すでにあるFieldクラスに以下のメソッドを足した。
// 配列に格納されたindexのマスを全部開ける
public OpenArray(indexes: number[]): Field
これを使うと、たったの2行でURLから現在の状態を復元できる。
const initField = Field.GetRandomField(parseInt(params.width), parseInt(params.bomb), new Random(parseInt(params.seed)));
const openedField = initField.OpenArray(params.open.split("").map(Number));
あとRandomクラスも以前作った自前のクラスで、同じシード値を与えれば決まった順番で同じ疑似乱数を得られる便利ツール。
まとめ
出来上がったのがこちら。
Pure React版マインスイーパーのロジックを使い回せたので2日間(実作業3時間くらい)で作ることができた。
作りが全然違う割にすんなりいったのは、ロジックを極力コンポーネントと分離していたお陰かもしれない。
しかしClient Componentだけの構成とServer Componentだけの構成はずいぶん違うなぁ。ユーザー操作の受け取り方からデータの持ち方まで違う。マインドセットの切り替えを求められる。クライアント側に状態を持たず、「ページの状態はすべてURLから演繹される」という発想はそれはそれで分かりやすい。
作ってる途中で「あれ?これは いにしえのCGIなのでは…」と思ってしまった。ガラケー時代のソーシャルゲームにありそう。App Routerの強みはサーバーコンポーネントとクライアントコンポーネントの適材適所な使い分けや柔軟なキャッシュ管理であり、全部サーバー側に寄せたらCGIのような世界になるのは、それはそう。fetchを使う場面がなかったのも大きい。
とはいえ世の中のWebサービスの大半は「文字入力」と「ボタンクリック」の組み合わせだけで回ってるので、過去のライブラリのしがらみがなくなりパフォーマンスさえ許容できれば、もうクライアントコンポーネントの出番はほぼほぼ無いのかもしれない。
Discussion