🧩

Rustで少しずつリバーシを作ってみた

2022/09/15に公開約18,700字

はじめに

Rustの学習目的でリバーシを作ってみたいと思います。最初からすべての機能を作るのではなく、少しずつ機能を追加しながら解説していきます。また、できるだけよいコードを目指すために機能追加の度にリファクタリングをします。

最初の開発

仕様策定

まずはリバーシとして最低限遊べるうえで最も工数がかからなさそうな仕様を策定します。

  • cuiアプリ
  • 矢印キーでカーソル移動
  • Wキーで白石を置き、Bキーで黒石を置き、Backspaseキーで石を取り除く
  • Escキーでアプリ終了

とりあえずこれだけあればリバーシとして遊ぶことはできます。cuiアプリなので実行はWindowsTerminalを想定します。

実装

ソース

とりあえずコードの良し悪しは置いといて動くものを作ります

https://github.com/gawetto/reversi/tree/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/src/main.rs

実行結果

解説

cuiアプリとして実装するのでターミナルライブラリを導入します。今回はcrosstermを利用します。Cargo.tomlのdependenciesに下記を追加します

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/Cargo.toml#L9-L9

crosstermでcuiアプリを作るときまず下記3点を行います。

  • 端末をRAWモードにする
  • カーソルを非表示にする
  • AlternateScreenに切り替える

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/src/main.rs#L20-L21

今回はstderrに出力するようにしてます。stdoutでも問題ありません。また、アプリを終了するときにそれぞれ元に戻すのを忘れずに行います。

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/src/main.rs#L101-L102

アプリの全体像としては、内部データとしてそれぞれのマス目(8×8)に対応した黒か白か空かの3パターンの状態とカーソルがどこにあるかの座標があり、ユーザの操作によって内部データが変化し、変化した直後に全てのマスを再描画するようにします。

マスの黒か白か空かの3パターンの状態はenumで定義しています

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/src/main.rs#L10-L15

内部データは変化するのでmutで定義しています。盤面の状態はマスの二次元配列とし、カーソルは何行の何列目かをタプルとして保持します。

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/src/main.rs#L18-L19

描画の方法としては、まずMoveToでカーソルを左上にしてそのあとはPrintでカーソルの位置とマスの状態を確認しながら一文字づつ出力しています。黒石・白石の表現はまるでこの時のために準備されていたかのような絵文字⚫⚪が存在するのでこれを使います。カーソルの位置はBackgroudColorを切り替えることで表現します。

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/src/main.rs#L23-L44

ユーザの入力を取得するにはcrossterm::event::read()を使います。対応したKeyEventが発生したときに内部状態を更新します。

https://github.com/gawetto/reversi/blob/92f2f1726e7a276067e2d1d43ad5a3dcdb4cb482/src/main.rs#L45-L99

描画と入力取得をloopさせることで入力がビューに反映されるようになります。

最初のリファクタリング

現状はmain関数しかないのでテストが書けません。テストが書けるようにリファクタリングしていきます。今ある機能の中でテストで確認するべきことは

  • 各入力によって正しく内部状態が変更されているか
  • 内部状態を正しく描画できているか

なのでそれぞれの機能に該当するコードを関数化しテストを書きます

https://github.com/gawetto/reversi/commit/997e9f789c0e0b7abf4971fc2dbf09a51be2323e

解説

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と同様に可変参照として引数を取ります。

https://github.com/gawetto/reversi/blob/997e9f789c0e0b7abf4971fc2dbf09a51be2323e/src/main.rs#L17-L79

そしてinput関数に対するテストですが、内部状態のfield,cursor,endを定義して各イベントをinput関数に与えたあとに内部状態が想定の状態に変更されたかを確認します。

https://github.com/gawetto/reversi/blob/997e9f789c0e0b7abf4971fc2dbf09a51be2323e/src/main.rs#L132-L165

次にview関数です。view関数は内部状態を描画するのですが、描画先をstderrに固定してしまうと出力が正しいかどうかをテストで確認できなくなってしまうので、描画先をプロダクションとテストで切り替えられるように引数に取ります。描画するときに利用しているcrossterm::executeは描画先をstd::io::Writeの可変参照を引数に取るのでview関数では描画先をstd::io::Writeの可変参照として引数にとりcrossterm::executeに渡すようにします。描画する内部データfield,cursorはview関数の中で変更する必要はないので可変ではなく不変参照として引数に取ります。

https://github.com/gawetto/reversi/blob/997e9f789c0e0b7abf4971fc2dbf09a51be2323e/src/main.rs#L80-L109

view関数のテストは難しいですが、一度テストデータとして出力のバイト列をファイルに保存しておき、そのデータと同じデータを出力していることを確認するようにします。この方法だと同じデータを出力しているテストにはなりますが、正しい出力なのかのテストにはなりません。正しい出力かどうかを確認する方法を考えたのですが思いつかなかったので正しいかどうかはファイルをget-contentコマンドレットで確認することにします。一応view関数に想定外の変更があったときにテストが失敗して気付けるのでそれで良しとします。

https://github.com/gawetto/reversi/blob/997e9f789c0e0b7abf4971fc2dbf09a51be2323e/src/main.rs#L166-L184

マス目の実装

実際にreversiで遊べるようになって使ってみてわかるのですが、現状だとマス目がなく斜めで挟んだときに挟んだかどうかがとても分かりにくいです。早速改修します。

https://github.com/gawetto/reversi/tree/ac3b75d045135ec54dbd1679f21439762af46699

解説

CUIでマス目を区切る線を表現することはできないのでマス目の色を交互に変更することでマスがあるように見せています。内部データとは一切関係なく、表示に関することだけなのでview関数の修正のみです。

https://github.com/gawetto/reversi/blob/ac3b75d045135ec54dbd1679f21439762af46699/src/main.rs#L91-L95
view関数の修正によって出力が変わるので以前作成したtestデータとは不一致となりtestが失敗します。これは想定内なのでtestデータを作り直して完了です。

初期石設置の実装

reversiを遊ぶときは必ず最初に4つの石を置いてから始めるので初期化時においておくようにします。あまり意味はないかもしれませんが一応testも書いておきます。解説することは特にないです。

https://github.com/gawetto/reversi/blob/ef2fa4a87c4bc878ea72e0ec945e6c4dffb8a31c/src/main.rs#L115-L120
https://github.com/gawetto/reversi/blob/ef2fa4a87c4bc878ea72e0ec945e6c4dffb8a31c/src/main.rs#L196-L1204

自動反転機能の実装

ここで一番大事な機能の実装に入ります。自動反転機能です。新しく石を置いたときにひっくり返せる石があれば自動でひっくり返るようにします。

https://github.com/gawetto/reversi/blob/3609b988845f4928f0796fc1ea559765609784c0/src/main.rs#L17-L53

https://github.com/gawetto/reversi/blob/3609b988845f4928f0796fc1ea559765609784c0/src/main.rs#L246-L253

解説

自動反転を実現するには置いた石の上下左右斜めの8方向をそれぞれ考える必要があります。その8方向をdirectionという配列で表現しています。それぞれの方向で反転できるものがあるかどうかを判定し、反転できれば反転しています。

手番の実装

自動反転機能を実装したことによりユーザは黒と白を交互に打てばよくなったので今までは黒を打つか白を打つかはどのキーを押すかで決めていましたが、黒の手番の時には黒の石を、白の手番の時は白の石を置くようにすればキーを打ち分ける必要はなくなります。ただし、reversiは常に黒と白が交互に打てるとは限らないのでパスをするコマンドを追加します。

解説

まずは手番を表現するEnumとしてTurnを定義します

https://github.com/gawetto/reversi/blob/b6b4e0b58c2f2b7654d873326e289621fb8b4b11/src/main.rs#L17-L21

input関数でpキーを押すとパス、つまり手番を入れ替える処理を入れます

https://github.com/gawetto/reversi/blob/b6b4e0b58c2f2b7654d873326e289621fb8b4b11/src/main.rs#L72-L82

Enterキーを押すと現在の手番の石をカーソルの場所に設置し、手番を入れ替える処理を入れます

https://github.com/gawetto/reversi/blob/b6b4e0b58c2f2b7654d873326e289621fb8b4b11/src/main.rs#L114-L129

現在の手番が何かわからないと困るのでview関数で現在の手番を出力します

https://github.com/gawetto/reversi/blob/b6b4e0b58c2f2b7654d873326e289621fb8b4b11/src/main.rs#L173-L180

石を置けない場所に置けなくする

reversiでは相手の石をひっくり返せる場所にしか自分の石を置くことが出ませんが、現在はそのような制限はないので自由に置けてしまいます。なので石を置けない場所には置けないようにします。

解説

指定したマスに現在の手番の石が置けるかどうかを判定するcheck_putable関数を定義します。すでに石が存在する場合はおけないのでMasu::Emptyでなかったらfalseを返します。あとはひっくり返せる石があるかどうかを判定しますが、ロジックとしてはauto_reverseの一部を流用すればできるのでとりあえずコピペします。

https://github.com/gawetto/reversi/blob/02a91bbb867489ad146ab0f4e4c118980c3f0124/src/main.rs#L23-L65

enterキーが押されたときにcheck_putable関数を呼び出して石を置くかどうかを決めます。

https://github.com/gawetto/reversi/blob/02a91bbb867489ad146ab0f4e4c118980c3f0124/src/main.rs#L158-L175

リファクタリングその2

このあたりで一度リファクタリングをします。

共通部分の抽出

ひっくり返すことができるマスを探すロジック

auto_reverseとcheck_putableで同じロジックをコピーして使っている箇所があるので修正します。ここで使っているロジックは同じなので片方にバグがあった場合はもう片方にもバグがあるはずですが、片方を直し忘れるというリスクがあるため同じロジックは共通化します。ただし、同じロジックなら必ず共通化したほうが良いわけではないので注意しましょう。むやみに共通化すると修正を行ったときに思わぬところに影響が出てしまう可能性があるからです。

https://github.com/gawetto/reversi/blob/330b0dc9c11bf8cd5dcb3161127a15b0b4651360/src/main.rs#L23-L85

共通しているロジックはひっくり返すことができるマスを探す部分なのでひっくり返す事ができるマスのVecを返す関数としてget_reversableという関数を定義します。check_putableの中ではget_reversableで返ってくるVecの長さを確認し、auto_reverseの中では返ってきたVecの要素ひとつづつひっくり返す処理をします。共通部分を切り出した後、ちゃんとテストを実行し、問題ないことを確認します。

白黒のEnum

現状、TurnというEnumとMasuというEnumがありますが、どちらにもWhiteとBlackという要素があり、TurnのBlackの時にMasuにBlackを置くといった処理がいくつかあるので黒か白かというEnumを使いまわすことにします。

https://github.com/gawetto/reversi/blob/74d1429ce5aafcde62dd17e7f669b10b64820f22/src/main.rs#L10-L20
また、黒の時に白を探すという処理もいくつかあるので色を反転させる処理を関数化します。
https://github.com/gawetto/reversi/blob/74d1429ce5aafcde62dd17e7f669b10b64820f22/src/main.rs#L22-L27

構造体の作成

Position構造体

マスの位置を表現するために(usize,usize)というタプルを利用している箇所がいくつかありますが、マスの位置は0から7までしか存在しないのでusizeという型を使ってしまうと範囲外の値が入る可能性があります。現在はその都度範囲内かどうかを確認していますが、このタプルを構造体にして、構造体を生成するときに範囲内かどうかを確認するようにします。

https://github.com/gawetto/reversi/blob/871c32d08db8280fcb9ec95dec5aeeb411e0d793/src/main.rs#L10-L46

Position構造体を作って前後左右を取得するメソッドを作ったのでget_reversable関数内のひっくり返せる石を探す8方向をクロージャで表現できるようになりました。

https://github.com/gawetto/reversi/blob/e932f2bbf2b4a6c835403cf2040431e912b5389d/src/main.rs#L67-L102

Field構造体

fieldは[[Masu; 8]; 8]で表現していますが、この二次元配列へはインデックスアクセスなのでPosition構造体を直接利用することができません。Position構造体を使ってfieldにアクセスする構造体としてField構造体を定義します。ついでにinit_field関数もコンストラクタに含めます

https://github.com/gawetto/reversi/blob/be467404ea99eec9efee4136c1d9333e99aa8ab3/src/main.rs#L18-L39

打てる場所表示機能の実装

reversiでは相手の石をひっくり返せるマスにしか石はおけないのでおける場所を表示する機能を付けます。といってもcheck_putable関数をすでに実装しているのでview側で利用してやればすぐに実現できます。

大分reversiらしくなってきました。

https://github.com/gawetto/reversi/blob/c24d23dd3708f037302d637b3c35a846c5b2110e/src/main.rs#L201-L256

現在の石の数を表示する機能の実装

field内の石の数をカウントするメソッドをField構造体に実装します。fieldは2次元配列なので二重ループをしてカウントすることもできますが、イテレータのflattenを使って一次元にしてから処理するとすっきりします。

https://github.com/gawetto/reversi/blob/e065d5f1dcba51848dacf2e5b207774529b8866f/src/main.rs#L39-L48
view関数内で表示します。桁数をそろえないとPrintしたりしなかったりする領域ができてしまい、意図しない文字が残ってしまうので桁数を2桁で揃えます。
https://github.com/gawetto/reversi/blob/e065d5f1dcba51848dacf2e5b207774529b8866f/src/main.rs#L265-L272

ゲームリセット機能の実装

勝負が終わったときに新しいゲームを開始できるようにrキーを押したらゲームをリセットできるようにします。

https://github.com/gawetto/reversi/blob/eaf2be0df6dc79905059242041afdcabcbc7aafa/src/main.rs#L280-L285
https://github.com/gawetto/reversi/blob/eaf2be0df6dc79905059242041afdcabcbc7aafa/src/main.rs#L168-L171
最初のゲームを始めるときもcreate_initial_dataを使うようにします
https://github.com/gawetto/reversi/blob/eaf2be0df6dc79905059242041afdcabcbc7aafa/src/main.rs#L287-L288

自動パス機能の実装

石を置く場所がない時に自動的にパスするようにします。まず石を置けるかどうかを判断するメソッドをfield構造体に実装します。

https://github.com/gawetto/reversi/blob/e5c30d68eab3591bbe0fedfda8ca01f25556d70c/src/main.rs#L55-L65
石を置いたときに手番を反転させた後に石を置けるかを判断し、おけない場合はもう一度手番を反転するようにします
https://github.com/gawetto/reversi/blob/e5c30d68eab3591bbe0fedfda8ca01f25556d70c/src/main.rs#L226-L238

勝者の表示機能の実装

ゲーム結果を表示するためにゲームの結果を表すGameResultを定義します。

https://github.com/gawetto/reversi/blob/e5c30d68eab3591bbe0fedfda8ca01f25556d70c/src/main.rs#L22-L26
ゲーム結果を取得するメソッドをfield構造体に追加します。
https://github.com/gawetto/reversi/blob/e5c30d68eab3591bbe0fedfda8ca01f25556d70c/src/main.rs#L66-L79
puttableメソッドによって白か黒のどちらかがまだ石を置ける状態のときはまだ勝負がついてないのでPlayingとなります。白も黒も置けないときに石の数をカウントして勝者を決めます。

viewの中でゲーム結果を取得し、表示するようにします。Playingのときには空白を出力して古い結果が残らないようにします。

https://github.com/gawetto/reversi/blob/e5c30d68eab3591bbe0fedfda8ca01f25556d70c/src/main.rs#L311-L324

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を手動で作成し、下記のように記載します

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/Cargo.toml
reversi_cuiのCargo.tomlは下記のようにしてreversi_coreの機能を取り込みます
https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_cui/Cargo.toml

GUI版の作成

RUSTでGUIを実装する方法はいろいろありますが、今回はwasmを簡単に使えるようにしてくれるtrunkを使います。
下記のようにインストールします

cargo install --locked trunk

プロジェクトルートにreversi_guiディレクトリを作成し、下記のようにreversi_gui/Cargo.tomlを作成します。

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_gui/Cargo.toml
あとで利用しますが、web_sysクレートは細かくfeatureが定義されているので利用するものを列挙する必要があります。

trunkでwasmを表示させるのに必要な空のindex.htmlを作成します

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_gui/index.html

あとはwasm用のコードを書きます。まずはmain関数です。
方針としてはcanvasエレメントを作成してbodyにkeydownのイベントリスナーを登録し、イベントが発生したら内部データの書き換えとcanvasへの反映を行うようにします。

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_gui/src/main.rs#L95-L102
canvasエレメントを作成するにはcreate_element("canvas")ですが、この関数は一般的なElementという型しか返さないので、canvas用のメソッドを利用するためにdyn_intoというメソッドでHtmlCanvasElementにキャストする必要があります。この流れは今後も多用します。

canvasエレメントを取得出来たらサイズを設定したり、contextを取得したり、bodyに追加したりします。この辺りの流れはjavascriptとほぼ同じです。

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_gui/src/main.rs#L103-L109

次にcontextに内部データを書き込むview関数を定義します。contextの操作はjavascriptで利用できるメソッドがほとんどweb_sysクレートに用意されています。ただ、メソッドの引数はほとんどf64なので整数型は使えません。

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_gui/src/main.rs#L7-L62

次にKeyboardEventを取得して内部データを変更するinput関数を定義します

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_gui/src/main.rs#L64-L93
このあたりのコードはcui版のコードとほぼ同じです。

view関数とinput関数を定義したらイベントリスナーに登録します

https://github.com/gawetto/reversi/blob/1647776b816faffa870d0549fca0ba2b70af031d/reversi_gui/src/main.rs#L111-L116
いきなり複雑なコードが出てきましたが、このあたりはweb_sysやwasm_bindgenがRustとjavascriptを橋渡しするために必要なコードになっていて、私も完全には理解していないですが、理解できている範囲で説明します。まず、Rustのクロージャをmoveキーワードをつけて定義しています。これはmain関数で定義した内部データはクロージャに渡したらmain関数で利用することがないので、クロージャに内部データの所有権を渡すためにつけています。定義したクロージャをBoxでくるんでからキャストしていますが、ここはClosure::wrapの実装上の都合で引数をこの型にしなければならないのでキャストしているという理解です。同様にClousure::wrapをadd_event_listener_with_callbackに渡す時も.as_ref().unchecked_ref()というメソッドチェインを使って変換します。
クロージャはイベントリスナに登録したのでRustのmain関数が終了してもDropさせないようにclosure.forget()をつける必要があります。

trunkコマンドを使ってブラウザで動かくことができます

trunk serve

実行結果

マウス操作機能の追加

せっかくGUIにしたのでマウスクリックでも石を置けるようにします。キーボードでの操作ができるようになったのでマウスも簡単にできると思いましたが、複数のイベントリスナーを登録するのにRc(reference counting)を使う必要が出てきます。

まずmouse用のinput関数を定義します

https://github.com/gawetto/reversi/blob/b821465787e88e9e67515fb287a09b1b255e1bd5/reversi_gui/src/main.rs#L66-L81

クリックされたcanvas上の座標を入力として受け取ってどのマス上にマウスがあるかを判定して石を置きます。
あとはmain関数でクロージャを定義してイベントリスナーに登録するだけかと思いきやここで大きな問題があります。クロージャを定義するとそのクロージャ内で使われた変数の所有権がクロージャに移動するので、field,cursor,turnといったデータの所有権がmainからクロージャに移動します。定義するクロージャが一つの場合はそのまま所有権を渡せばいいのですが、複数のクロージャを定義したいときは複数のクロージャで所有権を持つ必要があります。そこでRc(reference counting)の出番です。

https://github.com/gawetto/reversi/blob/b821465787e88e9e67515fb287a09b1b255e1bd5/reversi_gui/src/main.rs#L114-L186

前述したようにfield,cursor,turnは複数の所有権が必要になるのでRcで包むのですが、Rcで包んだものは不変としてしか扱えないので可変参照を取得できるようにRcで包む前にRefCellで包みます。Rc<RefCell>で包んだらあとはクロージャに渡す前にRc::cloneすれば所有権ごとクローンされるので複数のクロージャで所有権を持つことができます。

リファクタリングその3

ReversiData構造体の定義

input関数やview関数に毎回field,cursor,turnを渡すのはRcを使うと結構めんどくさいのでこの3つをまとめて一つの構造体として定義します

https://github.com/gawetto/reversi/blob/e4bb5ec1190c18f92c59323f5fd64e1cbd606c93/reversi_core/src/lib.rs#L9-L24
こうすることでRc::cloneを一回呼べばよくなるのでmain関数がすっきりします
https://github.com/gawetto/reversi/blob/e4bb5ec1190c18f92c59323f5fd64e1cbd606c93/reversi_gui/src/main.rs#L114-L155

通信対戦機能の実装

今までは一つの画面で白と黒両方を置く必要がありましたが、通信機能を実装して複数画面から操作できるようにします。サーバプログラムとクライアントプログラムに分けて、サーバ側で複数のクライアントの管理とリバーシとしての機能を提供し、クライアント側はサーバへ入力を送信し、サーバから送信されたデータを受信して描画を行います。サーバプログラムはreversi_server、クライアントプログラムはreversi_clientというワークスペースで実装します。

まず通信機能を実装する前にReversiDataを通信するためにシリアライズできるようにします。Rustではserdeというクレートを使うとderiveで簡単に構造体をシリアライズできるようになります。

https://github.com/gawetto/reversi/blob/1c35cc5007d5558ac919e69aa233465a0bc47f2f/reversi_core/src/lib.rs#L11-L16

シリアライズするためには構造体のメンバもシリアライズ可能である必要があるのでメンバの構造体にも同様にderiveをつけておきます。

次にクライアント側を実装します。クライアントプログラムはGUI版をベースにReversiDataをサーバからWebSocketで取得するようにします。WebSocketはweb_sysに含まれているのでそれを利用します。

https://github.com/gawetto/reversi/blob/1c35cc5007d5558ac919e69aa233465a0bc47f2f/reversi_client/src/main.rs#L97-L112

mouseinput関数ではWebSocketでクリックしたマスをシリアライズして送信します。

https://github.com/gawetto/reversi/blob/1c35cc5007d5558ac919e69aa233465a0bc47f2f/reversi_client/src/main.rs#L72-L82

view関数の中ではWebSocketで受けとったメッセージをReversiDataとしてデシリアライズして描画します。描画部分はGUI版と同様です。

https://github.com/gawetto/reversi/blob/1c35cc5007d5558ac919e69aa233465a0bc47f2f/reversi_client/src/main.rs#L9-L15

次にサーバ側です。サーバ側は複数の接続を受けるので非同期処理が必要になります。Rustで非同期処理をするには非同期ランタイムが必要ですのでtokioというクレートを利用します。また、WebSocketライブラリとしてtokio-tungsteniteを使います。サーバ側はtokio-tungsteniteのexampleを参考にしています。

https://github.com/gawetto/reversi/blob/1c35cc5007d5558ac919e69aa233465a0bc47f2f/reversi_server/src/main.rs#L85-L103
main関数に#[tokio::main]asyncがついていますが、main関数をtokioの非同期ランタイムで実行するためにのシンタックスシュガーです。Arc<Mutex<>>型の変数を定義していますが、これはRustでマルチスレッド間のデータ共有をするためのイディオムです。マルチスレッドでデータ共有をするには複数の所有権が必要になりますが、GUI版で利用したRc型は書き込みと読み込みを同時に行えないシングルスレッドでしか利用できません。マルチスレッドでは書き込みと読み込みが別スレッドで同時に行われる可能性があり、それを回避するためにMutexを使ってデータをlockする仕組みが必要になりそれがArc<Mutex<>>です。

Tcpをリッスンしてコネクション毎にtokio::spawnでスレッドを立てています。(正確にはスレッドではなく非同期タスクですが、分かりやすさ重視でスレッドと表現しています)

https://github.com/gawetto/reversi/blob/da7005955b5ef05764e64ac972c1156ee7cbe037/reversi_server/src/main.rs#L15-L79

スレッド毎の処理ですが、データの流れが分かりづらいので雑ですが概念図を書きました。(これも厳密にはいろいろ間違ってますが参考程度に)

リファクタリングその4

gui版とclient版で重複しているwasmのコードをreversi_wasm_commonというライブラリとして切り出します。

trunkからwasm-packへ移行

trunkはwasmへのビルドと開発サーバの起動を同時に行ってくれて便利ですがテストが行いにくいのでwasm-packへ移行します。
まず、reversi_clientをライブラリクレートにします

https://github.com/gawetto/reversi/blob/f02afc4c9e4771f764812388554b70bbca531650/reversi_client/Cargo.toml#L8-L9
wasm-packでビルドするためにアトリビュートを追加します
https://github.com/gawetto/reversi/blob/f02afc4c9e4771f764812388554b70bbca531650/reversi_client/src/lib.rs#L9-L10

これでwasm-packでビルドできるようになります

wasm-pack build --target web

実行するためには下記のようなindex.htmlを作成します。

https://github.com/gawetto/reversi/blob/f02afc4c9e4771f764812388554b70bbca531650/reversi_client/index.html
あとはhttpサーバに置けばよいですが、開発中はhttpサーバをnpxで起動します。
npx http-server

マルチセッション機能の実装

一つのサーバで複数のセッションを管理できるようにします。

まずデータ構造ですが、GameIDからReversiDataをとれるようなHashMapとConnection毎にどのGameIDが選択されているかをとれるようなHashMapをもつServerDataという構造体を作成します

https://github.com/gawetto/reversi/blob/494dad6f0606591248070333f889ad1ca4fb83e9/reversi_server/src/main.rs#L23-L43

クライアントとサーバとのデータのやり取りはそれぞれServerData,ClientDataというシリアライズ、デシリアライズできるEnumとして定義しておきます。このEnumはサーバ側、クライアント側両方で利用するのでライブラリクレートとしてワークスペースを分けます。GameIDはu32をラップしているだけですが、ID順に並べ替えたりHashのKeyにしたりするのでいくつかderiveを追加してます。

https://github.com/gawetto/reversi/blob/494dad6f0606591248070333f889ad1ca4fb83e9/reversi_message/src/lib.rs

上記のようにEnumで定義しておけばデシリアライズした後にmatch式でメッセージ毎に処理を分けるのが書きやすくなります。

https://github.com/gawetto/reversi/blob/494dad6f0606591248070333f889ad1ca4fb83e9/reversi_server/src/main.rs#L166-L172
https://github.com/gawetto/reversi/blob/494dad6f0606591248070333f889ad1ca4fb83e9/reversi_server/src/main.rs#L143-L151

おわりに

リバーシを作る過程で今までよく分かってなかった並行処理やwasmについてだいぶ理解を深めることができました。私の理解が間違っている点や、疑問に思う点があったら是非コメントでご指摘、ご意見を頂けると嬉しいです。

Discussion

ログインするとコメントできます