catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

tauriでテトリスっぽいものを作ってみる

rustの勉強をしています。かたりぃなです。 勉強といっても写経だけじゃあつまらないので、実際に何かを作ってみようと思います。

個人的にはエンターテインメント的なもの、たとえば簡単なゲームとかがモチベーションが上がって良いです。

あまり複雑なものを作ると設計上の問題も懸念されるので、簡単にできるテトリスを作ってみようと思います。

初めてtauriでアプリを作るにあたって、詰まったポイントなどをメモしておきます。

ちなみに完成したのはこんな感じのやつです。

jsからrustを呼び出す

jsから呼び出し可能な関数をrustで書く手順は次のとおりです。

  1. 関数定義に#[tauri::command]を付与する
  2. tauriのinvoke_handlerに登録する
  3. jsからは invoke("関数名", "json形式のパラメータ") で呼び出す
  4. struct等を受け渡すときはserdeクレートを使う

たとえばキー入力を受け取る関数はこんな感じに定義できます。 #[tauri::command]がついただけですね。

#[tauri::command]
fn keyinput(
    key: char
) -> GameField{
    ~~関数本体~~
}

この関数をアプリケーション起動時にinvoke_handlerとしてtauriに登録します。

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            keyinput
        ])

これをjsから呼び出すコードは以下のようになります。

  async function keyDownHandler(e) {
    if(e.key === 'a' || e.key === 'w' || e.key === 's' || e.key === 'd'){
      var gf = await invoke("keyinput", {key:e.key});

この方式でのデータの受け渡しはjson形式になるようです。 structを受け渡すのであれば、serdeクレートを使ってシリアライズできるよう定義します。enumとかも同様に、serdeクレーとのドキュメント通りにやればOKです。

#[derive(Serialize, Deserialize)]
pub struct GameField{
    field: [[i32; 10]; 20],
}

tauri(rust)で状態管理

rustではmutableなグローバル変数は使えません。(頑張れば使えるらしいですが、そういうことはしない方が良いです。) すなわちゲームの状態を管理できないということで、いきなり困ってしまいました。

これ系の話では、往々にしてフレームワーク側が管理する仕組みを用意していたりします。 確かC++のboostの中にもそういうのが幾つもあった気がします。

実際、tauriにもstateを管理するという機能が用意されています。 (https://tauri.app/v1/guides/features/command/#accessing-managed-state)

というわけで、今回作った状態管理はこんな感じです。

  1. 状態管理用の構造体を定義し、Mutexでラップする(書き換えらえるように)
  2. tauriのsetup時にapp.manage("状態管理構造体")を渡す
  3. #[tauri::command]の関数でtauri::State<'_, 状態管理構造体>として受け取る

まず状態を管理する構造体を適当に作ります。 GameFieldは固定された盤面の状態を管理するもの、 FallingBlockが落ちてきているブロック、すなわち動かせるブロックの情報です。

pub struct GameManager{
    field: Mutex<GameField>,
    block: Mutex<FallingBlock>,
}

このstructを、tauriのアプリケーション起動時パラメータに設定します。 setupの中でGameManagerを生成し、それをmanageで登録するというものです。これでtauriが「状態として管理」してくれます。

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_gamefield,
            update_gamefield,
            keyinput
        ])
        .setup(|app|{
            let gamemanager = GameManager::new();
            app.manage(gamemanager);    //State変数として登録する
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");

こうして登録したstateは、次のようにして使うことができます。 #[tauri::command]を指定した関数に渡してくれるようになります。

あとはGameManagerに適当な関数を定義しておけば、状態の読み書きができるようになります。

#[tauri::command]
fn keyinput(
    state: tauri::State<'_, GameManager>,
    key: char
) -> GameField{
    let mut x_pos = state.get_xpos();
    ~~ キー入力に応じた処理 ~~
    state.set_xpos(x_pos);

ちなみに、get,setの実装ではmutexをロックして操作する必要があります。

impl GameManager{
    pub fn get_xpos(&self) -> i32{
        let param = self.update_param.lock().unwrap();
        param.x_pos
    }
    pub fn set_xpos(&self, x_pos:i32){
        let mut param = self.update_param.lock().unwrap();
        param.x_pos = x_pos;
    }

reactコンポーネントを複数作るときはkeyを指定する必要がある

reactでブロックや空白を敷き詰めてゲームフィールドを再現しようとしたとき、 こんな感じのコードになりました。

<div className="gamefield">
    gamefield.flat().map((s, index) => s.fill === 'Block' ?
        <div key={index} className="block"></div> :
        <div key={index} className="Space"></div>)
</div>

gamefieldのfill要素には'Block'か'Space'が入っていて、それを出し分けつつ複数個生成してるコードです。 key={index}はreactが必要としているもので、付与しておかないとコンソールにエラーが出てきてしまいます。

ちなみにflattenしてしまっているのは、ゲームフィールドの縦横幅を固定とし、gridもそのように調整したので、順に生成していくだけでいい感じに折り返してくれるようになりました。

.block{
  width: 30px;
  height: 30px;
  background-color: green;
}

.space{
  width:30px;
  height:30px;
  background-color: aliceblue;
}

.gamefield{
  display: grid;
  grid-template-columns: repeat(10, 30px);
  grid-template-rows: repeat(20, 30px);
  column-gap: 2px;
  row-gap: 2px;
  width: 320px;
  height: 640px;
  background-color: gray;
}

rustで複数のラムダを統一的に扱いたい

ゲームロジックの実装で

  1. 入力を取得する
  2. 入力をもとに盤面を更新
  3. 更新結果を反映

みたいなのがあります。

このとき、3のロジックを1の段階で切り替えたいことがあります。 移動方向によってstateをどう更新するかが変わるから等です。 解決策として1の段階でラムダを生成し、3の段階ではそのラムダを使うようにすればいいのではと考えました。 次のようになりました。

    let postproc:Box<dyn Fn(&tauri::State<'_, GameManager>)->()>  = match key{
        'a' => {
            x_pos = x_pos - 1;
            Box::new(move |state|{state.set_xpos(x_pos);})
        },
        'd' => {
            x_pos = x_pos + 1;
            Box::new(move |state|{state.set_xpos(x_pos);})
        },
        ~~ 略 ~~
    };

    ~~ゲームのロジック~~
    if ~~~ {
        postproc(&state);
    }

ポイントは let postproc:Box<dyn Fn(&tauri::State<'_, GameManager>)->()>の部分で、 これによりmatchアーム部のラムダを統一的に扱える型で受け取ることを明示します。

まだ完全には理解できていませんが、大体そんな感じみたいです。

イテレータとかファンクタを使いこなしたい

Cとかで書くと、forでループカウンタまわして。。。みたいなのが多発しがちですが、rustには関数型言語らしい機能が多々あるので大いに利用していきたいです。

たとえばフィールド中の一行が全てブロックで埋まっている行を抽出する処理はこうなりました。

for row in field.iter() {
    if row.iter().all(|x| *x == 'Block'){
    }
}

field.iter()で、上から下へ走査していき、 row.iter().all(|x| *x == 'Block')で、横一列が特定の値か(=ブロックで埋まっている)を抽出します。 この記事を書いてて気づきましたが、外側のforもmapで書けばすっきりしそうですね。

ちなみにこのコードはBingAIに質問しつつ作ったものです。 この例のようにAIの回答通りでは欲しいものにはならないので、追加の質問をしたり、提示されたコードを参考に別の方法を探すなどの対応は必要そうですね。。