各種定数を求める
ここまでの議論で、以下5つの定数を求めれば良いことが分かりました。
- 2つの座標、合計4つ
- 一段上がる y 座標の増加幅
これらは実験的に求める他ないので、実験のための仕様に一度実装変更してもらいます。Board.tsx を開いた状態で Agent を開き、以下のように命令します。
canvas 上をクリックしたときの挙動を以下に変更してください。
- ある x, y, dy という Number 型の定数を定義しておく
- 一回目のクリックでは (x, y) にコマを配置する
- 二回目のクリックでは (x, y + dy) にコマを配置する
- 以降、n 回目のクリックでは (x, y + n*dy) にコマを配置する
- ただし以上の座標は canvas の中心を (0, 0) とする
すると以下の実装を用意してくれました。直観的でわかりやすい実装です。
const Board: React.FC = () => {
const canvasWidth = 350;
const canvasHeight = 350;
const pieceWidth = 50;
const x = 50;
const y = -100;
const dy = 60;
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pieces, setPieces] = useState<Piece[]>([]);
const [clickCount, setClickCount] = useState<number>(0);
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (canvas) {
const currentClick = clickCount + 1;
const canvasCenterX = canvas.width / 2;
const canvasCenterY = canvas.height / 2;
const relativeX = x;
const relativeY = y + (currentClick - 1) * dy;
const actualX = canvasCenterX + relativeX;
const actualY = canvasCenterY + relativeY;
setPieces(prevPieces => [...prevPieces, { x: actualX, y: actualY }]);
setClickCount(currentClick);
}
};
実験してわかったのですが、x座標は左端が0で右に行くほど大きい数値、y座標は上端が0で下に行くほど大きい数値のようです。したがって dy は負の数になりそうです。
コマの横幅やボードの幅の倍率で表現するために、すこし修正して以下のようにします。
const relativeX = x * canvas.width;
const relativeY = y * canvas.width + (currentClick - 1) * dy * pieceWidth;
気合で数値を動かして実験した結果、以下が求まりました。
const [x1, y1] = [0.137, 0.248];
const [x2, y2] = [0.330, 0.095];
const dy = -0.19;
二箇所に三段ずつ置いてみた図これを踏まえて、以下のような関数を作っておきます。posIndex は上の右側にあるマスを 0 として時計回りに一周する向きに増えるインデックスで、heightIndex は下から順に 0, 1, 2 となるインデックスです。
const calcPieceCoordinate = (canvas: HTMLCanvasElement, pieceWidth: number, posIndex: number, heightIndex: number) => {
const dh = 0.19;
const [x1, y1] = [0.137, 0.248];
const [x2, y2] = [0.330, 0.095];
const [xBase, yBase] = [
[x1, -y1], [x2, -y2], [x2, y2], [x1, y1],
[-x1, y1], [-x2, y2], [-x2, -y2], [-x1, -y1],
][posIndex];
const relativeX = xBase * canvas.width;
const relativeY = yBase * canvas.width - dh * heightIndex * pieceWidth;
const canvasCenterX = canvas.width / 2;
const canvasCenterY = canvas.height / 2;
return { x: relativeX + canvasCenterX, y: relativeY + canvasCenterY };
};
これを用いて handleCanvasClick を以下のように書き換えます。
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (canvas) {
const posIndex = clickCount % 8;
const heightIndex = Math.floor(clickCount / 8);
const { x, y } = calcPieceCoordinate(canvas, pieceWidth, posIndex, heightIndex);
setPieces(prevPieces => [...prevPieces, { x, y }]);
setClickCount(prevClick => prevClick + 1);
}
};
この状態で24回クリックすればきれいに積み上がるはず!
24回クリック・・・あるぇ〜〜???
ちょっとずれてしまいました。何が原因でしょうか・・・?
正解は、「コマの画像の中心座標は、コマの底面の中心の座標ではないから」です。上側に置くコマを上下反転させればピッタリ枠にはまるはずですが、実際はそうではないので、補正が必要です。
calcPieceCoordinate にもうひとつの定数 dh0 を導入して、y1, y2 を微調整します。dh0 もコマの幅に対する倍率として定義しておきます。
const calcPieceCoordinate = (canvas: HTMLCanvasElement, pieceWidth: number, posIndex: number, heightIndex: number) => {
const dh0 = 0.12;
const dh = 0.19;
const [x1, y1] = [0.137, 0.263];
const [x2, y2] = [0.330, 0.110];
const [xBase, yBase] = [
[x1, -y1], [x2, -y2], [x2, y2], [x1, y1],
[-x1, y1], [-x2, y2], [-x2, -y2], [-x1, -y1],
][posIndex];
const relativeX = xBase * canvas.width;
const relativeY = yBase * canvas.width - (dh0 + dh * heightIndex) * pieceWidth;
const canvasCenterX = canvas.width / 2;
const canvasCenterY = canvas.height / 2;
return { x: relativeX + canvasCenterX, y: relativeY + canvasCenterY };
};
これで無事きれいに配置できました。
微調整後
コマのデータの持ち方を変更
これまでコマは描画する座標で管理してきました。しかし、これからは24箇所しか配置しないので、posIndex と heightIndex の組で持てば十分そうです。
ただ変更する箇所が結構多そうで面倒なので、Cursor に命令します。
Piece が保持するデータを以下に変更して。
- posIndex: number
- heightIndex: number
また、calcPieceCoordinate の3,4番目の引数の代わりに、ひとつの Piece を受け取るようにして。
その他、関連するところも修正して。
するとサラサラと編集してくれました。座標を計算するタイミングが描画のタイミングに移動しています。
import React, { useRef, useEffect, useState } from 'react';
import './Board.css';
interface Piece {
posIndex: number;
heightIndex: number;
}
const adjustSize = (originalWidth: number, originalHeight: number, targetWidth: number) => {
const aspectRatio = originalHeight / originalWidth;
return {
width: targetWidth,
height: targetWidth * aspectRatio,
};
};
const calcPieceCoordinate = (canvas: HTMLCanvasElement, pieceWidth: number, piece: Piece) => {
const dh0 = 0.12;
const dh = 0.19;
const [x1, y1] = [0.137, 0.263];
const [x2, y2] = [0.330, 0.110];
const [xBase, yBase] = [
[x1, -y1], [x2, -y2], [x2, y2], [x1, y1],
[-x1, y1], [-x2, y2], [-x2, -y2], [-x1, -y1],
][piece.posIndex];
const relativeX = xBase * canvas.width;
const relativeY = yBase * canvas.width - (dh0 + dh * piece.heightIndex) * pieceWidth;
const canvasCenterX = canvas.width / 2;
const canvasCenterY = canvas.height / 2;
return { x: relativeX + canvasCenterX, y: relativeY + canvasCenterY };
};
const Board: React.FC = () => {
const canvasWidth = 350;
const canvasHeight = 350;
const pieceWidth = 50;
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pieces, setPieces] = useState<Piece[]>([]);
const [clickCount, setClickCount] = useState<number>(0);
const [pieceImage, setPieceImage] = useState<HTMLImageElement | null>(null);
const [boardImage, setBoardImage] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const pieceImg = new Image();
pieceImg.onload = () => {
setPieceImage(pieceImg);
};
pieceImg.src = '/src/assets/piece_white.svg';
const boardImg = new Image();
boardImg.onload = () => {
setBoardImage(boardImg);
};
boardImg.src = '/src/assets/board.svg';
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (canvas && pieceImage && boardImage) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
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);
pieces.forEach(piece => {
const { x, y } = calcPieceCoordinate(canvas, pieceWidth, piece);
const { width: drawPieceWidth, height: drawPieceHeight } = adjustSize(
pieceImage.width,
pieceImage.height,
pieceWidth
);
ctx.drawImage(
pieceImage,
x - drawPieceWidth / 2,
y - drawPieceHeight / 2,
drawPieceWidth,
drawPieceHeight
);
});
}
}
}, [pieces, pieceImage, boardImage]);
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (canvas) {
const posIndex = clickCount % 8;
const heightIndex = Math.floor(clickCount / 8);
setPieces(prevPieces => [...prevPieces, { posIndex, heightIndex }]);
setClickCount(prevClick => prevClick + 1);
}
};
return (
<div className="board-container">
<canvas
ref={canvasRef}
className="board-canvas"
width={canvasWidth}
height={canvasHeight}
onClick={handleCanvasClick}
/>
</div>
);
};
export default Board;
最後に、黒のコマも置けるようにしましょう。Piece の中で 'B' または 'W' の値をとる color を用意します。描画の際はその値に応じて白か黒を描画するようにしてもらいましょう。以下を Agent で命令します。
以下に対応して
- Piece に color: 'B' | 'W' の項目を追加する
- 黒のコマ @piece_black.svg を読み込むようにする
- Piece の color の値に応じて白または黒のコマを描画するようにする
- クリックイベントでは clickCount の偶奇に応じて白と黒を入れ替えて
こちらもサラサラっと書き換えてくれました。
import React, { useRef, useEffect, useState } from 'react';
import './Board.css';
interface Piece {
posIndex: number;
heightIndex: number;
color: 'B' | 'W';
}
const adjustSize = (originalWidth: number, originalHeight: number, targetWidth: number) => {
const aspectRatio = originalHeight / originalWidth;
return {
width: targetWidth,
height: targetWidth * aspectRatio,
};
};
const calcPieceCoordinate = (canvas: HTMLCanvasElement, pieceWidth: number, piece: Piece) => {
const dh0 = 0.12;
const dh = 0.19;
const [x1, y1] = [0.137, 0.263];
const [x2, y2] = [0.330, 0.110];
const [xBase, yBase] = [
[x1, -y1], [x2, -y2], [x2, y2], [x1, y1],
[-x1, y1], [-x2, y2], [-x2, -y2], [-x1, -y1],
][piece.posIndex];
const relativeX = xBase * canvas.width;
const relativeY = yBase * canvas.width - (dh0 + dh * piece.heightIndex) * pieceWidth;
const canvasCenterX = canvas.width / 2;
const canvasCenterY = canvas.height / 2;
return { x: relativeX + canvasCenterX, y: relativeY + canvasCenterY };
};
const Board: React.FC = () => {
const canvasWidth = 350;
const canvasHeight = 350;
const pieceWidth = 50;
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pieces, setPieces] = useState<Piece[]>([]);
const [clickCount, setClickCount] = useState<number>(0);
const [pieceImageWhite, setPieceImageWhite] = useState<HTMLImageElement | null>(null);
const [pieceImageBlack, setPieceImageBlack] = useState<HTMLImageElement | null>(null);
const [boardImage, setBoardImage] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const pieceImgWhite = new Image();
pieceImgWhite.onload = () => {
setPieceImageWhite(pieceImgWhite);
};
pieceImgWhite.src = '/src/assets/piece_white.svg';
const pieceImgBlack = new Image();
pieceImgBlack.onload = () => {
setPieceImageBlack(pieceImgBlack);
};
pieceImgBlack.src = '/src/assets/piece_black.svg';
const boardImg = new Image();
boardImg.onload = () => {
setBoardImage(boardImg);
};
boardImg.src = '/src/assets/board.svg';
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (canvas && pieceImageWhite && pieceImageBlack && boardImage) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
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);
pieces.forEach(piece => {
const { x, y } = calcPieceCoordinate(canvas, pieceWidth, piece);
const pieceImage = piece.color === 'W' ? pieceImageWhite : pieceImageBlack;
const { width: drawPieceWidth, height: drawPieceHeight } = adjustSize(
pieceImage.width,
pieceImage.height,
pieceWidth
);
ctx.drawImage(
pieceImage,
x - drawPieceWidth / 2,
y - drawPieceHeight / 2,
drawPieceWidth,
drawPieceHeight
);
});
}
}
}, [pieces, pieceImageWhite, pieceImageBlack, boardImage]);
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (canvas) {
const posIndex = clickCount % 8;
const heightIndex = Math.floor(clickCount / 8);
const color: 'B' | 'W' = clickCount % 2 === 0 ? 'W' : 'B';
setPieces(prevPieces => [...prevPieces, { posIndex, heightIndex, color }]);
setClickCount(prevClick => prevClick + 1);
}
};
return (
<div className="board-container">
<canvas
ref={canvasRef}
className="board-canvas"
width={canvasWidth}
height={canvasHeight}
onClick={handleCanvasClick}
/>
</div>
);
};
export default Board;
いい感じに二色のコマを置けるようになりました。
白黒に対応
追記
記事を投稿して「よっしゃできた〜」と喜びながら風呂に入っていたら、「え、てか、xBase と yBase は三角関数で書いたほうがシンプルじゃね?」と気づいたので、追加で編集します。
xBase, yBase は楕円の周上の点なので、あるパラメーター
を用いて以下のように表せます。

ここで

はそれぞれ

方向の半径です。
前回ボードの画像を作ったときのコードを見てもらえば分かりますが、ボードの配置は
- 小円を円周上に等間隔に並べて
- y 方向に 0.8 倍して潰す
という風に作っています。したがって

が成り立ち、さらに

が成り立つことが分かります。
これを踏まえると、x1, y1, x2, y2 の値は

であることがわかります。

であることを踏まえると

が成り立つので、

となることが分かります。
実際、目分量で頑張って求めた値は x1 = 0.137, y1 = 0.263, x2 = 0.330, y2 = 0.110 でしたが、0.137 * 0.8 = 0.1096 ≒ 0.110 および 0.330 * 0.8 = 0.264 ≒ 0.263 となっていることが分かります。
からくりがわかったので
を求めると

となります。
これを踏まえて、xBase, yBase を定義すると、以下のようになります。
const radiusX = 0.358;
const radiusY = radiusX * 0.8;
const angle = (-3 * Math.PI / 8) + (Math.PI / 4) * piece.posIndex;
const [xBase, yBase] = [ radiusX * Math.cos(angle), radiusY * Math.sin(angle) ];
動作確認もバッチリです!
動作確認タグの付け直しが面倒くさいので、この変更は次回の記事の範囲で織り込むことにします。