ボードゲームのWebアプリをReactに移行した記録
機能と移行状況
ざっくりとした機能と移行状況。
対局機能
- ユーザーがコマを選択するとコマを手に持つことができる(マウスカーソルを追いかける)
- 手に持ったコマを配置することができる。
- 配置場所が有効な場所だった場合、CPUのターンに移る
- CPUのターンになったらCPUは置く場所を考える。
- CPUがコマを動かしたら画面に反映させる。
- 決着がつくまで交互にコマを動かす。
- 決着がついたらゲーム終了画面に移る。
ツイート機能
- ゲーム終了画面で棋譜をツイートするボタンを押したら、棋譜をエンコードしてTwitterの投稿画面に飛ばす。
棋譜機能
- ゲーム終了画面から棋譜を再生ボタンを押したら、棋譜の再生画面に飛ぶ。
- URLのパラメータをもとに棋譜をデコードする。
- ユーザーが何も操作をしていない場合、数秒間隔で棋譜を最初から自動再生する。
- ユーザーが「<<」「<」「>」「>>」のいずれかのボタンを押したら自動再生をやめて、棋譜を戻したり進めたりする。
デモ機能
- ユーザーがページを開いたら、ユーザーがクリックするまではCPU同士の対局を見せる。
移行前の構成
- 言語はTypescript。
- UIライブラリは使ってない。
- テストはJest
- ビルドツールはwebpack。
- 多言語対応のためにhtml-webpack-plugin/copy-webpack-pluginで1つのindex.htmlから8つの言語違いページを作っている。
- ビルド時刻を埋め込んでいる。(数年間更新がないサイトとして扱われるとGoogle検索で不利なので)
- ボードゲームの盤面はcanvasで描画している。利用している画像はザラついたテクスチャ1枚だけで、基本的には全部canvasで作っている。画像のロードに失敗してもゲームは問題なくプレイできる。
ソースの構造
ソースコードの呼び出しツリーはだいたいこんな感じの構造になっている。
template.html
└logic/
└boardgame.ts
┌────┘├rule.ts
│ ├uiController.ts
│ └ai.ts
│ └eval.ts
├canvas/
│ └view.ts
│ ├utils.ts
│ └params.ts
└states/
├gamestate.ts
└viewState.ts
boardgame.ts
偉大なるメイン関数。
他に置くところがない処理が集まり肥大化してしまったクラス。
rule.ts
ボードゲームのルール制御を行うクラス。
割りとどこからでも呼ばれる。
ai.ts
CPUが次に打つ手を考えるクラス。
view.ts
ボードゲームの盤面の描画を一手に引き受けるクラス。
基本的に冪等性があり、同じパラメータを渡せば同じ結果が表示される。
gamestate.ts
ゲームの状態を保持するクラス。
viewState.ts
盤面以外のUIの状態を保持するクラス。
移行方針
Reactは(いつか)捨てる
今はReactを使うが、そのうち使うのをやめる。
Javascriptのフレームワークの寿命は3年から5年で、それ以上立つと廃れるか、名前が同じだけの別物になる。ReactはHook以前と以後でだいぶ違うし、Vueも2と3で大きな変更があった。もう最初から賞味期限のある生モノとして扱うしかない。だから出来るだけ捨てやすいように実装を行う。
React Hooksを使う
将来ReactをWeb Componentベースの何かに置き換える時、データ管理がネックになる可能性がある。それは避けたいので、Componentの内側の状態管理はHooksでなんとかしつつ、Componentどうしは素朴にプロパティのバケツリレーを行う。ReduxもRecoilも使わない。
Viteを使う
個人的には「良いツールを追い求めて頻繁に乗り換える」より「多少不便でも長く使えるツールを使う」という方針を取りたいところだが、ビルド時間はQOLに直結するので、こればっかりは速さこそ正義。
盤面はReact+SVG+canvasで描画する
canvasはReactと相性があまり良くないので、以下のような方針を取る。
canvasで画像を作成
↓
画像をSVGに埋め込む
↓
SVGをReactで制御する
移行後の構成
- 言語はTypescript。
- React+React Hooks。
- ビルドツールはVite。
- テストはVitest。
- 盤面描画はReact+SVG+canvas。
- 多言語化はreact-i18next
呼び出しツリーはだいたいこんな感じ。
index.tsx
└components/
└Colamone.tsx
┌────┘ ├Panel.tsx,Header.tsx,Footer.tsx
│ └Board.tsx
│ └Piece.tsx,Message.tsx,Hover.tsx,Cover.tsx,Background.tsx
└reducer/
└GameStateManager.ts
└model/
└GameState.tsx,Mode.tsx,Piece.tsx
└static/
└Ai.ts,Cookie.ts,DrawUtil.tsx,Eval.ts,Params.tsx,Rule.ts,Util.ts
index.tsx
ここにはほとんど何もない。
Colamone.tsx
Componentの親玉。イベントは全部ここで受ける。
Stateも全部ここで管理する。でもビジネスロジックは置かない。
Compornentにビジネスロジックは原則一切置かない。UI生成に徹する。
ビジネスロジックの世界と唯一コミュニケーションできる橋渡し役。
Board.tsx
盤面を表すSVG要素。親コンポーネントからプロパティを受け取り、描画に徹する。
GameStateManager.ts
useReducerで使う。Colamone.tsxからのみ呼び出される。
このクラス自体は特に何もせず、GameState.tsxのラッパーに徹する。
ビジネスロジックの世界とコンポーネントの世界の橋渡し役。
GameState.tsx
GameStateManager.tsから利用される。このアプリケーションの全状態がこのクラスに集約されている。
プロパティは原則外部からいじらせない。このクラスに生えてるメソッドで操作する。
static/
純粋関数置き場。状態を一切持たず、ライブラリを意識したコードも書かない。
Reactを捨てることになったらここから骨を拾う。
悩みどころ
状態管理
Reactの状態管理のベストプラクティスがさっぱり分からない。
保存すべき状態が10個あったら、10個のuseStateを用意すべきか、それとも10個のプロパティをもつオブジェクトを管理するuseStateを1つ用意すべきか。
今回は後者、巨大な1つのオブジェクトでアプリケーションのすべての状態を管理している。なんか悪いことをしているような気がしてならない。
しかし前者のようにuseStateを大量に定義したのでは、useStateは非同期で状態を更新するので更新した値を参照して別の値を更新するのが非常に面倒でBuggyになる。だから涙を飲んで巨大なオブジェクトを作ることになった。
巨大なオブジェクトを作るといろいろ罠が多い。オブジェクトを使い回してプロパティだけ書き換えると値が全然書き換わらない。そもそもuseStateから受け取るオブジェクトは読み取り専用になってたりする。オブジェクトの使い回しにより、StrictModeでuseReducerが2回呼ばれてオブジェクトが2回編集されるなどのトラブルにも悩まされた。純粋関数って難しい。
非同期処理・タイマー処理
元のソースではsetTimeoutやsetIntervalを多用していた。これがuseStateと相性が悪く苦労した。クロージャが初期状態を記憶していしまい、useStateで最新の状態を取れなくなってしまうのだ。
setIntervalについてはuseTimerで置き換え、setTimeoutは諦めた。
再描画、再描画、再描画
canvasで動的に画像を作るという割りと重い描画を行っているので、再描画のコストが馬鹿にならない。素直に実装すると親コンポーネントの描画で子供が全部再描画されてしまう。「マウスカーソルの座標」のような更新が激しいプロパティをトップのコンポーネントに持つと大惨事になる。カーソル座標のような状態は子コンポーネントに押し込むことでそれは回避したが、それ以外についてはuseMemoやuseEffectで実行回数を絞ることで何とか減らした。しかしuseEffectの使い方としてはあんまり良くない気もする。recoilが登場した気持ちが分かった気がした。
assetの動的な読み込み
普通にImageタグを使いぶんにはReactは快適だが、Javascriptで動的に画像を読み込むのは結構つらかった。再描画するたびにloadイベントが発行されるのは避けなければならない。useRefでImageオブジェクトを保持し、読み込みはuseEffectで1回のみに限定することで再ロードを回避したが、やればやるほどReactのベストプラクティスから離れていく気がする。
更新
canvasへの描画ロジックをコンポーネントから排除して外部化した。
Web Components移行に一歩近づいた。