以下の内容はhttps://smooth-pudding.hatenablog.com/entry/2025/05/31/190512より取得しました。


【AI×Tauri】第八回:バックエンドを作って連携する

前回はユーザーが操作するときのエフェクトをいくつか実装し、ボードのフロントエンド実装のベースを完成させました。
smooth-pudding.hatenablog.com

今回はいよいよバックエンドの実装を行い、セカンドベストのルールに沿った振る舞いができるようにしていきます。

バックエンドを生成

バックエンドの仕様については第二回にまとめました。これを docs/backend_api.md に保存します。これを使って実装を生成してみてもらいます。Cursor の Agent を開いて、以下を命令します。

@backend_api.md の仕様に従って、バックエンドのAPIを実装してください。ただし実装するのはAPIの表面の部分のみにとどめて、内部実装はダミーにしてください。

内部実装を空にしたのは、だいたいうまく行かないからです。あと Rust のコード部分は自分で書いてみたいというのもあります。

さて、実行が終わると、かなり多くのファイルが生成されました。以下は git status の実行結果です。前半が編集されたファイル、後半が新たに生成されたファイルです。

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   src-tauri/src/lib.rs

追跡されていないファイル:
  (use "git add <file>..." to include in what will be committed)
        docs/api_implementation.md
        src-tauri/src/api.rs
        src-tauri/src/game.rs
        src-tauri/src/game_engine.rs
        src/lib/
        src/types/

src-tauri 以下は Rust のバックエンドの実装、src 以下はフロントエンドとの連携のためのインターフェース群です。さらに親切なことに docs/api_implementation.md が生成されていて、これをAIに食べさせればフロントエンドの実装もかなり捗りそうです。

生成されたファイルの内訳は api_implementation.md に詳しいので、そのまま引用します。

  • Rust バックエンド (src-tauri/src/)
    1. `game.rs` - ゲームのデータ構造とタイプ定義
      • `Position`, `Player`, `PieceStack`, `GameState`, `TurnPhase`, `MoveAction`
      • Push 型 API 用イベント構造体
    2. `game_engine.rs` - ゲームエンジン(ダミー実装)
      • ゲーム状態管理
      • AI 動作シミュレーション
      • イベント発火
    3. `api.rs` - Pull 型 API(Tauri commands)
      • ゲーム管理 API
      • プレイヤーアクション API
      • ゲーム情報 API
    4. `lib.rs` - メインエントリーポイント
      • モジュール統合
      • Tauri アプリケーション設定
  • TypeScript フロントエンド (src/)
    1. `types/game.ts` - TypeScript 型定義
      • Rust 構造体に対応する TypeScript 型
      • フロントエンド用インターフェース
    2. `lib/gameApi.ts` - API ヘルパー関数
      • Pull 型 API 呼び出しクラス
      • Push 型 API イベントリスナー
      • 使用例

中身のコードも軽く見て、Tauri の書き方の雰囲気を感じ取っておきましょう。まず gameApi.ts はこんな構造をしています。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { 
  // API 用の基本的な型たち
} from '../types/game';

// Pull型API(invoke関数)
export class GameAPI {
  // 各種実装
}

// Push型API(イベントリスナー)
export class GameEventListeners {
  // 各種実装
}

// 使用例
export const gameApiExample = {
  async startNewGame() {
    // ゲーム開始の実装例
  },

  async makePlayerMove(action: MoveAction) {
    // 手を実行するときの実装例
  },

  setupEventListeners() {
    // イベントリスナーの設定例
  }
};

例えば Pull 型APIである合法手を取得する関数 getLegalMoves() は、以下のように実装されています。invoke という tauri 専用の関数を使って、何やら関数を呼び出しています。

  static async getLegalMoves(): Promise<MoveAction[]> {
    return await invoke('get_legal_moves');
  }

引数がある場合はどうでしょう?特定の位置のコマの情報を取得する getPositionStack(position: Position) はこんな感じです。invoke の引数に入っている関数名の文字列のあとに、引数が格納されました。

  static async getPositionStack(position: Position): Promise<PieceStack> {
    return await invoke('get_position_stack', { position });
  }

一方、Push 型 API である onAiSecondBestDeclared (AIがセカンドベスト宣言をしたときに発火するコールバック) は、以下のように実装されています。こちらは invoke の代わりに listen<...> を使っていて、関数名のあとに (event) => callback(event.payload) という関数を渡しています。

  static async onAiSecondBestDeclared(callback: (event: AiSecondBestEvent) => void) {
    return await listen<AiSecondBestEvent>('ai_second_best_declared', (event) => {
      callback(event.payload);
    });
  }

次に、game.ts には API 用の基本的な型が定義されています。例えば、先程登場した Position は、以下の enum として定義されています。他にも enum や interface がズラズラと並んでいます。

export enum Position {
  N = "N",
  NE = "NE", 
  E = "E",
  SE = "SE",
  S = "S",
  SW = "SW",
  W = "W", 
  NW = "NW"
}

gameApi.ts で関数名を指定して呼び出していましたが、これらはどう解釈されるのでしょうか?バックエンド側の実装を見てみます。まずは api.rs を見てみると、こんな感じです。Pull 型 API の内容が定義されています。

use crate::game::*;
use crate::game_engine::GameEngine;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, State};

pub type GameEngineState = Arc<Mutex<GameEngine>>;

// ...

#[tauri::command]
pub fn get_legal_moves(state: State<GameEngineState>) -> Vec<MoveAction> {
    let engine = state.lock().unwrap();
    engine.get_legal_moves()
}

// ...

#[tauri::command]
pub fn get_position_stack(position: Position, state: State<GameEngineState>) -> PieceStack {
    let engine = state.lock().unwrap();
    engine.get_position_stack(position)
}

// ...

だいたいこんな構造になっていますね。state にどう渡しているかは不明ですが、引数からもらった GameEngine の中身を取り出して、同名のメソッドを呼び出しているようです。

#[tauri::command]
pub fn APIのメソッド名(..API引数, state: State<GameEngineState>) -> APIの戻り値の型 {
    let engine = state.lock().unwrap();
    engine.APIのメソッド名(...)
}

game_engine.rs を覗くと、GameEngine の仮実装が与えられていました。バックエンドの振る舞いを実装する場合は、この中身を書き換えていけばよさそうです。

use crate::game::*;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};

pub struct GameEngine {
    state: Arc<Mutex<GameState>>,
}

impl GameEngine {
  // 各種のAPI内部実装
}

API で引数として与えられる基本的な型たちは game.rs の中にあります。serde の Serialize と Deserialize を使うことで、json とシンプルに対応がつくようになっているようです。

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Position {
    N,
    NE,
    E,
    SE,
    S,
    SW,
    W,
    NW,
}

// ...

最後に、バックエンド側のAPIの定義の部分にあった、state がどうやって与えられるか問題を解決するために、lib.rs を覗いてみます。

mod api;
mod game;
mod game_engine;

use api::GameEngineState;
use game_engine::GameEngine;
use std::sync::{Arc, Mutex};

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    // ゲームエンジンの初期化
    let game_engine: GameEngineState = Arc::new(Mutex::new(GameEngine::new()));

    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .manage(game_engine)
        .invoke_handler(tauri::generate_handler![
            greet,
            // ゲーム管理API
            api::new_game,
            api::get_game_state,
            // ...
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ちょっと greet というゴミが混ざっていますが、メインは run という関数です。invoke_handler のところで各種APIを登録しているようです。また manage の引数には game_engine が渡されていて、これは少し上で作成されています。きっとここで登録したものが state の引数として渡されるようになっているのでしょう。

ところで、Push 型 API のバックエンド実装はどこでしょうか?ちょっとパッと見ではわからないので、とりあえず gameApi.ts で呼び出されていた listen<...> の仕様を検索してみます。すると、以下の公式のページがヒットしました。
v2.tauri.app
以下が先程のページで紹介されている listen<...> の用法です。どうやら Global Events というタイプの方を使っているみたいです。

import { listen } from '@tauri-apps/api/event';

type DownloadStarted = {
  url: string;
  downloadId: number;
  contentLength: number;
};

listen<DownloadStarted>('download-started', (event) => {
  console.log(
    `downloading ${event.payload.contentLength} bytes from ${event.payload.url}`
  );
});

一方、Rust 側で Global Events を発行するには emit という関数を使えばよいようです。イベントの名前とイベントに含める情報を emit に渡している雰囲気です。

use tauri::{AppHandle, Emitter};

#[tauri::command]
fn download(app: AppHandle, url: String) {
  app.emit("download-started", &url).unwrap();
  for progress in [1, 15, 50, 80, 100] {
    app.emit("download-progress", progress).unwrap();
  }
  app.emit("download-finished", &url).unwrap();
}

ちなみに emit の内部実装を見てみると、Emitter トレイトの関数として以下のように用意されていました。第二引数は Serialize + Clone が要求されていて、第二引数を payload の形式 (json でしょう) に Serialize したものが実際に payload に格納されていそうです。

/// Emit events.
pub trait Emitter<R: Runtime>: sealed::ManagerBase<R> {
  // ...
  fn emit<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> {
    let event = EventName::new(event)?;
    let payload = EmitPayload::Serialize(&payload);
    self.manager().emit(event, payload)
  }
  // ...
}

そう分かった上でもう一度 game_engine.rs を見てみると、たしかに emit を呼び出している場所がありました。app_handle が emit の手段を用意してくれているので、手軽にフロントエンドにイベントを発行できるようです。

// ...
// ダミーAI実装
fn simulate_ai_move(&self, app_handle: &AppHandle, _current_state: GameState) {
    let app_handle = app_handle.clone();
    let state_arc = self.state.clone();

    // 非同期でAIの動作をシミュレート
    std::thread::spawn(move || {
        // ...
        // イベントを発火
        let _ = app_handle.emit(
            "ai_move_completed",
            AiMoveEvent {
                action: ai_action,
                new_state: current_state,
            },
        );
    });
}
// ...

AppHandle がどこで作成されているかは追いきれませんでしたが、まあ知らなくても当面は困らないでしょう。これでAIが生成してくれた中身はだいたい理解できました。

AI生成すごいって話

バックエンドの生成が素晴らしすぎたので、ここで語らせてください。

今回、バックエンドの生成の命令で、AIは以下を作成してくれました。

  • フロントエンドからバックエンドのAPIを呼び出す実装
  • バックエンドでのAPIの実装
  • 実装した概要のドキュメント

もし仮にこれらを手作業で作ろうとしていたとすると、以下のような壁が立ちはだかっていたことでしょう。

  • Tauri の壁
    前のセクションでは、invoke や listen の仕組みを使って連携する方法を確認しました。これらの使い方はたしかに公式ドキュメントに解説がありますが、何も知らない状態から始めた場合、「invoke や listen を使えば実装できる」を特定することから始まります。大抵の場合、これはサンプルコードを頑張って漁ることから始めることになるでしょう。目的に合うピッタリなサンプルコードがあれば超ラッキーですが、基本的には近そうなものをいくつか見比べながら悩むことになります。特定した後も、公式ドキュメントの説明を読みながら、試行錯誤することになります。私は Web 開発の知識が乏しいので、基本的な用語を理解するところも含めると、かなり険しい道のりであることは間違いないです。
  • フロントエンド-バックエンド連携の壁
    なんとか頑張って Rust で用意した処理をフロントエンドから利用できるようになったとしましょう。しかし今度は「どういう風にファイル構成を考えれば利用しやすいのか」という壁が立ちはだかります。例えば Pull 型のAPIであれば、適当に invoke すればどこでも呼び出せはするはずですが、そんなことをしていては収集がつかなくなるのは目に見えています。そのため、比較的管理しやすい状態にするにはどう作ればいいか?と考える作業が追加で必要になります。
  • 実装量の壁
    うまいファイル分割の方法になんとかたどり着き、APIに対応するファイルはフロントエンドとバックエンドで同じ構成にすればよい、と気づいたとしましょう。しかしAPIが提供する機能は手書きすると結構な分量です。一つひとつは大したことはありませんが、ただただ数が多いので、入力がしんどいのは明白です。また人間は数が増えるとミスるので、確認作業もどんどん大きくなるでしょう。

しかし、AIに命令してものの数分で生成してくれたものは、元々作りたかったものに完全に沿った実装で、それなりに整理されたマトモな実装になっているわけです。当然コードの入力はAIが行ってくれているので、人間は入力されるのをただ待てば良いだけです。つまり、AIは先程挙げたすべての壁を見事に破壊して見せたのです。

それだけではありません。生成されたものは、自分にとって Tauri の学習のための世界最高の教材になっているのです。自分で準備した仕様に沿って実装を生成しているので、当然どういう振る舞いを実現しようとするコードかは把握できています。そのため、コードを読むときは「Tauri で目的の振る舞いを実現するにはどう書けばよいのか」というポイントに集中することができます。前のセクションで行っていたのが、まさにそういう作業ですね。

それだけでも神対応なのに、追加した内容を要約したドキュメントまで生成してくれました。もう神超えてゼウスです。ゼウスも神か。

バックエンドの内部実装を与える

雛形が用意できたので、内部実装を与えていきましょう。この部分は書いていて楽しいので、基本的に自分の力で進めていきたいと思います。

編集の対象は game_engine.rs です。以前公開した secondbest クレートを利用して実装を与えていきます。
https://crates.io/crates/secondbest

まずは src-tauri/Cargo.toml の dependencies に secondbest クレートを追加します。最新版の 0.5.0 を使うことにします。

[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
secondbest = "0.5.0" // ←追加

念の為ビルドチェックしましたが、問題なさそうです。

早速実装していきます。Cursor Tab は優秀ですが、Rust を書くときは微妙なことも多いので、今回は切って作業します。コマンドパレットから Disable Cursor Tab を実行して作業スタートです。

ということで、実装しました。game_engine.rs に実装を与え、game.rs の不要な new メソッドを削除しました。
github.com

上記の実装をしていると、API に以下の変更が必要なことが分かりました。

  • TurnPhase の目的が明らかでないので、検討が必要。
  • プレイヤーが後攻だった場合に、ゲーム開始時に伝える手段がない。
  • MoveAction のうち Place に色情報がないので、変換がきれいにできないし、多分フロントエンドの実装で困る。

TurnPhase についてはあとで検討することにして、今はとりあえず定数にしておきます。プレイヤーが後攻の場合についても、後ほど拡張として対応することにして、今はプレイヤーは黒手番固定にしておきます。

MoveAction の Place に Player を増やすのはフロントエンドとバックエンドの両方の変更が必要なので、Cursor に依頼してみます。Agent で以下のように命令します。

@game.rs と @game.ts で定義されている MoveAction で、Place の引数に Player を追加してください。伴って変更が必要なコードが他にあれば、あわせて編集してください。ただし @game_engine.rs の内容は自分で確認するので、変更しないでください。この変更によってビルドエラーが出ても無視してください。

無事変更してくれました。他のファイルには特に変更が必要な場所はないようです。game_engine.rs 内の不自然になっていた実装も改善しました。

そういえば、バックエンドのドキュメントも修正しておいた方がよいですね。命令しておきましょう。

@backend_api.md の内容も修正しておいてください。

無事修正されました。

フロントエンドでバックエンドのAPIを利用するフローを整理する

ここまでで、ボード用のエフェクトと、バックエンドのメソッドが揃いました。これらをうまく組み合わせて、描画とバックエンドとのやり取りを連携させたいです。

自分で考えてフローを整理することもできますが、そこそこちゃんと考える必要がありそうです。ここではあえてAIを使ってやってみましょう。以下のようなややしっかりめのプロンプトを作りました。これを Agent で投げてみます。

# 命令

このアプリは、ボードゲーム Second Best で遊ぶためのアプリである。バックエンド API と連携して Board.tsx にボードを適切に描画し、ゲームを遊べるようにしたい。Board.tsx に与える以下の処理を自然言語でまとめ、docs/ 以下に出力してください。

- 最初の描画
- プレイヤーのターンでの操作の処理フロー
- バックエンドから Push 型の通知が来たときの処理のフロー

# 重要なファイル

- src/components/Board.tsx: ボードの tsx ファイル
- src/lib/gameApi.ts: バックエンドの API を使う関数群
- セカンドベストのルールが記載されたファイル (Web 上)
  https://raw.githubusercontent.com/mat-der-D/secondbest/refs/heads/main/README.md

# Board.tsx で定義されている状態

Board.tsx には以下の状態が定義されている。それぞれいずれかの描画の関数で参照されている。

- **pieces**
  コマの位置を保持する状態。
- **highlightedCells**
  マスを強調する位置を保持する状態。以下の使い方をする:
  - コマを置くことが合法なマスの表示
  - コマを移動させるときの合法な移動先のマスの表示
- **highlightedPieces**
  一番上のコマを強調する位置を保持する状態。以下の使い方をする:
  - コマを移動させるときの合法な移動元の表示
- **liftedPieces**
  コマをすこし浮かせるマスを保持する状態。以下の使い方をする:
  - コマを移動させるとき、移動先を選ぶ前に、移動元のコマを浮かせる
- **clickCount**
  本番では利用しない
- **showSecondBest**
  "Second Best!" という文字を画面中央に表示させるかどうかの状態。以下の使い方をする:
  - プレイヤーまたはコンピューターがセカンドベスト宣言をした際に一定時間表示させる

# その他の条件

- ユーザーがセカンドベスト宣言をするための機能は考慮しないでください。
- 極力新しい描画機能は追加しないでください。

以下が出力されたファイルです。


▼結果をクリックで表示

いろいろと気になるところがあるので修正していきます。Agent で続けて以下を命令します。

出力したファイルに対して以下対応して

  • コマの配置でも動かす場合でも、合法なマスやコマの表示がクリックしてからとなっているが、実際は手番開始時点ではすでにハイライトされている状態になっていてほしい。
  • ハイライトは可能な選択肢をユーザーにヒントとして知らせる機能なので、動作を行った後にハイライトするのでは意味がない。必ず、動作を行う前にハイライトを行うようにしてほしい。
  • 新たに実装が必要な関数の概要も新しい章立てを追加してまとめてほしい。

命令の結果、以下のように編集されました。それっぽい感じになりましたね。


▼結果をクリックで表示

フロントエンドでバックエンドのAPIを利用する

処理のフローが整理されたので、実際に実装を依頼していきます。Board.tsx を開き、Agent で以下のように命令します。なお board-processing-flows.md は先程作ったドキュメントです。

@board-processing-flows.md に基づいて、 @Board.tsx に処理を実装してください。関数のまとめ方等については、既存の実装に倣ってください。

動作確認すると・・・

動作確認

なんだこれ・・・

やっぱり多くのことを一度にお願いすると大変なことになるみたいです。少しずつ確認します。

とりあえずすぐに分かった以下を追加で命令します。

  • return する jsx の部分は変更しないでください。
  • 手番の実行後のボードの状態が更新されていないようです。pieces が正しく更新されているか確認してください。
  • AIがセカンドベスト宣言したあと、操作可能な状態になっていません。確認してください。

1つ目の命令はガン無視されていますが、2つ目と3つ目は解消されました。

動作確認

1つ目の命令をもうすこし詳しめにやり直します。

現在のステータスを表示するようなコンポーネントを return する jsx の部分に追加していますが、余計です。ステータスの表示は現在は不要なので、コンポーネントを追加しないでください。

この後何度かやり取りをして、やっと元に戻してくれました。頑固ですね。。。

また、移動元を選んだ際にコマのハイライトが外れなかったので、それも指摘してみます。

移動の操作の際、移動元を選んだ状態のときはコマのハイライトを切ってください。

いい感じになりました!動作確認が辛かったので、AIの思考時間もこっそり1秒に縮めました。

動作確認

再度リファクタリング

機能が実装されたのは良かったのですが、おびただしい数の関数がコンポーネント内に実装されてしまい、正直カオスな状況になってしまいました。赤信号に近い黄色信号なので、Agent でなんとかならないか聞いてみましょう。

@Board.tsx は以下の問題を含んでいます。

  • ファイル内で定義されている関数の数が多すぎて管理が難しい
  • コンポーネントの定義の中にも多くの関数が定義されており、把握しきれない
  • 様々な役割を持つ関数たちが秩序なく並んでいる

これらの問題を解消するために、適切なファイル分割を実施したいです。適切に関数を移動させて、見通しの良い実装に改善してください。ただし、元の機能を損なわないように慎重に検討してください。

すると、これぐらい分けてくれました。ウワァ・・・

リファクタリング結果

hook の数が多すぎてもはや人間が扱えるサイズではなさそうです。

おびただしい hook たち

うまく行くか分かりませんが、hook の整理を命令してみます。

Board.tsx の先頭の hook たちの初期化処理で引数に渡している関数の数が膨大すぎて、管理できるような状態ではありません。hook たちの役割を整理して、管理しやすい形にリファクタしてください。

多少よくなりましたが、まだ useGameState が巨大すぎて扱いづらいです。

useGameState が多くの役割を持ちすぎているように思います。useGameState の役割を分析し、役割ごとに定義を分割してください。

hooks が分解されて、それなりに良くなってきました。でもまだです。

@useCanvasInteraction.ts や @useGameEvents.ts では、引数からおびただしい量の hook を受け取っています。これらの一部はローカル関数で定義しても問題ないものも含まれているのではないかと思っています。この観点から@useCanvasInteraction.ts @useGameEvents.ts および @useGameState.ts の実装を見直して整理してください。hook のやり取りの量が適正なサイズに圧縮されることを期待しています。

まだまだ。

@useBoardController.ts と @useGameState.ts の役割が重複しています。 @useGameState.ts の実装を @useBoardController.ts に移動させてください。

まだいけます。

@useBoardController.ts について、以下の変更を行ってください。

  • useBoardController が呼び出されている箇所を確認し、必要最小限の戻り値を設定してください。
  • gameState という変数を廃止してください。gameState の要素にアクセスしている箇所については、元々その要素が定義された箇所に直接参照するように変更してください。

だいたい落ち着いてきました。最後にドキュメントを生成させましょう。

src/components/board/hooks に含まれる各 hook たちの情報をまとめた Markdown 形式のドキュメントを作成し、 docs/ 以下に出力してください。ドキュメントには以下を含めてください。

  • 各 hook の役割
  • 各 hook の引数と戻り値
  • hook 同士の依存関係

さらに追加注文。

@board-hooks-architecture.md にもっとコンパクトな一覧 (各 hook を一言で表したものの箇条書き) を追記してください。また依存関係の図で、useBoardController 以下にあるものたちが並列にかかれていますが、実際にはその間にも依存関係があるはずなので、それもうまく表現してください。

ドキュメントに生成された依存関係がこちらです。

hook たちの依存関係

こんなにも複雑なものを1ファイルに押し込んでいたのか・・・恐ろしや・・・

最終的にはこんな構成になりました。ドキュメントと組み合わせれば、きっとなんとか管理できるでしょう。

最終状態

第八回まとめ

今回はバックエンドの実装とフロントエンドとの連携を一気に行いました。セカンドベスト宣言はまだ実装されていませんが、それ以外は一応ルールに従って動くようになってきました。

今回までの変更文は v8-backend というタグで push してあります。
github.com

次回は Board コンポーネントの外にあるコンポーネントとの連携部分を実装していきたいと思います。特に、セカンドベストボタンが実装できれば完全にルール通りに遊べるようになりますね。楽しみです。

ではまた。

続き↓
smooth-pudding.hatenablog.com




以上の内容はhttps://smooth-pudding.hatenablog.com/entry/2025/05/31/190512より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14