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


【AI×Tauri】第九回:ボタンとボードを連携させる

前回はバックエンドの実装を行い、フロントエンドと連携させました。かなりセカンドベストらしい振る舞いが実現できるようになりました。
smooth-pudding.hatenablog.com

しかし、前回の時点ではまだプレイヤーはセカンドベスト宣言できない状態でした。セカンドベストボタンに機能を実装していなかったからです。

今回はセカンドベスト宣言ボタンとリセットボタンがボードと連携するための実装を行っていきたいと思います。

方針

複数のコンポーネントの間でやり取りをするには、リフトアップというものを行うのが標準的なやりかたのようです。
ja.react.dev

以下は現在の状況を表す模式図です。ボードの状態はボードの中で作成・運用されていて、部外者のボタンにとっては全くなにもわからない状態になっています。

ボードの中身はボタンにはわからない

そこで、状態とそれを変化させる setter を作る場所を共通の親コンポーネントに移動させます。ここで setter の方をボタンに渡してしまえば、ボタン側からボードの状態を変化させられるようになります。

リフトアップ

今回のケースでは、Board コンポーネントは GamePage 内に配置されていて、セカンドベストボタンもリセットボタンも同じく GamePage 内に配置されています。そこで、Board の状態を GamePage にリフトアップするところから始めます。その後、必要なコールバックを用意して各ボタンに割り当てるようにします。

ボタンの状態をリフトアップする

Board.tsx を確認してみると、現在 useBoardControl という hook を使って必要な状態を生成しています*1

  // ボード全体の状態とロジックを統合管理
  const {
    pieces,
    highlightedCells,
    highlightedPieces,
    liftedPieces,
    showSecondBest,
    errorMessage,
    initializeGame,
    handleCanvasClick,
  } = useBoardController();

そこで Cursor の Agent に依頼してみましょう。components/Board.tsx と pages/GamePage.tsx を参照した状態で、以下のように命令します。

Board の状態を別のボタンから操作できるようにしたいです。Board 内の useBoardController() の処理を GamePage にリフトアップしてください。

すると安全にリフトアップを実行してくれました。ところが、気を利かせてボードを動かす用のボタンの新規配置や、そのための機能の実装を始めてしまいました。それは目的と異なるので、stop ボタンで停止し、以下のように知らせました。

ボタンの追加はすでにしてありますし、機能の実装依頼はこれから行います。余計なボタンを追加しないでください。

なんとか止めてくれました。

Board コンポーネントの入口部分が以下のように変更されました。外から状態を受け取る形に変化しています。

// Boardコンポーネントのpropsインターフェース
interface BoardProps {
  pieces: Piece[];
  highlightedCells: number[];
  highlightedPieces: number[];
  liftedPieces: number[];
  showSecondBest: boolean;
  errorMessage: string;
  onCanvasClick: (event: React.MouseEvent<HTMLCanvasElement>) => Promise<void>;
  onInitializeGame: () => Promise<void>;
}

// メインコンポーネント
const Board: React.FC<BoardProps> = ({
  pieces,
  highlightedCells,
  highlightedPieces,
  liftedPieces,
  showSecondBest,
  errorMessage,
  onCanvasClick,
  onInitializeGame,
}) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const images = useImageLoader();

  // ゲーム初期化
  useEffect(() => {
    onInitializeGame();
  }, [onInitializeGame]);

// ...

GamePage.tsx は以下のように変更されました。リフレッシュボタンには勝手に initializeGame を割り当てられました。今回はやる気に満ちあふれてやや暴走気味だった感じですね。

const GamePage: React.FC<GamePageProps> = ({ onHomeClick }) => {
  // ボードの状態をGamePageで管理
  const boardController = useBoardController();

  return (
    <div className="game-page">
      <div className="game-header">
        <div className="game-buttons">
          <button className="home-button" onClick={onHomeClick}>
            🏠
          </button>
          <button className="refresh-button" onClick={boardController.initializeGame}>
            🔄
          </button>
        </div>
      </div>
      
      <div className="game-content">
        <SecondBestButton />
        <Board 
          pieces={boardController.pieces}
          highlightedCells={boardController.highlightedCells}
          highlightedPieces={boardController.highlightedPieces}
          liftedPieces={boardController.liftedPieces}
          showSecondBest={boardController.showSecondBest}
          errorMessage={boardController.errorMessage}
          onCanvasClick={boardController.handleCanvasClick}
          onInitializeGame={boardController.initializeGame}
        />
        <StatusDisplay />
      </div>
    </div>
  );
};

リフレッシュボタンに機能を与える

initializeGame はリフレッシュボタンの挙動としては確かに initializeGame が近いのですが、機能として以下が足りません。

  • セカンドベストの表示を消す
  • AIの返答を待っている間は許可しない

前回作った hook のドキュメントを眺めると、以下が分かります。

  • セカンドベストの表示を消す → useUIState が作る setShowSecondBest で操作可能
  • AIの返答を待っているかどうかを判定 → useUserInteraction が作る userInteractionEnabled を見ればOK

useBoardController がこれらを返却してくれれば、GamePage.tsx でボタンに渡す用の関数オブジェクトが作れそうです。そこで Cursor に依頼します。すこしノリノリの暴走気味なので、控えめにするような命令にしてみます。

useBoardController から setShowSecondBest (useUIState で作るもの) と userInteractionEnabled (useUserInteraction で作るもの) を追加で返すようにしてください。それ以外は何もしないでください。

正しく対応してくれました。「何もしないでください」がめちゃくちゃ強力なのか、口数も減りました。おもろ。

AIの返答

準備が整ったので、ボタンをクリックしたときの動作 onRefreshClick を実装し、ボタンに割り当てます。

  const onRefreshClick = () => {
    if (!boardController.userInteractionEnabled) return;
    boardController.setShowSecondBest(false);
    boardController.initializeGame();
  };

  // ...
          <button className="refresh-button" onClick={onRefreshClick}>
            🔄
          </button>
  // ...

これでリセットできるようになりました。

セカンドベストボタンの機能を実装する。

いよいよセカンドベストボタンの機能を実装しましょう。セカンドベスト宣言の流れは以下のとおりです。

  • セカンドベスト宣言可能か調べる
  • セカンドベスト宣言をする、とバックエンドに伝える
  • 更新した状態が返ってくるので、ボードに反映させる
  • セカンドベストのラベルを表示する

この中で、一番最初の状態だけが定義されていません。まずはこれを追加しましょう。

hook のドキュメントを再度眺めると、useGameCore 内で追加するのがよさそうだと分かります。幸い、ゲームの状態から各生の状態を更新する処理は updateBoardFromGameState 関数に集約されていたので、ここだけ編集すればよさそうです。

まず先頭の useState が並んでいるところに以下を追加します。

  const [canDeclareSecondBest, setCanDeclareSecondBest] = useState<boolean>(true);

次に updateBoardFromGameState 内に以下を記述します。

    setCanDeclareSecondBest(newGameState.second_best_available);

外部で利用するのは「セカンドベスト宣言可能か」という情報のみなので、状態だけを return に追加します。

  return {
    // ..
    canDeclareSecondBest,    
    // ...
  };

さらに useBoardController もこの状態を追加で return するようにします。

  return {
    // ...
    canDeclareSecondBest: gameCore.canDeclareSecondBest,
    // ...
  };

ところで、セカンドベストの表示は現在バックエンドからの通知に基づいて表示する一箇所で実装されています。そこでは setShowSecondBest を使って以下のように実装されています。

   setShowSecondBest(true);
    setTimeout(() => setShowSecondBest(false), 2000);

ただ、実際のところ以下の2つのケースしか利用ケースがありません。

  • 一定時間だけ表示して消す
  • あるかもしれない表示を消す

であれば、setShowSecondBest を公開せずに

  • 引数で与えられた時間だけ表示して消す関数
  • 表示されているかどうかに関わらず、表示を消す関数

を公開すれば十分そうですね。ついでに showSecondBest という状態の名前がややこしいので、変更しましょう。

これらが実装されているのは useUIState です。まずは useUIState の中で定義されている状態とセットする関数の名前を以下のように変更します。

  • showSecondBest → isSecondBestShown
  • setShowSecondBest → setIsSecondBestShown

関連する箇所もすべてリネームしておきます。

次に useUIState 内に以下を定義します。同じ場所に定義されている関数と同じような書き方に揃えました。

  // セカンドベストを表示する関数
  const showSecondBest = useCallback(() => {
    setIsSecondBestShown(true);
    setTimeout(() => setIsSecondBestShown(false));
  }, []);

  // セカンドベストを非表示にする関数
  const clearSecondBest = useCallback(() => {
    setIsSecondBestShown(false);
  }, []);

その上で、setIsSecondBestShown を return 対象から外し、上記2つを return に含めます。

そうすると、元々 setIsSecondBestShown を使っていた場所からエラーが出てきます。それぞれ確認して修正します。

1つ目は useGameEvents です。この中では showSecondBest しか使わないので、setIsSecondBestShown を引数から消して、代わりに showSecondBest を与えるようにします。この関数を使っている場所も忘れずに修正します。

2つ目はちょうど先程追加したリフレッシュボタンの処理です。これは useBoardController の戻り値経由で渡しています。この目的だけであれば clearSecondBest のみ返せばよいですが、どっちみちセカンドベストボタンの実装で showSecondBest の方も使うので、両方 return に追加しておきます。合わせて onRefreshClick も修正します。

  const onRefreshClick = () => {
    if (!boardController.userInteractionEnabled) return;
    boardController.clearSecondBest();
    boardController.initializeGame();
  };

さて、本来の目的のセカンドベストボタンの実装に行きましょう。components/SecondBestButton.tsx を開き、interface と書くと・・・

interface SecondBestButtonProps

どんどんサジェストされて、あれよあれよという間に以下が完成しました。「まーるかいてフォイ」ですね。一応、途中で違う方向に行きそうなタイミングでは自分でちょろっと書き足したりしましたが、基本的に Tab のなすがままです。Cursor Tab は本当に優秀です。

import React from 'react';
import './SecondBestButton.css';
import { GameAPI } from '../lib/gameApi';

interface SecondBestButtonProps {
  userInteractionEnabled: boolean;
  canDeclareSecondBest: boolean;
  updateBoardFromGameState: (gameState: any) => void;
}

const SecondBestButton: React.FC<SecondBestButtonProps> = ({ userInteractionEnabled, canDeclareSecondBest, updateBoardFromGameState }) => {

  const onClick = async () => {
    if (!userInteractionEnabled) return;
    if (!canDeclareSecondBest) return;
    const newState = await GameAPI.declareSecondBest();
    updateBoardFromGameState(newState);
  }

  return (
    <button className="second-best-button" onClick={onClick}>
      Second Best!
    </button>
  );
};

export default SecondBestButton; 

GamePage.tsx に戻ると、また Cursor Tab であれよあれよと以下に編集されました。

<SecondBestButton 
  userInteractionEnabled={boardController.userInteractionEnabled}
  canDeclareSecondBest={boardController.canDeclareSecondBest}
  updateBoardFromGameState={boardController.updateBoardFromGameState}
/>

最後の updateBoardFromGameState はまだ公開されていないようで、赤い波線が引かれていました。「もしや・・・」と思い、useGameBoardController.tsx に移動してみると、やっぱり Tab に導かれるまま return に項目が追加されてしまいました。恐ろしい子

ところで、セカンドベストの宣言後の処理は手番の手を実行した後の処理が近そうです。そこで useCanvasInteraction の中のコマを置く関数を見てみます。

const executePlaceAction = useCallback(async (position: any, player: any) => {
  const placeAction: MoveAction = {
    Place: { position, player }
  };
  
  console.log('配置アクションを実行:', placeAction);
  const newGameState = await GameAPI.makeMove(placeAction);
  console.log('配置後の新しいゲーム状態:', newGameState);
  
  updateBoardFromGameState(newGameState);
  clearAllHighlights();
  setUserInteractionEnabled(false); // AI の手番まで無効化
}, [updateBoardFromGameState, clearAllHighlights, setUserInteractionEnabled]);

どうやら最後に setUserInteraction の処理が必要そうです。これもさっきと同じノリで Tab でどんどん追加しておきます。

動作確認していたら、うっかり showSecondBest を足し忘れていることに気づきました。ですがやはり同じノリで修正は簡単でした。すごい時代ですね。以下は、ちゃんと表示するようになった状態です。

動作確認

表示時間は定数で管理しておきたいので、constants.ts に以下のように追加しておきます。対応する箇所もこれを使うように変更しておきました。

export const BOARD_CONSTANTS = {
  CANVAS_WIDTH: 350,
  CANVAS_HEIGHT: 350,
  PIECE_WIDTH: 50,
  CELL_WIDTH: 70,
  PIECE_LIFT_OFFSET_RATIO: 0.2,
  SECOND_BEST_DURATION: 2000, // 追加
} as const;

これでついに通常のセカンドベストは遊べるようになりました!

第九回まとめ

今回はボード以外のボタンを使った動作の実装を行っていきました。状態を剥がして親に動かすのって大変そう・・・と想像していましたが、思った以上に簡単に実現できてしまいました。これもロジックと見た目を分離する React の威力なのかもしれません。

今回までの分は v9-buttons というタグをつけて push しておきました。
github.com

次回はステータス表示の部分を対応していきたいと思います。

ではまた。




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

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