<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, css, keyframes } = styled;

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

const loadImage = (url) => {
	return new Promise((resolve) => {
		const img = new Image();
		img.src = url;
		img.crossOrigin = "Anonymous";
		img.addEventListener("load", (e) => {
			resolve(e.target);
		});
	});
};

async function getSquareImage(url, sideLength) {
	const image = await loadImage(url).then((r) => r);

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

	const ratio =
		image.height / image.width >= 1
			? sideLength / image.width
			: sideLength / image.height;
	canvas.width = sideLength; //image.width
	canvas.height = sideLength; //image.width

	ctx.drawImage(
		image,
		sideLength / 2 - (image.width * ratio) / 2,
		sideLength / 2 - (image.height * ratio) / 2,
		image.width * ratio,
		image.height * ratio
	);

	const result = canvas.toDataURL("image/jpeg");

	return result;
}

async function divideImage(url, divisionNumber) {
	const canvas = document.createElement("canvas");

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

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

  const rows = Math.sqrt(divisionNumber);
  const cols = rows;
	canvas.width = image.width / rows;
	canvas.height = image.height / cols;

	const dividedImages = [];

	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
			);

			const clipedImage = canvas.toDataURL("image/jpeg");

			dividedImages[count] = clipedImage;

			count++;
		}
	}

	return dividedImages;
}

const useSlidePuzzle = (quantity, bg) => {
     // 全ピースのステート
    const [puzzlePieces, setPuzzlePieces] = useState([]);

    const slidePuzzleRef = useRef(null);

    // ピースを初期化する関数
    const initializePieces = useCallback(async () => {
        const width = slidePuzzleRef.current.clientWidth;
        const height = slidePuzzleRef.current.clientHeight;

        // 画像を正四角形にする
        const slidePuzzleImage = await getSquareImage(bg, width).then((r) => r);

        // 画像をcol列×row行に分割する
        const pieceImages = divideImage(slidePuzzleImage, quantity);

        pieceImages.then((images) => {
            const pieces = [];
            let rowCount = 1;

            for(let i = 0; i < quantity; i++){
                const piece = {
                    baseId: i + 1,// 元々の位置番号
                    image: images[i],// 分割した背景画像の欠片
                    blank: i === quantity - 1 ? true : false,// 初めは最後の番号を空白にする
                };

                pieces[i] = piece;
            };

            setPuzzlePieces(pieces);
        });
    });

    // 初回とピース数が変わったときに行う
    useEffect(() => {
        initializePieces();
    }, [quantity]);

    return [
        slidePuzzleRef,
        puzzlePieces,
    ];
};

const GlobalStyle = createGlobalStyle`
	@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

	:root {
    --base-color: burlywood;
    --accent-color: darkorange;
    --text-color: rgb(40, 40, 40);
  }

	* {
	  font-family: 'Press Start 2P', cursive;
	}
	
	*,
	*::before,
	*::after {
		box-sizing: border-box;
	}

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

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

const StyledPiece = styled.div`
    padding: 0.3vmin;
    position: relative;
    user-select: none;
    z-index: 2;
`;

const Img = styled.img`
  display: block
  height: auto;
  pointer-events: none;
  width: 100%;
`;

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

const Piece = ({ className, piece }) => {
    return (
        <StyledPiece
            id={`piece-${piece.baseId}`}
            className={`${className} piece`}
            piece={piece}
        >        
                <>
                    <Num className="piece-num">{piece.baseId}</Num>
                    <Img className="piece-image" src={piece.image}></Img>
                </>
        </StyledPiece>
    );
};

const Blank = styled.div`
    padding: 0.3vmin;
    position: relative;
    user-select: none;
    z-index: 1;
`;

const StyledSlidePuzzle = styled.div`
    background-color: ${({ boardColor }) => boardColor};
    border: 1vmin solid ${({ boardColor }) => boardColor};
    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};
    position: relative;
    width: ${({ size }) => size};
    z-index: 0;
`;

const SlidePuzzle = forwardRef(
    ({ className, pieces, boardColor, size }, ref) => (
        <StyledSlidePuzzle
            className={className}
            ref={ref}
            boardColor={boardColor}
            size={size}
            pieceQuantity={pieces.length}
        >
            {pieces.map((piece, i) => !piece.blank ? (
                <Piece
                    piece={piece}
                    key={i}
                />
            ) : <Blank piece={piece} />)}
        </StyledSlidePuzzle>
    )
);

const App = () => {
    const [
        slidePuzzleRef,
        puzzlePieces
    ] = useSlidePuzzle(9, BG);
    return (
        <>
            <GlobalStyle />
            <StyledApp>
                <SlidePuzzle
                        className="slide-puzzle"
                        ref={slidePuzzleRef}
                        pieces={puzzlePieces}
                        boardColor={BOARD_COLOR}
                        size={BOARD_SIZE}
                    />
            </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/17.0.2/umd/react.production.min.jshttps://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/styled-components/4.3.1/styled-components.min.js