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


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

前回は8箇所のマスの部分にクリックの当たり判定を作る実装を行いました。
smooth-pudding.hatenablog.com

今回は「コマを置く」「コマを動かす」「セカンドベスト宣言をする」といったときのエフェクトを作っていきたいと思います。

AIにエフェクトの案を尋ねてみる

コマを置いたり動かしたりするときに見せるエフェクトをいろいろ考えていたのですが、どうもうまく決めきれないので、AIにアイディアを聞いてみます。今回作りかけのスクリーンショットを使って、ChatGPT に以下のように質問します。

添付画像のようなゲームのアプリを作成しています。円柱形のコマがあり、8箇所のマスにそれぞれ積まれています。このゲームでは、以下の二種類の操作のいずれかを実行します。ただし、状況により一方のみが可能で、2つが同時に可能になるわけではありません。
1. 積まれているコマまたは空いているマスの上に新しいコマを置く
2. 積まれているコマの一番上のコマを別の場所に移動させる

ユーザーがこれらの操作を直観的に実行できるようにするためのUIを考えていて、アイディアが欲しいです。制約は以下の通りです。

  • ユーザーができる操作はクリックのみで、ドラッグはしない
  • アニメーションは利用しない

この条件の下で、ユーザーを誘導するための案をいくつか挙げてください。

添付した画像

すると以下のように返ってきました。

ユーザー操作をクリックのみに限定し、アニメーションなしで直感的に操作できるUIを考えるにあたって、以下のような視覚的誘導と状態管理の工夫が効果的です。以下に具体的なアイディアをいくつか挙げます:

1. 操作可能なマス・コマのハイライト
2. 段階的な操作状態表示 (2段階UI)
3. クリック対象ごとのカーソル変更 or 視覚反応
4. 操作不能時の明示的な禁止表現
5. 最近の操作履歴の表示
まとめ

なるほど。マスやコマの周りの線の色を変えるという方法があるんですね。ペイントツールを使って、試しに枠を赤く塗ってみました。洗練されている感じはしませんが、とりあえずわかりやすそうです。

マスやコマの周りを赤くしてみた

また「浮かび上がって見える」というアイディアもあります。これで思いついたのですが、コマを動かすとき、動かすコマを選んだら、そのコマを少し浮かせる手がありそうです。浮かしているコマをクリックすると戻りそうに見えるので、ちょうど良さそうです。

整理すると、以下の効果を実装していこうと思います。

  1. コマを置く
    コマを置ける先のマスを太い赤枠で囲む。クリックするとコマが配置される。
  2. コマを動かす
    1. 動かせるコマの枠を赤色にする。いずれかをクリックすると、そのコマが宙に浮く。
    2. 動かす先のマスを太い赤枠で囲む。クリックするとそこにコマが移動する。宙に浮いたコマをクリックすると元に戻る。

追加の素材を用意する

先程の効果を実現するためには、追加で以下の2つの素材が必要です。

  • コマの赤い枠
  • マスの赤い枠

まずコマの赤い枠は以前にコマを作成したときの Python スクリプト (第三回参照) の線の色・太さと塗りつぶしの色を編集すれば作成できます。マスの赤い枠については、上記スクリプトをコピーした上で、全体選択→Ctrl+K のあと以下のように命令しました。

円柱の上面だけを中央に表示するようにして。ただし各種パラメーター設定は変更しないこと。

以上の操作で以下が作成できました。

赤い枠

それぞれ piece_frame.svg, cell_frame.svg として、プロジェクトの src/assets ディレクトリ以下に保存します。

枠を配置する状態を実装する

描画の順序を整理しましょう。これまでは以下の順序で描画していました。

  1. ボードの画像を描画する
  2. コマを描画する

もし枠を表示するのであれば、以下の順序で描画することになるでしょう。

  1. ボードの画像を描画する
  2. マスの赤い枠を描画する
  3. コマを描画する
  4. コマの赤い枠を描画する

これをそれぞれ実装していきます。

下準備として、とりあえず2つの画像を読み込んでおきましょう。Board.tsx を開き、コマやボードの画像を読み込んでいるあたりを選択して、Ctrl+K で命令します。

新たに @piece_frame.svg と @cell_frame.svg を読み込むようにして

すると以下が追加されました。

const [pieceFrameImage, setPieceFrameImage] = useState<HTMLImageElement | null>(null);
const [cellFrameImage, setCellFrameImage] = useState<HTMLImageElement | null>(null);

初期化の処理を書いている useEffect のところを選択して

pieceFrameImage と cellFrameImage の初期化処理を追加して

と命令すれば、以下も追加してくれました。

// コマのフレーム画像を読み込む
const pieceFrameImg = new Image();
pieceFrameImg.onload = () => {
  setPieceFrameImage(pieceFrameImg);
};
pieceFrameImg.src = '/src/assets/piece_frame.svg';

// セルのフレーム画像を読み込む
const cellFrameImg = new Image();
cellFrameImg.onload = () => {
  setCellFrameImage(cellFrameImg);
};
cellFrameImg.src = '/src/assets/cell_frame.svg';

それぞれの枠の描画処理を実装していきましょう。

マスの赤い枠を描画の実装

追加で以下が必要そうです。

  • 赤いマスを描画する位置 posIndex の集まりを保持する state
  • 上記の状態を元にマスの赤い枠を描画する処理

すでに整理したとおり、この処理はボードの描画とコマの描画の間に行う必要があります。

以上を命令しましょう。コード全体を選択して Ctrl+K を開き、以下を命令します。

@cell_frame.svg を適切な場所に描画する機能を追加実装してください。具体的には、以下を実行してください。

  • 赤いマスを描画する位置 posIndex の集まりを保持する state の追加
  • 上記の状態を元にマスの赤い枠を描画する処理の追加

なお、描画の実装の際は以下に注意してください。

  • 描画の中心は calcPosCenter を用いて計算してください。
  • 幅については、新たな定数 cellWidth を導入して用いてください。
  • 描画順は「ボード→マスの枠→コマ」にしてください。

すると以下を編集してくれました。

state の導入

const [highlightedCells, setHighlightedCells] = useState<number[]>([]); // 赤いマスを表示する位置

定数の導入

const cellWidth = 70; // マスの幅を定義

描画処理の条件の変更

  useEffect(() => {
    const canvas = canvasRef.current;
    if (canvas && pieceImageWhite && pieceImageBlack && boardImage && cellFrameImage) {

    // (中略)

  }, [pieces, pieceImageWhite, pieceImageBlack, boardImage, cellFrameImage, highlightedCells]);

描画処理の実装 (ボードとコマの間)

// ハイライトされたセルを描画
highlightedCells.forEach(posIndex => {
  const { x, y } = calcPosCenter(canvas, posIndex);
  const { width: drawCellWidth, height: drawCellHeight } = adjustSize(
    cellFrameImage.width,
    cellFrameImage.height,
    cellWidth
  );
  
  ctx.drawImage(
    cellFrameImage,
    x - drawCellWidth / 2,
    y - drawCellHeight / 2,
    drawCellWidth,
    drawCellHeight
  );
});

動作確認用の処理

// ↓初期化の処理の末尾に追加
// 初期状態で特定のセルをハイライト(テスト用)
setHighlightedCells([0, 3, 6]);
// ↓クリックイベントの関数内に追加
// クリックしたセルをハイライトする配列を更新(トグル)
setHighlightedCells(prevCells => {
  if (prevCells.includes(clickedPosIndex)) {
    return prevCells.filter(index => index !== clickedPosIndex);
  } else {
    return [...prevCells, clickedPosIndex];
  }
});

コード内容は問題なさそうです。動作確認も良好です。

動作確認

コマの赤い枠の描画の実装

コマの描画はマスの描画とよく似ていますが、以下に注意が必要です。

  • コマの枠の描画位置は、その場所の一番上にあるコマと同じ位置
  • 画像の幅は pieceWidth を流用する
  • 描画のタイミングはコマのあと

これを命令してみます。やはりコード全体を選択して Ctrl+K で窓を開き、以下を命令します。

@cell_frame.svg の描画の実装を参考に、@piece_frame.svg の描画を実装してください。ただし以下は cell_frame.svg と異なる点です。

  • コマの枠の描画位置は、その場所の heightIndex が最大のコマと同じ位置
  • 画像の幅は pieceWidth を流用する
  • 描画のタイミングはコマのあと

すると以下を編集してくれました。

描画処理の条件の変更

if (canvas && pieceImageWhite && pieceImageBlack && boardImage && cellFrameImage && pieceFrameImage) {
}, [pieces, pieceImageWhite, pieceImageBlack, boardImage, cellFrameImage, pieceFrameImage, highlightedCells]);

描画処理

// コマのフレームを描画
// 各posIndexごとに最も高いheightIndexを持つコマの位置にフレームを描画
const posMaxHeightMap = new Map<number, number>();

// 各位置における最大のheightIndexを計算
pieces.forEach(piece => {
  const currentMax = posMaxHeightMap.get(piece.posIndex) || -1;
  if (piece.heightIndex > currentMax) {
    posMaxHeightMap.set(piece.posIndex, piece.heightIndex);
  }
});

// 各位置の最上部コマにフレームを描画
posMaxHeightMap.forEach((maxHeight, posIndex) => {
  const topPiece: Piece = { posIndex, heightIndex: maxHeight, color: 'W' }; // 色は描画に影響しないのでどちらでも良い
  const { x, y } = calcPieceCoordinate(canvas, pieceWidth, topPiece);
  
  const { width: drawFrameWidth, height: drawFrameHeight } = adjustSize(
    pieceFrameImage.width,
    pieceFrameImage.height,
    pieceWidth
  );
  
  ctx.drawImage(
    pieceFrameImage,
    x - drawFrameWidth / 2,
    y - drawFrameHeight / 2,
    drawFrameWidth,
    drawFrameHeight
  );
});

書いてくれたコード自体はよさそうです。動作確認もよさそうです。

動作確認

上記のコードでは「一番上のマスは確定で枠を重ねる」という処理になっていますが、コマをハイライトする場所を管理する state を導入してもらいましょう。全体選択→Ctrl+K で以下を命令します。

コマをハイライトする posIndex の集まりを管理する state を導入して。

以下を追加してくれました。]

state の導入

const [highlightedPieces, setHighlightedPieces] = useState<number[]>([]); // ハイライトするコマの位置

描画処理の依存状態の変更

}, [pieces, pieceImageWhite, pieceImageBlack, boardImage, cellFrameImage, pieceFrameImage, highlightedCells, highlightedPieces]);

描画処理の更新

// (最上位コマの位置の計算)

// ハイライトされたコマの位置かどうかを確認
const isHighlighted = highlightedPieces.includes(posIndex);

// ハイライトされたコマには特別な描画処理を適用
if (isHighlighted) {
  ctx.save();
  ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
  ctx.shadowBlur = 15;
}

ctx.drawImage(
  pieceFrameImage,
  x - drawFrameWidth / 2,
  y - drawFrameHeight / 2,
  drawFrameWidth,
  drawFrameHeight
);

if (isHighlighted) {
  ctx.restore();
}

テスト用の処理の実装

// ↓初期化処理の末尾
// 初期状態で特定のコマをハイライト(テスト用)
setHighlightedPieces([0, 4]);
// ↓クリックイベントの末尾
// クリックしたコマの位置をハイライトする配列を更新(トグル)
setHighlightedPieces(prevPieces => {
  if (prevPieces.includes(clickedPosIndex)) {
    return prevPieces.filter(index => index !== clickedPosIndex);
  } else {
    return [...prevPieces, clickedPosIndex];
  }
});

ちょっと意図した処理とは異なりそうです。動作確認したところ、どうやら薄く黄色に塗ってくれているみたいでした。「ハイライト」という言葉が誤って伝わっているようですね。サボって言葉を減らしたのが仇になりました。

動作確認

確定せずに、追加注文の部分 (Ctrl+K の命令直後の Add a follow-up の欄) に以下を補足します。

ハイライトとは piece_frame.svg を描画する処理のことです。
また、最上位のコマすべてに piece_frame.svg を重ねて描画するのではなく、heighlightedPieces が保持している posIndex に対応する場所の一番上のコマにのみ重ねてプロットするようにしてください。

すると、描画処理のところを以下に変更してくれました。これなら意図通りですね。

// ハイライトされたposIndexの位置だけフレームを描画
highlightedPieces.forEach(posIndex => {
  const maxHeight = posMaxHeightMap.get(posIndex);
  // その位置にコマがある場合のみフレームを描画
  if (maxHeight !== undefined) {
    const topPiece: Piece = { posIndex, heightIndex: maxHeight, color: 'W' }; // 色は描画に影響しないのでどちらでも良い
    const { x, y } = calcPieceCoordinate(canvas, pieceWidth, topPiece);
    
    const { width: drawFrameWidth, height: drawFrameHeight } = adjustSize(
      pieceFrameImage.width,
      pieceFrameImage.height,
      pieceWidth
    );
    
    ctx.drawImage(
      pieceFrameImage,
      x - drawFrameWidth / 2,
      y - drawFrameHeight / 2,
      drawFrameWidth,
      drawFrameHeight
    );
  }

動作確認もいい感じです。初期状態として設定した 0, 4 だけが反転しているのも想定通りです。

動作確認

枠線の処理のテストが終わったので、テスト用の初期値 + クリックイベントの処理は削除しておきます。

コマを浮かせる処理を実装する

次にコマを浮かせる処理を実装させてみましょう。まずは深く考えずにコード全体を選択し、以下を命令してみます。

posIndex の集まりの状態を新たに導入し、その状態に含まれるマスに積まれているコマのうち一番上のコマのプロット位置をすこし上側にずらす処理を実装して。ずらす位置については適切な場所で定数で管理すること。

概ね問題ないコードが生成されましたが、やや修正できそうだったので、以下を追加注文しました。

  • calcPieceCoordinate に isLiftedPiece: boolean と liftOffset: number を両方指定するのは煩雑なので、代わりに isLiftedPiece が false を与えたい状況のときは liftOffset = 0 を与えるようにして
  • liftOffset は pieceWidth に対する倍率で定義して

いい感じに修正してくれましたが、動作確認のための処理がなかったので、さらに追加注文しました。

handleCanvasClick の中で動作確認のための処理を追加して

良くなったのでここで確定しました。変更点は以下のとおりです。

まず calcPieceCoordinate に liftOffset が導入されました。

const calcPieceCoordinate = (canvas: HTMLCanvasElement, pieceWidth: number, piece: Piece, liftOffset: number = 0) => {
  const dh0 = 0.12; // コマの底面の中心に補正する項
  const dh = 0.19; // コマの高さ

  const { x: xBase, y: yBase } = calcPosCenter(canvas, piece.posIndex);
  // liftOffsetを適用(pieceWidthに対する倍率)
  const liftYOffset = liftOffset * pieceWidth;
  return { 
    x: xBase, 
    y: yBase - (dh0 + dh * piece.heightIndex) * pieceWidth - liftYOffset 
  };
};

持ち上げる高さと state が導入されました。

const pieceLifeOffsetRatio = 0.3; // コマを持ち上げる高さ(pieceWidthに対する倍率)
// (中略)
const [liftedPieces, setLiftedPieces] = useState<number[]>([]); // 持ち上げるコマの位置のposIndex

この状態は描画処理の監視対象になりました。

}, [pieces, pieceImageWhite, pieceImageBlack, boardImage, cellFrameImage, pieceFrameImage, highlightedCells, highlightedPieces, liftedPieces]);

一番上のコマの位置の Map を計算する処理がコマの描画処理の前に移動されました。これを用いて、コマの描画位置が以下のように修正されました。

// 最上部のコマかどうかを判断
const isTopPiece = piece.heightIndex === posMaxHeightMap.get(piece.posIndex);
// 持ち上げるべきコマかどうかを判断し、必要ならオフセットを適用
const liftOffset = isTopPiece && liftedPieces.includes(piece.posIndex) ? pieceLifeOffsetRatio : 0;

// 駒の座標を計算
const { x, y } = calcPieceCoordinate(canvas, pieceWidth, piece, liftOffset);

同様に、コマの枠線処理の部分も以下のように更新されました。

const topPiece: Piece = { posIndex, heightIndex: maxHeight, color: 'W' }; // 色は描画に影響しないのでどちらでも良い
// 持ち上げるべきコマかどうかを判断し、必要ならオフセットを適用
const liftOffset = liftedPieces.includes(posIndex) ? pieceLifeOffsetRatio : 0;
const { x, y } = calcPieceCoordinate(canvas, pieceWidth, topPiece, liftOffset);

また追加注文に対応して、クリックで動作確認できるようにしてくれました。

// 動作確認のため、クリックしたposIndexをliftedPiecesに追加または削除する
setLiftedPieces(prevLiftedPieces => {
  if (prevLiftedPieces.includes(clickedPosIndex)) {
    // すでに含まれている場合は削除
    return prevLiftedPieces.filter(index => index !== clickedPosIndex);
  } else {
    // 含まれていない場合は追加
    return [...prevLiftedPieces, clickedPosIndex];
  }
});

// また、動作確認のためにハイライトも切り替える
setHighlightedPieces(prevHighlightedPieces => {
  if (prevHighlightedPieces.includes(clickedPosIndex)) {
    return prevHighlightedPieces.filter(index => index !== clickedPosIndex);
  } else {
    return [...prevHighlightedPieces, clickedPosIndex];
  }
});

以下が動作確認のスクショです。悪くない出来です。

動作確認

もう少し雰囲気を体感したいので、初期状態とクリックイベントを見直させてみます。コード全体を選択→Ctrl+K で下記を命令します。

コマをリフトする機能の動作確認をしたいので、以下を実行して。

  • 初期状態を以下のように変更して

- 各マス上に3つずつコマが配置してある。偶数のマスには白黒白、奇数のマスには黒白黒を配置する。

  • 次に、マスをクリックしたときは、そのマスのリフト状態を変更するようにして。クリック時のほかの処理は削除して。

その結果、pieces の初期化処理が変更されました。

const [pieces, setPieces] = useState<Piece[]>(() => {
  // 初期状態で各マスに3つずつコマを配置
  const initialPieces: Piece[] = [];
  for (let posIndex = 0; posIndex < 8; posIndex++) {
    // 偶数のマスには白黒白、奇数のマスには黒白黒を配置
    const colors: ('B' | 'W')[] = posIndex % 2 === 0 ? ['W', 'B', 'W'] : ['B', 'W', 'B'];
    for (let heightIndex = 0; heightIndex < 3; heightIndex++) {
      initialPieces.push({
        posIndex,
        heightIndex,
        color: colors[heightIndex]
      });
    }
  }
  return initialPieces;
});

クリックの処理のうち、場所を特定したあとの処理がシンプルになりました。

// マスのリフト状態を切り替える
setLiftedPieces(prevLiftedPieces => {
  if (prevLiftedPieces.includes(clickedPosIndex)) {
    // すでに含まれている場合は削除
    return prevLiftedPieces.filter(index => index !== clickedPosIndex);
  } else {
    // 含まれていない場合は追加
    return [...prevLiftedPieces, clickedPosIndex];
  }
});

クリックで上げ下げできるようになりました。

動作確認

持ち上げる高さを少し調整して 0.2 にしてみました。

持ち上げ高さの調整

セカンドベストラベルの表示

プレイヤーまたはコンピューターがセカンドベスト宣言をしたときに「Second Best !」とデカデカと表示したいです。まったくやり方がわからないので、とりあえず雑に投げてみましょう。Board.tsx のコード全体を選択して、以下のように命令してみます。

キャンバス中央に「Second Best !」と書かれたラベルを大きく表示する機能を実装してください。表示/非表示は state で管理してください。動作確認のために、いずれかのマスをクリックすると表示/非表示が入れ替わるようにしてください。

いくつか編集してくれましたが、描画の部分は以下のように記述されていました。

// 「Second Best !」ラベルを表示
if (showSecondBestLabel) {
  ctx.save();
  ctx.font = 'bold 32px Arial';
  ctx.fillStyle = 'rgba(255, 0, 0, 0.8)';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.shadowColor = 'black';
  ctx.shadowBlur = 4;
  ctx.shadowOffsetX = 2;
  ctx.shadowOffsetY = 2;
  ctx.fillText('Second Best !', canvas.width / 2, canvas.height / 2);
  ctx.restore();
}

見た目はこんな感じです。

Second Best! 表示

ちょっとこれで頑張るのは厳しそうなので、画像の素材を用意して、それを表示する方針に変更します。

ということで、作りました。

Second Best!

これを描画するために用いた Python スクリプトは以下のとおりです。これも Cursor で適当に命令しながら作りました。


▼コードをクリックで表示

さて、素材ができたので、これまでと同じく assets に配置します。その上で改めて Board.tsx を開き、以下のように命令します。

@secondbest.svg を読み込んでください。また、この画像をボードの中央に、横幅いっぱいに表示する機能を作ってください。表示・非表示は state で管理し、ボードのコマの部分のクリックで切り替わるようにしてください。

同じく自然に編集してくれました。これまでと同じような変更なので、今回はコードの貼り付けは省略します。動作確認の画面はこんな感じでした。いい感じですね!

動作確認

コードの整理

作りたいものは一通りできましたが、Board.tsx のコード量が少々大きくなりすぎた感じがします。似たようなコードも多くあり、ただただ冗長にも感じます。

そこで Ask でどう改善できるか聞いてみます。Board.tsx を開いた状態で以下のように聞いてみます。

@Board.tsx の内容は繰り返しが多く、読みづらいです。リファクタして可読性を改善したいです。具体的にどのように編集すればよいか指摘してください。

すると、具体的なコード例とともに提案してくれました。要約すると以下の4点です。
1. 画像読み込み処理を統合する
2. 個別の画像の描画処理を独立した関数群に分割する
3. メインコンポーネントを簡素化する
4. 定数やパスを一箇所にまとめる
どれもまともそうな編集なので、Agent に切り替えて適用してもらいます。

先程の提案をすべて適用してください。

無事適用されましたが、変更内容が多いので、直接 GitHub のコミットを確認してください。こちらから確認できます↓
github.com

ひとつだけ言及しておきます。リファクタリングの中で useImageLoader という関数が定義されましたが、これのコメントには「画像読み込み用のカスタムフック」とあります。はて、カスタムフック。どちらさんでしょう。


▼コードをクリックで表示
少し調べてみたところ、カスタムフックとは自分でカスタムしたフックのことみたいです。そのまんま!
qiita.com
今回の例だと、画像を読み込んでセットし終わった状態だけを返すカスタムフックを定義しているようですね。これにより、コンポーネントの最初の処理の方の useEffect がなくなっています!確かに、その部分は画像のロードのみでしたから、これでグンとスッキリしました。

第七回まとめ

今回はかなり盛りだくさんでした。今回一気に以下を行いました。

  • ユーザーの視覚的な強調効果の検討
  • マスやコマを赤い枠で強調する機能の実装
  • マスを浮かせる機能の実装
  • "Second Best!" の素材の作成と機能の実装

これでボードのフロントエンド側の基本的な実装はとりあえず一通りできたと思います。

今回実装したところまでの分は v7-effects というタグで push しておきました。
github.com

次回はバックエンド部分の実装を行って、ボードのフロントエンドと連携させていきたいと思います。

ではまた。

続き↓
smooth-pudding.hatenablog.com




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

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