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


【AI×Tauri】第四回:ボードのフロントエンド部分を作る Part 1

前回は “ハリボテ仕様”、つまり最小限のフロント実装を与えるところまで進めました。
smooth-pudding.hatenablog.com
今回はいよいよボード部分に踏み込んでいきたいと思います。

ボード部分の実装の方針

まずはボードにそれっぽい見た目を付与するところまでを目指します。バックエンドとの連携を含めた実装は一旦脇に置いておきます。
第二回の記事で調べたように、ボードの表現はキャンバス上への画像の配置を利用していきます。
ただ、繰り返しになりますが、私自身 React のド初心者なので、いきなりボードの完成形を目指すと辛そうです。その代わりに、キャンバス上に画像を配置する機能に少しずつ理解を深めながら、少しずつそれっぽくしていけたらと思っています。

画像の素材の準備

今回の記事の範囲のために、まず以下を用意します。

  • コマの画像 (白と黒の各1枚)
  • ボードの床部分のマーク

手で描くときれいに重なるように描くのが大変なので、Python の matplotlib を使ってプロットするようにしてみました。コードは適当にプロンプトをぶつけまくって Cursor で書かせました。

色は以下を使いました。

  • 線: #3E2723
  • 白: #FFFFFF
  • 黒: #616161
    ↑線と見分けがつくように少し薄めにしました

まずボードから。

ボードの画像


▼ボード描画コードをクリックで表示


次にコマ。

コマの画像


▼コマ描画コードをクリックで表示

作成した board.svg, piece_white.svg, piece_black.svg は Tauri の assets ディレクトリ以下に配置します。

クリックした場所にコマを置くようにする

まずは手始めに、クリックした場所にコマを置く機能を実装してみます。

components/Board.tsx と components/Board.css を開いて、Cursor に以下のように命令します。

@Board.tsx @Board.css に対し、以下の仕組みになるように実装変更して。

  • 最初は白いキャンバスにする
  • キャンバス上をクリックすると @piece_white.svg を配置する

起動して動作確認すると、問題なく動きました!

クリックで白いコマを配置

コードの内容を確認してみましょう。


▼.tsx と .css をクリックで表示
順番に読み解いて行きます。まず .tsx の末尾の部分を見てみると、canvas の onClick に対して handleCanvasClick という関数を割り当てていることが分かります。

  return (
    <div className="board-container">
      <canvas 
        ref={canvasRef}
        className="board-canvas"
        width={300}
        height={200}
        onClick={handleCanvasClick}
      />
    </div>
  );

すぐ上で handleCanvasClick が定義されています。MouseEvent から canvas 上の座標を計算して x, y としています。さらに setPieces を呼んで、コマの状態を更新しているようです。

  const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
    const canvas = canvasRef.current;
    if (canvas) {
      const rect = canvas.getBoundingClientRect();
      const x = event.clientX - rect.left;
      const y = event.clientY - rect.top;
      
      // 新しい駒を追加
      setPieces(prevPieces => [...prevPieces, { x, y }]);
    }
  };

setPieces は Board の定義の先頭で useState を使って定義されています。

  const [pieces, setPieces] = useState<Piece[]>([]);

Piece はさらに上で interface として定義されています。

interface Piece {
  x: number;
  y: number;
}

handleCnvasClick の最後の部分は、x, y のみを持つ Piece のリストを状態として持つ pieces に対し、その末尾に新しい点を付け加える処理だったということが分かりました。

残りは2つの useEffect です。第二回の記事で勉強したように、1つ目は最初に一回だけ走る処理、2つ目は pieces または pieceImage が変更されるたびに走る処理です。

  useEffect(() => {
    // (中略)
  }, []);

  useEffect(() => {
    // (中略)
  }, [pieces, pieceImage]);

この pieceImage も先頭で useState で定義されています。画像を状態として持つ変数 (ない場合は null) のようです。初期値は null になっていますね。

  const [pieceImage, setPieceImage] = useState<HTMLImageElement | null>(null);

最初に走る方の useEffect の実装を確認してみます。Image 型のオブジェクトを作成し、img.onload に自分自身を setPieceImage に渡す関数を登録しています。その後 img.src に画像の URL を登録しています。onload は img.src に代入するタイミングで発火するのでしょうか?

  useEffect(() => {
    // SVG画像を読み込む
    const img = new Image();
    img.onload = () => {
      setPieceImage(img);
    };
    // SVGをData URLに変換して読み込む
    img.src = '/src/assets/piece_white.svg';
  }, []);

軽く検索したところ、このような記事がヒットしました。この記事の内容が正しければ、onload が呼ばれるのはもう少し先かもしれません。ともかく、どこかのタイミングで画像が読み込まれて、そのタイミングに pieceImage が更新されそうです。
qiita.com
なお、この記事では onload ではなく decode を使うのを推奨しているようですが、初心者の自分にとっては勇み足かもしれないので、一旦そのままにしておきます。

ここまでを踏まえると、画像が読み込まれたときと、クリックされたときに、2つ目の useEffect が走ることが分かりました。では2つ目の useEffect の中身を見てみます。

  const canvasRef = useRef<HTMLCanvasElement>(null);
  // (中略)
  useEffect(() => {
    const canvas = canvasRef.current;
    if (canvas && pieceImage) {
      const ctx = canvas.getContext('2d');
      if (ctx) {
        // キャンバスを白い背景でクリア
        ctx.fillStyle = '#FFFFFF';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        
        // 配置された駒を描画
        pieces.forEach(piece => {
          const pieceSize = 40; // 駒のサイズ
          ctx.drawImage(
            pieceImage,
            piece.x - pieceSize / 2,
            piece.y - pieceSize / 2,
            pieceSize,
            pieceSize
          );
        });
      }
    }
  }, [pieces, pieceImage]);

useRef で取り出した canvasRef に対して canvasRef.current で canvas を取り出します。この部分の理解は難しそうなので、一旦「そういうもの」として飲み込んでおきます。

次に canvas.getContext('2d') が呼ばれています。getContext は以下のページに説明があります。見た目まんまですが、2次元描画のコンテキスト、というものが返ってくるようです。これを ctx に束縛しています。
developer.mozilla.org

次に ctx.fillStyle = (白) および ctx.fillRect(キャンバスのサイズ) を呼んでいます。全体を白い四角で塗りつぶす処理でしょう。

その後、pieces.forEach で、各コマに対する処理が書かれています。コマの画像サイズ pieceSize を用いて ctx.drawImage を呼んでいるようです。まさにコマを描画する処理ですね。drawImage は以下に説明があります。
developer.mozilla.org
今回は引数が5つのケースなので、以下の解釈になるようです。つまり、クリックした位置が中心になるように、縦横それぞれ40の正方形で画像を貼り付けています。

ctx.drawImage(
  pieceImage, // 画像の要素
  piece.x - pieceSize / 2, // 画像の左上のx座標
  piece.y - pieceSize / 2, // 画像の左上のy座標
  pieceSize, // 画像の幅
  pieceSize // 画像の高さ
);

CSS の方も見てみます。board-canvascanvas 本体で、board-container は board-canvas を含む div 要素です。

.board-container {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: 20px 0;
}

.board-canvas {
  border: 3px solid #3E2723;
  border-radius: 8px;
  background-color: #FFFFFF;
  box-shadow: 0 4px 8px rgba(62, 39, 35, 0.2);
  cursor: pointer;
} 

各項目の役割を一言でコメントとして追記してもらってみます。CSSのファイル全体を選択し、Ctrl+K で出た窓に以下を命令します。

各行の末尾に、その変数が指すものを表す短いコメントをつけて。

するとこうなりました。もう説明なしでわかりやすいですね。

.board-container {
  display: flex; /* フレックスボックスレイアウトを使用 */
  justify-content: center; /* 水平方向中央揃え */
  align-items: center; /* 垂直方向中央揃え */
  margin: 20px 0; /* 上下のマージン */
}

.board-canvas {
  border: 3px solid #3E2723; /* 茶色の枠線 */
  border-radius: 8px; /* 角を丸くする */
  background-color: #FFFFFF; /* 白い背景 */
  box-shadow: 0 4px 8px rgba(62, 39, 35, 0.2); /* 影の効果 */
  cursor: pointer; /* マウスポインタをポインターに変更 */
} 

背景をボードの模様にしてみてもらいましょう。Agent で以下のように命令します。

@Board.tsx や @Board.css を編集して、白背景ではなく、 @board.svg を背景にするようにしてください。

その結果以下のようになりました。


▼編集後のコードをクリックで表示
変更点は以下のとおりです。

  • 画像の状態が pieceImg と boardImg の2つになった
  • 最初の処理のところで2枚の画像を読み込むようになった
  • canvas 描画の最初の処理でボードの画像を配置するようになった
  • board-canvasCSSの背景色が透明になった

起動時の見た目はこんな感じです。

背景をボードに変更

いい雰囲気ですが、縦横比が歪んでいて、コマの形にピッタリ合わなくなっています。そこで、追加で以下のように命令します。

piece_white.svg および board.svg はいずれも元の画像の縦横比を保存するようにしてください。

するとそれなりにいい感じになりました。

縦横比を保存して貼り付け

編集されたのは Board.tsx のみで、ボードの描画部分とコマの描画部分が変更されました。まずボードは以下のとおり。

// ボードの背景画像を縦横比を保持して描画
const boardAspectRatio = boardImage.width / boardImage.height;
const canvasAspectRatio = canvas.width / canvas.height;

let drawWidth, drawHeight, offsetX, offsetY;

if (boardAspectRatio > canvasAspectRatio) {
  // 画像の方が横長の場合、幅に合わせる
  drawWidth = canvas.width;
  drawHeight = canvas.width / boardAspectRatio;
  offsetX = 0;
  offsetY = (canvas.height - drawHeight) / 2;
} else {
  // 画像の方が縦長の場合、高さに合わせる
  drawHeight = canvas.height;
  drawWidth = canvas.height * boardAspectRatio;
  offsetX = (canvas.width - drawWidth) / 2;
  offsetY = 0;
}

ctx.drawImage(boardImage, offsetX, offsetY, drawWidth, drawHeight);

そしてコマの方は以下のとおりです。

const pieceSize = 40; // 駒のサイズ
const pieceAspectRatio = pieceImage.width / pieceImage.height;

let pieceWidth, pieceHeight;
if (pieceAspectRatio > 1) {
  // 横長の場合
  pieceWidth = pieceSize;
  pieceHeight = pieceSize / pieceAspectRatio;
} else {
  // 縦長の場合
  pieceHeight = pieceSize;
  pieceWidth = pieceSize * pieceAspectRatio;
}

ctx.drawImage(
  pieceImage,
  piece.x - pieceWidth / 2,
  piece.y - pieceHeight / 2,
  pieceWidth,
  pieceHeight
);

「たしかにうまくいきそうだけど、もうちょいいい書き方あるでしょ」なコードです。話を簡単にするために、幅に合わせてサイズを調整する仕様にします。まず以下の補助関数を定義しておきます。

const adjustSize = (originalWidth: number, originalHeight: number, targetWidth: number) => {
  const aspectRatio = originalHeight / originalWidth;
  return {
    width: targetWidth,
    height: targetWidth * aspectRatio,
  };
}

ボードを描画する箇所を選択して、Ctrl+K から以下のように命令します。

adjustSize 関数を用いてサイズ調整してください。targetWidth はキャンバスの幅にするようにしてください。

するとこうなりました。めちゃくちゃシンプルになりましたね。

// ボードの背景画像を縦横比を保持して描画
const { width: drawWidth, height: drawHeight } = adjustSize(
  boardImage.width,
  boardImage.height,
  canvas.width
);

// キャンバスの中央に配置するためのオフセットを計算
const offsetX = (canvas.width - drawWidth) / 2;
const offsetY = (canvas.height - drawHeight) / 2;

ctx.drawImage(boardImage, offsetX, offsetY, drawWidth, drawHeight);

コマの方も同様に命令します。

adjustSize 関数を用いてサイズ調整してください。const pieceWidth = 40 とし、これを targetWidth として利用してください。

するとこうなりました。こちらもシンプルになりました。

const pieceWidth = 40; // 駒のサイズの幅

// adjustSize関数を使って駒のサイズを調整
const { width: drawPieceWidth, height: drawPieceHeight } = adjustSize(
  pieceImage.width,
  pieceImage.height,
  pieceWidth
);

ctx.drawImage(
  pieceImage,
  piece.x - drawPieceWidth / 2,
  piece.y - drawPieceHeight / 2,
  drawPieceWidth,
  drawPieceHeight
);

実行した様子はこんな感じです。

実装変更後

サイズを微調整するために、以下の3つを Board の定義の関数の先頭に定義し、対応する箇所でこの変数を使うようにしました。

  const canvasWidth = 350;
  const canvasHeight = 350;
  const pieceWidth = 50;

結果はこんな感じです。よくなってきましたね。

サイズ微調整後

最後にセカンドベストボタンとステータス表示の位置を微調整します。pages/BoardPage.tsx を覗いてみると、game-content が3つのコンポーネントをまとめる div 要素であることが分かります。

<div className="game-content">
  <SecondBestButton />
  <Board />
  <StatusDisplay />
</div>

そこで pages/BoardPage.css の .game-content のところを見てみます。

.game-content {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 30px;
  padding: 20px;
  height: 100vh;
} 

gap のところで隙間を開けていそうなので、これを削除すると、いい感じになりました。

コンポーネントの隙間を調整

第四回まとめ

ボードの実装の助走として、キャンバス上のクリックした場所にコマの画像を配置する機能を実装しました。ゲームの実装にはまだまだ距離がありますが、少しずつ雰囲気が出てきて楽しいです。技術的な側面で見ても、画像を配置する処理を追いかけることで、少しだけ React と仲良く慣れたような気がします。

今回までで実装した部分は v4-piece-anywhere というタグをつけて push してあります。
github.com

次回はコマの配置する位置を8つの丸の上に固定する仕組みの実装を目指していきたいと思います。

ではまた。

続き↓
smooth-pudding.hatenablog.com




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

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