Rustで少しずつリバーシを作ってみた
はじめに
Rustの学習目的でリバーシを作ってみたいと思います。最初からすべての機能を作るのではなく、少しずつ機能を追加しながら解説していきます。また、できるだけよいコードを目指すために機能追加の度にリファクタリングをします。
最初の開発
仕様策定
まずはリバーシとして最低限遊べるうえで最も工数がかからなさそうな仕様を策定します。
- cuiアプリ
- 矢印キーでカーソル移動
- Wキーで白石を置き、Bキーで黒石を置き、Backspaseキーで石を取り除く
- Escキーでアプリ終了
とりあえずこれだけあればリバーシとして遊ぶことはできます。cuiアプリなので実行はWindowsTerminalを想定します。
実装
ソース
とりあえずコードの良し悪しは置いといて動くものを作ります
実行結果
解説
cuiアプリとして実装するのでターミナルライブラリを導入します。今回はcrosstermを利用します。Cargo.tomlのdependenciesに下記を追加します
crosstermでcuiアプリを作るときまず下記3点を行います。
- 端末をRAWモードにする
- カーソルを非表示にする
- AlternateScreenに切り替える
今回はstderrに出力するようにしてます。stdoutでも問題ありません。また、アプリを終了するときにそれぞれ元に戻すのを忘れずに行います。
アプリの全体像としては、内部データとしてそれぞれのマス目(8×8)に対応した黒か白か空かの3パターンの状態とカーソルがどこにあるかの座標があり、ユーザの操作によって内部データが変化し、変化した直後に全てのマスを再描画するようにします。
マスの黒か白か空かの3パターンの状態はenumで定義しています
内部データは変化するのでmutで定義しています。盤面の状態はマスの二次元配列とし、カーソルは何行の何列目かをタプルとして保持します。
描画の方法としては、まずMoveToでカーソルを左上にしてそのあとはPrintでカーソルの位置とマスの状態を確認しながら一文字づつ出力しています。黒石・白石の表現はまるでこの時のために準備されていたかのような絵文字⚫⚪が存在するのでこれを使います。カーソルの位置はBackgroudColorを切り替えることで表現します。
ユーザの入力を取得するにはcrossterm::event::read()
を使います。対応したKeyEventが発生したときに内部状態を更新します。
描画と入力取得をloopさせることで入力がビューに反映されるようになります。
最初のリファクタリング
現状はmain関数しかないのでテストが書けません。テストが書けるようにリファクタリングしていきます。今ある機能の中でテストで確認するべきことは
- 各入力によって正しく内部状態が変更されているか
- 内部状態を正しく描画できているか
なのでそれぞれの機能に該当するコードを関数化しテストを書きます
解説
mainにあった入力を受けとって内部状態を変更する処理と内部状態を描画する処理をそれぞれinput,viewという関数にします。
まず、input関数です。入力を受けとって内部状態を変更するのですが、実際に入力を受け取るのはcrossterm::event::readを使っています。crossterm::event::readはcrossterm::event::EventのResultを返すのでinput関数ではcrossterm::event::Eventを入力にとりそれぞれのイベントに対する処理を書きます。内部状態を表現しているfieldとcursorを変更するのでそれぞれを可変参照として引数に取ります。また、input関数をmain関数から切り離したことでescを押したときのbreakによるループ脱出ができなくなったので、内部状態としてアプリを継続するか終了するかを表現するendという変数を定義します。こちらもfield,cursorと同様に可変参照として引数を取ります。
そしてinput関数に対するテストですが、内部状態のfield,cursor,endを定義して各イベントをinput関数に与えたあとに内部状態が想定の状態に変更されたかを確認します。
次にview関数です。view関数は内部状態を描画するのですが、描画先をstderrに固定してしまうと出力が正しいかどうかをテストで確認できなくなってしまうので、描画先をプロダクションとテストで切り替えられるように引数に取ります。描画するときに利用しているcrossterm::executeは描画先をstd::io::Writeの可変参照を引数に取るのでview関数では描画先をstd::io::Writeの可変参照として引数にとりcrossterm::executeに渡すようにします。描画する内部データfield,cursorはview関数の中で変更する必要はないので可変ではなく不変参照として引数に取ります。
view関数のテストは難しいですが、一度テストデータとして出力のバイト列をファイルに保存しておき、そのデータと同じデータを出力していることを確認するようにします。この方法だと同じデータを出力しているテストにはなりますが、正しい出力なのかのテストにはなりません。正しい出力かどうかを確認する方法を考えたのですが思いつかなかったので正しいかどうかはファイルをget-contentコマンドレットで確認することにします。一応view関数に想定外の変更があったときにテストが失敗して気付けるのでそれで良しとします。
マス目の実装
実際にreversiで遊べるようになって使ってみてわかるのですが、現状だとマス目がなく斜めで挟んだときに挟んだかどうかがとても分かりにくいです。早速改修します。
解説
CUIでマス目を区切る線を表現することはできないのでマス目の色を交互に変更することでマスがあるように見せています。内部データとは一切関係なく、表示に関することだけなのでview関数の修正のみです。
view関数の修正によって出力が変わるので以前作成したtestデータとは不一致となりtestが失敗します。これは想定内なのでtestデータを作り直して完了です。初期石設置の実装
reversiを遊ぶときは必ず最初に4つの石を置いてから始めるので初期化時においておくようにします。あまり意味はないかもしれませんが一応testも書いておきます。解説することは特にないです。
自動反転機能の実装
ここで一番大事な機能の実装に入ります。自動反転機能です。新しく石を置いたときにひっくり返せる石があれば自動でひっくり返るようにします。
解説
自動反転を実現するには置いた石の上下左右斜めの8方向をそれぞれ考える必要があります。その8方向をdirectionという配列で表現しています。それぞれの方向で反転できるものがあるかどうかを判定し、反転できれば反転しています。
手番の実装
自動反転機能を実装したことによりユーザは黒と白を交互に打てばよくなったので今までは黒を打つか白を打つかはどのキーを押すかで決めていましたが、黒の手番の時には黒の石を、白の手番の時は白の石を置くようにすればキーを打ち分ける必要はなくなります。ただし、reversiは常に黒と白が交互に打てるとは限らないのでパスをするコマンドを追加します。
解説
まずは手番を表現するEnumとしてTurnを定義します
input関数でpキーを押すとパス、つまり手番を入れ替える処理を入れます
Enterキーを押すと現在の手番の石をカーソルの場所に設置し、手番を入れ替える処理を入れます
現在の手番が何かわからないと困るのでview関数で現在の手番を出力します
石を置けない場所に置けなくする
reversiでは相手の石をひっくり返せる場所にしか自分の石を置くことが出ませんが、現在はそのような制限はないので自由に置けてしまいます。なので石を置けない場所には置けないようにします。
解説
指定したマスに現在の手番の石が置けるかどうかを判定するcheck_putable関数を定義します。すでに石が存在する場合はおけないのでMasu::Emptyでなかったらfalseを返します。あとはひっくり返せる石があるかどうかを判定しますが、ロジックとしてはauto_reverseの一部を流用すればできるのでとりあえずコピペします。
enterキーが押されたときにcheck_putable関数を呼び出して石を置くかどうかを決めます。
リファクタリングその2
このあたりで一度リファクタリングをします。
共通部分の抽出
ひっくり返すことができるマスを探すロジック
auto_reverseとcheck_putableで同じロジックをコピーして使っている箇所があるので修正します。ここで使っているロジックは同じなので片方にバグがあった場合はもう片方にもバグがあるはずですが、片方を直し忘れるというリスクがあるため同じロジックは共通化します。ただし、同じロジックなら必ず共通化したほうが良いわけではないので注意しましょう。むやみに共通化すると修正を行ったときに思わぬところに影響が出てしまう可能性があるからです。
共通しているロジックはひっくり返すことができるマスを探す部分なのでひっくり返す事ができるマスのVecを返す関数としてget_reversableという関数を定義します。check_putableの中ではget_reversableで返ってくるVecの長さを確認し、auto_reverseの中では返ってきたVecの要素ひとつづつひっくり返す処理をします。共通部分を切り出した後、ちゃんとテストを実行し、問題ないことを確認します。
白黒のEnum
現状、TurnというEnumとMasuというEnumがありますが、どちらにもWhiteとBlackという要素があり、TurnのBlackの時にMasuにBlackを置くといった処理がいくつかあるので黒か白かというEnumを使いまわすことにします。
また、黒の時に白を探すという処理もいくつかあるので色を反転させる処理を関数化します。構造体の作成
Position構造体
マスの位置を表現するために(usize,usize)というタプルを利用している箇所がいくつかありますが、マスの位置は0から7までしか存在しないのでusizeという型を使ってしまうと範囲外の値が入る可能性があります。現在はその都度範囲内かどうかを確認していますが、このタプルを構造体にして、構造体を生成するときに範囲内かどうかを確認するようにします。
Position構造体を作って前後左右を取得するメソッドを作ったのでget_reversable関数内のひっくり返せる石を探す8方向をクロージャで表現できるようになりました。
Field構造体
fieldは[[Masu; 8]; 8]で表現していますが、この二次元配列へはインデックスアクセスなのでPosition構造体を直接利用することができません。Position構造体を使ってfieldにアクセスする構造体としてField構造体を定義します。ついでにinit_field関数もコンストラクタに含めます
打てる場所表示機能の実装
reversiでは相手の石をひっくり返せるマスにしか石はおけないのでおける場所を表示する機能を付けます。といってもcheck_putable関数をすでに実装しているのでview側で利用してやればすぐに実現できます。
大分reversiらしくなってきました。
現在の石の数を表示する機能の実装
field内の石の数をカウントするメソッドをField構造体に実装します。fieldは2次元配列なので二重ループをしてカウントすることもできますが、イテレータのflattenを使って一次元にしてから処理するとすっきりします。
view関数内で表示します。桁数をそろえないとPrintしたりしなかったりする領域ができてしまい、意図しない文字が残ってしまうので桁数を2桁で揃えます。ゲームリセット機能の実装
勝負が終わったときに新しいゲームを開始できるようにrキーを押したらゲームをリセットできるようにします。
最初のゲームを始めるときもcreate_initial_dataを使うようにします自動パス機能の実装
石を置く場所がない時に自動的にパスするようにします。まず石を置けるかどうかを判断するメソッドをfield構造体に実装します。
石を置いたときに手番を反転させた後に石を置けるかを判断し、おけない場合はもう一度手番を反転するようにします勝者の表示機能の実装
ゲーム結果を表示するためにゲームの結果を表すGameResultを定義します。
ゲーム結果を取得するメソッドをfield構造体に追加します。 puttableメソッドによって白か黒のどちらかがまだ石を置ける状態のときはまだ勝負がついてないのでPlayingとなります。白も黒も置けないときに石の数をカウントして勝者を決めます。viewの中でゲーム結果を取得し、表示するようにします。Playingのときには空白を出力して古い結果が残らないようにします。
GUI版の実装
そろそろCUIでは物足りなくなってきたのでGUI版を作成しようと思います。ただせっかく作ったCUI版も残しておきたいのでworkspaceを分けてCUI版とGUI版の両方を遊べるようにします。GUI版もCUI版もreversiのコアな機能は同じはずなので、コアな機能はライブラリクレートとしてCUI版もGUI版も共用できるようにします。
コア機能の分離
まずは既存のファイルをすべてreversi_cuiというサブディレクトリを作って移動させます。そして別途reversi_coreというサブディレクトリを作りcargo init --lib
で初期化します。するとreversi_core/src/lib.rsというファイルができるので、GUI版でも使いたいコア機能(main関数、input関数、view関数以外)をreversi_core/src/lib.rsに移動させます。プロジェクトルートにCargo.tomlを手動で作成し、下記のように記載します
reversi_cuiのCargo.tomlは下記のようにしてreversi_coreの機能を取り込みます
GUI版の作成
RUSTでGUIを実装する方法はいろいろありますが、今回はwasmを簡単に使えるようにしてくれるtrunkを使います。
下記のようにインストールします
cargo install --locked trunk
プロジェクトルートにreversi_guiディレクトリを作成し、下記のようにreversi_gui/Cargo.tomlを作成します。
あとで利用しますが、web_sysクレートは細かくfeatureが定義されているので利用するものを列挙する必要があります。trunkでwasmを表示させるのに必要な空のindex.htmlを作成します
あとはwasm用のコードを書きます。まずはmain関数です。
方針としてはcanvasエレメントを作成してbodyにkeydownのイベントリスナーを登録し、イベントが発生したら内部データの書き換えとcanvasへの反映を行うようにします。
canvasエレメントを作成するにはcreate_element("canvas")ですが、この関数は一般的なElementという型しか返さないので、canvas用のメソッドを利用するためにdyn_intoというメソッドでHtmlCanvasElementにキャストする必要があります。この流れは今後も多用します。
canvasエレメントを取得出来たらサイズを設定したり、contextを取得したり、bodyに追加したりします。この辺りの流れはjavascriptとほぼ同じです。
次にcontextに内部データを書き込むview関数を定義します。contextの操作はjavascriptで利用できるメソッドがほとんどweb_sysクレートに用意されています。ただ、メソッドの引数はほとんどf64なので整数型は使えません。
次にKeyboardEventを取得して内部データを変更するinput関数を定義します
このあたりのコードはcui版のコードとほぼ同じです。view関数とinput関数を定義したらイベントリスナーに登録します
クロージャはイベントリスナに登録したのでRustのmain関数が終了してもDropさせないようにclosure.forget()をつける必要があります。
trunkコマンドを使ってブラウザで動かくことができます
trunk serve
実行結果
マウス操作機能の追加
せっかくGUIにしたのでマウスクリックでも石を置けるようにします。キーボードでの操作ができるようになったのでマウスも簡単にできると思いましたが、複数のイベントリスナーを登録するのにRc(reference counting)を使う必要が出てきます。
まずmouse用のinput関数を定義します
クリックされたcanvas上の座標を入力として受け取ってどのマス上にマウスがあるかを判定して石を置きます。
あとはmain関数でクロージャを定義してイベントリスナーに登録するだけかと思いきやここで大きな問題があります。クロージャを定義するとそのクロージャ内で使われた変数の所有権がクロージャに移動するので、field,cursor,turnといったデータの所有権がmainからクロージャに移動します。定義するクロージャが一つの場合はそのまま所有権を渡せばいいのですが、複数のクロージャを定義したいときは複数のクロージャで所有権を持つ必要があります。そこでRc(reference counting)の出番です。
前述したようにfield,cursor,turnは複数の所有権が必要になるのでRcで包むのですが、Rcで包んだものは不変としてしか扱えないので可変参照を取得できるようにRcで包む前にRefCellで包みます。Rc<RefCell>
で包んだらあとはクロージャに渡す前にRc::cloneすれば所有権ごとクローンされるので複数のクロージャで所有権を持つことができます。
リファクタリングその3
ReversiData構造体の定義
input関数やview関数に毎回field,cursor,turnを渡すのはRcを使うと結構めんどくさいのでこの3つをまとめて一つの構造体として定義します
こうすることでRc::cloneを一回呼べばよくなるのでmain関数がすっきりします通信対戦機能の実装
今までは一つの画面で白と黒両方を置く必要がありましたが、通信機能を実装して複数画面から操作できるようにします。サーバプログラムとクライアントプログラムに分けて、サーバ側で複数のクライアントの管理とリバーシとしての機能を提供し、クライアント側はサーバへ入力を送信し、サーバから送信されたデータを受信して描画を行います。サーバプログラムはreversi_server、クライアントプログラムはreversi_clientというワークスペースで実装します。
まず通信機能を実装する前にReversiDataを通信するためにシリアライズできるようにします。Rustではserde
というクレートを使うとderiveで簡単に構造体をシリアライズできるようになります。
シリアライズするためには構造体のメンバもシリアライズ可能である必要があるのでメンバの構造体にも同様にderiveをつけておきます。
次にクライアント側を実装します。クライアントプログラムはGUI版をベースにReversiDataをサーバからWebSocketで取得するようにします。WebSocketはweb_sysに含まれているのでそれを利用します。
mouseinput関数ではWebSocketでクリックしたマスをシリアライズして送信します。
view関数の中ではWebSocketで受けとったメッセージをReversiDataとしてデシリアライズして描画します。描画部分はGUI版と同様です。
次にサーバ側です。サーバ側は複数の接続を受けるので非同期処理が必要になります。Rustで非同期処理をするには非同期ランタイムが必要ですのでtokio
というクレートを利用します。また、WebSocketライブラリとしてtokio-tungstenite
を使います。サーバ側はtokio-tungstenite
のexampleを参考にしています。
main関数に#[tokio::main]
やasync
がついていますが、main関数をtokioの非同期ランタイムで実行するためにのシンタックスシュガーです。Arc<Mutex<>>
型の変数を定義していますが、これはRustでマルチスレッド間のデータ共有をするためのイディオムです。マルチスレッドでデータ共有をするには複数の所有権が必要になりますが、GUI版で利用したRc型は書き込みと読み込みを同時に行えないシングルスレッドでしか利用できません。マルチスレッドでは書き込みと読み込みが別スレッドで同時に行われる可能性があり、それを回避するためにMutexを使ってデータをlockする仕組みが必要になりそれがArc<Mutex<>>
です。
Tcpをリッスンしてコネクション毎にtokio::spawnでスレッドを立てています。(正確にはスレッドではなく非同期タスクですが、分かりやすさ重視でスレッドと表現しています)
スレッド毎の処理ですが、データの流れが分かりづらいので雑ですが概念図を書きました。(これも厳密にはいろいろ間違ってますが参考程度に)
リファクタリングその4
gui版とclient版で重複しているwasmのコードをreversi_wasm_common
というライブラリとして切り出します。
trunkからwasm-packへ移行
trunkはwasmへのビルドと開発サーバの起動を同時に行ってくれて便利ですがテストが行いにくいのでwasm-packへ移行します。
まず、reversi_clientをライブラリクレートにします
wasm-packでビルドするためにアトリビュートを追加します
これでwasm-packでビルドできるようになります
wasm-pack build --target web
実行するためには下記のようなindex.htmlを作成します。
あとはhttpサーバに置けばよいですが、開発中はhttpサーバをnpxで起動します。npx http-server
マルチセッション機能の実装
一つのサーバで複数のセッションを管理できるようにします。
まずデータ構造ですが、GameIDからReversiDataをとれるようなHashMapとConnection毎にどのGameIDが選択されているかをとれるようなHashMapをもつServerDataという構造体を作成します
クライアントとサーバとのデータのやり取りはそれぞれServerData,ClientDataというシリアライズ、デシリアライズできるEnumとして定義しておきます。このEnumはサーバ側、クライアント側両方で利用するのでライブラリクレートとしてワークスペースを分けます。GameIDはu32をラップしているだけですが、ID順に並べ替えたりHashのKeyにしたりするのでいくつかderiveを追加してます。
上記のようにEnumで定義しておけばデシリアライズした後にmatch式でメッセージ毎に処理を分けるのが書きやすくなります。
おわりに
リバーシを作る過程で今までよく分かってなかった並行処理やwasmについてだいぶ理解を深めることができました。私の理解が間違っている点や、疑問に思う点があったら是非コメントでご指摘、ご意見を頂けると嬉しいです。
Discussion