<div id="root"></div>
/*
| piece.isShuffling | transitionプロパティに値を指定するか否かを判定する条件式で使います。transition効果はシャッフル中に空白と入れ替える対象となったピースに適用するため、そうなったときにそのピースを表すpieceのisShufflingプロパティをtrueに切り変えます。シャッフルについては後述します。 |

| handleDown | 要素をドラッグ&ドロップする際に実行するhandleDown関数を受け取ります。受け取り後は、子コンポーネントのPieceコンポーネントのhandleDown属性に指定されます。handleDown関数については後述します。 |
*/

/*
| プロパティ名 | 値   |
| --- | --- |
| baseId | 元々の位置番号。この番号がピースの左上に表示する番号となる。 |
| currentId | 現在の位置番号。ピースが空白と入れ替わる度に変化する。移動範囲の制限時やクリア判定の際などに使われる。 |
| col | 列番号。9パズルであれば1~3の番号が割り振られる。移動範囲の制限時に使われる。 |
| row | 行番号。9パズルであれば1~3の番号が割り振られる。移動範囲の制限時に使われる。 |
| x   | ピースのx方向の移動量。この値をtranslate3dのx値に指定すると、ドラッグと同時にピースがx方向へ動くようになる。 |
| y   | ピースのy方向の移動量。この値をtranslate3dのy値に指定すると、ドラッグと同時にピースがy方向へ動くようになる。 |
| height | ピースの高さ。この値はリサイズ毎に変化する。移動範囲の制限時や移動先への移動を成立させるか否かの判定時に使われる。 |
| width | ピースの幅。この値はリサイズ毎に変化する。移動範囲の制限時や移動先への移動を成立させるか否かの判定時に使われる。 |
| image | ピースの画像 |
| isShuffling | シャッフル中にpieceが空白と入れ替える対象であるか否かを示す真偽値。入れ替える対象となったpieceのisShufflingはtrueに切り替わる。 |
| isDragging | ドラッグされてるか否かを示す真偽値。ドラッグしたピースを表すpieceのisDraggingはtrueに切り替わり、ドロップすればfalseに切り替わる。 |
| isBlinking | ピースが点滅しているか否かを示す真偽値。ドロップ時にピースが空白と入れ替わるには、空白にピースがぴったり収まった状態でドロップする必要があるため、移動中に空白にぴったり収まったときにピースを点滅させて、入れ替えが成立するか否かをプレイヤーにわかりやすく見せるために必要となる。 |
| isBlank | 空白を示す真偽値。isBlankがtrueのpieceは空白となる。 |

上記の行番号`row`と列番号`col`を割り当てる際、列番号colは横方向へ1ずつ増やしていくため、`(i % cols) + 1`とすることで、1→2→3、1→2→3...と割り振られるようになります。

行番号rowにはrowCount変数を指定します。rowCount変数は初期値が1であり、`i+1`が最大の行番号`rows`に達したときに`rowCount`を1増やします。

```js
// i + 1がrowsの倍数になる毎に行番号を1増やす
                if((i + 1) % rows === 0) {
                    rowCount++
                }
```

これにより、例えば9パズルと仮定すると、ループ処理の中でrowCountは下記のように変化します。

| ループ回数 | rowCount |
| --- | --- |
| 1回目 | 1   |
| 2回目 | 1   |
| 3回目 | 1   |
| 4回目 | 2   |
| 5回目 | 2   |
| 6回目 | 2   |
| 7回目 | 3   |
| 8回目 | 3   |
| 9回目 | 3   |
*/

/*
#### StyledPieceコンポーネントがpropsとして受け取る値について
StyledPieceコンポーネントがpropsとして受け取ったpieceオブジェクトは、コンポーネント内で展開され、そのうちpiece.isShuffling、piece.x、piece.yプロパティはそれぞれスタイル中で下記のように使われます。

| プロパティ名 | 値 |
| --- | --- |
| piece.x | translate3dのx値に指定します。それによってxはピースがドラッグされる度に変化するため、ピースがその分だけ移動するようになる。ドラッグについては後述します。 |
| piece.y | translate3dのy値に指定します。それによってyはピースがドラッグされる度に変化するため、ピースがその分だけ移動するようになる。ドラッグについては後述します。 |
*/

const { useState, useRef, useCallback, useEffect, forwardRef } = React;
const { createGlobalStyle } = styled;

const BOARD_COLOR = "#997f5d";
const BOARD_SIZE = "60vmin";
const SLIDE_PUZZLE_IMAGE = "https://images.ctfassets.net/w6gireivdm0c/61UEuQEwHoSoz3GIpNkF9e/1d18ada4da6c6de4912efa71f57d44aa/NEKO9V9A9131_TP_V4.jpg";
const NUMBER_OF_PIECES = 9;

// 画像を読み込んだあとにその画像を返す関数
const loadImage = (url: string): Promise<HTMLImageElement> => {
	return new Promise((resolve) => {
		const image = new Image();
		image.src = url;
		image.crossOrigin = "Anonymous";
		image.addEventListener("load", (e) => {
			const target = e.target as HTMLImageElement;
			resolve(target);
		});
	});
};

/*const getLoadedImage = async (
	loadedImagePromise: Promise<HTMLImageElement>
): HTMLImageElement => {
	const loadedImage = await loadedImagePromise.then((r) => r);

	return loadedImage;
};*/

// 画像を比率を維持したまま正方形に切り抜いてその画像を返す関数
// imageUrl=元画像のURL
// size=正方形の一辺の長さ
async function getSquareImage(imageUrl: string, size: number): Promise<string> {
	// 元画像を読み込んで取得する
	const image = await loadImage(imageUrl).then((r) => r);

	const canvas = document.createElement("canvas") as HTMLCanvasElement;
	const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;

	// canvasのサイズを生成する画像と同じサイズの正方形にする
	canvas.width = size;
	canvas.height = size;

	// 元画像の一辺の長さに対するこれから生成する画像の一辺の長さの比率
	// 元画像の高さと幅を比較して、小さいほうの一辺の長さを基にした比率を算出する(cssのvminと同じ考え方)
	// 元画像を縦横比を維持したまま正方形にするため、大きいほうを基準にしてしまうと生成した画像の両端に余白ができてしまう
	const scale =
		image.height >= image.width ? size / image.width : size / image.height;

	// 元画像の中央がcanvasの中央に来るように描画する
	// 元画像のサイズをscale倍にして小さいほうの一辺をcanvasにぴったり合わせて比率を維持したままサイズを変化させる
	// 両端のはみ出た部分は切り抜かれる
	ctx.drawImage(
		image,
		size / 2 - (image.width * scale) / 2,
		size / 2 - (image.height * scale) / 2,
		image.width * scale,
		image.height * scale
	);

	// canvasに描画されている内容を画像データURIとして取得する
	const squareImage = canvas.toDataURL("image/jpeg");

	return squareImage;
}

// 画像を格子状に分割してそれらの画像を返す関数
// imageUrl=元画像のURL
// numberOfDivisions=分割する数
async function divideImageEqually(
	imageUrl: string,
	numberOfDivisions: number
): Promise<string[]> {
	const canvas = document.createElement("canvas") as HTMLCanvasElement;

	const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;

	const image = await loadImage(imageUrl).then((r) => r);

	// 行・列数
	const cols = Math.sqrt(numberOfDivisions);
	const rows = cols;

	// canvasのサイズを格子状の1マス分のサイズにする
	canvas.width = image.width / rows;
	canvas.height = image.height / cols;

	// 分割した画像のURLを格納する配列
	const dividedImageURLs: string[] = [];

	// ループ処理で左上から右へ1マスずつ元画像を切り抜いていく
	let count = 0;
	for (let i = 0; i < cols; i++) {
		for (let j = 0; j < rows; j++) {
			ctx.drawImage(
				image,
				(j * image.width) / rows,
				(i * image.height) / cols,
				image.width / rows,
				image.height / cols,
				0,
				0,
				canvas.width,
				canvas.height
			);
			// canvasの内容を画像のデータURIとして取得する
			const dividedImageURL = canvas.toDataURL("image/jpeg");

			dividedImageURLs[count] = dividedImageURL;

			count++;
		}
	}

	return dividedImageURLs;
}

// pieceオブジェクトの型
type PieceData = {
	number: number;
	image: string;
	isBlank: boolean;
};

const useSlidePuzzle = (
	numberOfPieces: number,
	imageUrl: string
): [PieceData[], React.RefObject<HTMLDivElement>] => {
	// ピースのデータを管理するステート
	const [pieces, setPieces] = useState<PieceData[]>([]);

	// DOM 要素を保持するための ref を作成します。
	const elementRef = useRef<HTMLDivElement>(null);

	// ピースのデータを初期化する関数
	const initializePieces = async () => {
		//if(!puzzleRef.current) return;

		// パズルのサイズ
		const puzzleSize = elementRef.current.clientWidth;

		// bgをパズルと同じサイズに切り抜く
		const squareImageUrl = await getSquareImage(imageUrl, puzzleSize).then(
			(r) => r
		);

		//squareImageUrlをcols×rowsに分割する
		const pieceImageUrls = divideImageEqually(squareImageUrl, numberOfPieces);

		pieceImageUrls.then((imageUrls) => {
			const pieces = [];

			for (let i = 0; i < numberOfPieces; i++) {
				const piece = {
					number: i + 1, // 元々の位置番号
					isBlank: i === numberOfPieces - 1 ? true : false, // 初めは最後の番号を空白にする
					image: imageUrls[i] // 分割した画像のうち、piecesのi番目に格納されているピースに対応する画像
				};

				pieces[i] = piece;
			}

			setPieces(pieces);
		});
	};

	// ピースの数が変わるたびに初期化する
	useEffect(() => {
		initializePieces();
	}, [numberOfPieces]);

	return [pieces, elementRef];
};

type SlidePuzzlePieceProps = {
	className?: string;
	piece: PieceData;
};

const StyledSlidePuzzlePiece = styled.div`
	padding: 0.3vmin;
	position: relative;
`;

const PieceImage = styled.img`
	display: block;
	height: auto;
	pointer-events: none; // 画像がドラッグされないようにする
	width: 100%;
`;

const PieceNumber = styled.span`
	color: white;
	font-size: 1vmin;
	position: absolute;
	top: 1.2vmin;
	left: 1.2vmin;
`;

const SlidePuzzlePiece = ({ className, piece }: SlidePuzzlePieceProps) => {
	return (
		<StyledSlidePuzzlePiece className={className}>
			<>
				<PieceNumber className="piece-number">{piece.number}</PieceNumber>
				<PieceImage className="piece-image" src={piece.image}></PieceImage>
			</>
		</StyledSlidePuzzlePiece>
	);
};

const StyledBlank = styled.div`
	padding: 0.3vmin;
`;

const Blank = ({ className }: Pick<SlidePuzzlePieceProps, "className">) => {
	return <StyledBlank className={className} />;
};

type SlidePuzzleProps = {
	className?: string;
	size: string;
	pieces: PieceData[];
	pieceQuantity: number;
};

const StyledSlidePuzzle = styled.div<
	Pick<SlidePuzzleProps, "pieceQuantity" | "size">
>`
	background-color: #997f5d;
	border: 1vmin solid #997f5d;
	border-radius: 1vmin;
	display: grid;
	grid-template-rows: repeat(
		${({ pieceQuantity }) => Math.sqrt(pieceQuantity)},
		auto
	);
	grid-template-columns: repeat(
		${({ pieceQuantity }) => Math.sqrt(pieceQuantity)},
		auto
	);
	height: ${({ size }) => size};
	width: ${({ size }) => size};
`;

const SlidePuzzle = forwardRef<
	HTMLDivElement,
	Omit<SlidePuzzleProps, "pieceQuantity">
>(({ className, pieces, size }, ref: React.ForwardedRef<HTMLDivElement>) => {
	return (
		<StyledSlidePuzzle
			className={className}
			ref={ref}
			size={size}
			pieceQuantity={pieces.length}
		>
			{pieces.map((piece, index) =>
				!piece.isBlank ? (
					<SlidePuzzlePiece key={piece.num} piece={piece} />
				) : (
					<Blank key={piece.num} />
				)
			)}
		</StyledSlidePuzzle>
	);
});

const GlobalStyle = createGlobalStyle`
    :root {
    --bg-color: burlywood;
    --accent-color: darkorange;
    --text-color: rgb(40, 40, 40);
  }

    *,
    *::before,
    *::after {
        box-sizing: border-box;
    }

    body {
        background-color: var(--bg-color);
        color: var(--text-color);
    }
`;

const StyledApp = styled.div`
	display: flex;
	justify-content: center;
	align-items: center;
	height: 100vh;
`;

const App = () => {
	const [pieces, puzzleElementRef] = useSlidePuzzle(NUMBER_OF_PIECES, SLIDE_PUZZLE_IMAGE);

	return (
		<>
			<GlobalStyle />
			<StyledApp>
				<SlidePuzzle
					className="slide-puzzle"
					pieces={pieces}
					size={BOARD_SIZE}
					ref={puzzleElementRef}
				/>
			</StyledApp>
		</>
	);
};

ReactDOM.render(<App />, document.getElementById("root"));
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/styled-components/4.3.1/styled-components.min.js