ツクログネット

【React】スライドパズルゲームを作成する

eye catch

開発環境の構築

Reactアプリの開発環境の構築についてはReactの開発環境を構築するをご覧ください。

ディレクトリ構成

プロジェクトのsrc配下を下記のようにします。

react-slide-puzzle
 └── src
     ├── components
     │   ├── Button.js
     │   ├── Display.js
     │   ├── Margin.js
     │   ├── ModalWindow.js
     │   └── SlidePuzzle.js
     ├── hooks
     │   ├── useDragAndDrop.js
     │   ├── useMutationObserver.js
     |   ├── useResizeObserver.js
     │   └── useSlidePuzzle.js
     ├── reducers
     │   └── slidePuzzleReducer.js
     ├── App.css
     ├── App.js
     ├── App.test.js
     ├── constants.js
     ├── index.css
     ├── index.js
     ├── logo.svg
     ├── serviceWorker.js
     └── utils.js

styled-componentsのインストール

ターミナルで以下のコマンドを実行してstyled-componentsをインストールします。

yarn add styled-components

定数を定義する

src配下のconstants.jsにアプリケーション内で使用する定数を定義します。

// スライドパズルのサイズ
export const SLIDE_PUZZLE_SIZE = {
	height: '60vmin',
	width: '60vmin'
}

// スライドパズルの背景画像
export const BG = "bg.jpg";

// ピースがシャッフルされるスピード
export const SHUFFLE_SPEED = 60;

// ピースの最大シャッフル数
export const MAX_SHUFFLE_COUNT = 100;

// レベルに応じたピース数
export const LEVEL = {
	easy: 9,
	normal: 16,
	hard: 25
};

// 盤面のカラー
export const BOARD_COLOR = "#997f5d";

ユーティリティ関数を定義する

src配下のutils.jsに下記のユーティリティ関数を定義します。

utils.js

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

export async function divideImage(
	url,
	rows,
	cols
) {
	const canvas = document.createElement('canvas')

	const ctx = canvas.getContext('2d')

	const squareImageUrl = await getSquareImage(url).then(r => r)

	const image = await loadImage(squareImageUrl).then(r => r)

	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
}

export const clamp = (value, min, max) => {
	if (value < min)
		return min
	else if (value > max)
		return max
	return value
}

コンポーネントを作成する

Button

ボタンのビューを返すButtonコンポーネントを作成します。

Buttonコンポーネントについては【React】ボタンを作るをご覧ください。

Margin

コンポーネントが返すビューにマージンを加えるMarginコンポーネントを作成します。

Marginコンポーネントについては【React】marginをコンポーネントにするをご覧ください。

GameStatus

ゲームのステータスのビューを返すGameStatusコンポーネントを作成します。

components/GameStatus.js

import React from 'react'
import styled from 'styled-components'

const StyledGameStatus = styled.div`
	color: ${({ color }) => (color ? color : "#333333")};
	font-size: ${({ fontSize }) => (fontSize ? fontSize : "1em")};
`;

const GameStatus = ({ className, title, text, color, fontSize }) => (
	<StyledGameStatus
		className={className}
		color={color}
		fontSize={fontSize}
	>{`${title}: ${text}`}</GameStatus>
);

export default GameStatus

このコンポーネントは下記のビューを返します。

<div>{`${title}: ${text}`}</div>

また、styled-componentsで上記のビューにスタイルを持たせています。

このコンポーネントはStyledGameStatusコンポーネントを返します。

StyledGameStatusコンポーネントは、

このコンポーネントはpropsとして以下の値を受け取ります。

props 内容
className クラス名
title ステータスのタイトル
value ステータスの値
color 文字のカラーであり、カラーコードかカラー名を文字列で指定します。
fontSize フォントサイズであり、"16px"のように文字列で指定します。

受け取ったpropsのうち、colorとfontSizeはそれぞれStyledGameStatusコンポーネントが持つスタイルのcolorとfont-sizeプロパティの値となります。

titleとvalueはそのままStyledGameStatusコンポーネントが描画するdiv要素内のテキストの一部となります。

ModalWindow

モーダルウィンドウのビューを返すModalWindowコンポーネントを作成します。

ModalWindowコンポーネントについては【React】モーダルウィンドウを作るをご覧ください。

SlidePuzzle

スライドパズルのビューを返すSlidePuzzleコンポーネントを作成します。

components/SlidePuzzle.js

import React from 'react'
import styled from 'styled-components'

const StyledSlidePuzzle = styled.div`
	background-color: ${({ boardColor }) => boardColor};
	box-sizing: border-box;
	border: 1vmin solid ${({ boardColor }) => boardColor};
	border-radius: 1vmin;
	height: ${({ sideLength }) => sideLength};
	position: relative;
	width: ${({ sideLength }) => sideLength};
	z-index: 0;
`;

const Piece = styled.div`
	box-sizing: border-box;
	height: ${({ piece }) => piece.height}px;
	padding: 0.3vmin;
	position: absolute;
	left: ${({ piece }) => piece.x}px;
	top: ${({ piece }) => piece.y}px;
	transition: ${({ piece }) => (piece.isShuffle ? `all linear 100ms` : "")};
	transform: translate3d(
		${({ piece }) => piece.offsetX}px,
		${({ piece }) => piece.offsetY}px,
		0
	);
	user-select: none;
	width: ${({ piece }) => piece.width}px;
	z-index: ${({ piece }) => (piece.blank ? 1 : 3)};
`;

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 SlidePuzzle = ({ className, slidePuzzle, boardColor, sideLength }) => (
	<StyledSlidePuzzle
		className={className}
		ref={slidePuzzle.slidePuzzleNode}
		boardColor={boardColor}
		sideLength={sideLength}
	>
		{slidePuzzle.pieces.map((piece, i) => (
			<Piece
				className={`piece piece-${piece.baseId}`}
				piece={piece}
				onMouseDown={slidePuzzle.handleMouseDown}
				key={piece.baseId}
			>
				{!piece.blank && (
					<React.Fragment>
						<Num className="piece-num">{piece.baseId}</Num>
						<Img className="piece-image" src={piece.image}></Img>
					</React.Fragment>
				)}
			</Piece>
		))}
	</StyledSlidePuzzle>
);

export default SlidePuzzle

このコンポーネントが返すビューは次のコンポーネントで構成されています。

StyledSlidePuzzle

SlidePuzzleが返すビューの親要素とそのスタイルを持つコンポーネントであり、propsとしてclassName、ref、boardColor、sideLengthという名前の値を受け取ります。

これらの値については次の通りです。

説明
className クラス名を指定する。受け取った値はそのままクラス名となる。
ref useRefフックで作成したrefオブジェクトを渡すと、refオブジェクトのcurrentプロパティからそのDOMノードを参照できる。
boardColor 盤面のカラーを文字列で指定する。受け取った値はCSSのbackground-colorプロパティにセットされる
sideLength スライドパズルの一辺の長さを文字列で指定する。受け取った値はCSSのheightとwidthプロパティにセットされる。

Piece

スライドパズルのピースのビューとそのスタイルを持つコンポーネントであり、propsとしてclassName、piece、onMouseDown、keyという名前の値を受け取ります。

これらの値については次の通りです。

説明
className クラス名を指定する。受け取った値はそのままPieceコンポーネントが描画するDOMノードのクラス名となる。
piece ピースの情報をプロパティとして持つオブジェクトであり、詳しくは後述します。
onMouseDown ピースが押下されたときに実行する関数
key Pieceコンポーネントはループ処理で複数描画するため、そうした場合Reactではkeyを指定する必要がある。

Img

ピースの画像のビューとそのスタイルを持つコンポーネントであり、propsとしてclassNameとsrcを受け取ります。srcは画像のURLとなります。

Num

ピースの左上に表示する番号のビューとそのスタイルを持つコンポーネントであり、propsとしてclassNameを受け取ります。このコンポーネントが返すビューはpiece.baseIdをテキストに持ちます。

reducerを作成する

先ほど作成したSlidePuzzleコンポーネントが持つステートの管理はuseReducerフックで管理するため、その際に必要なreducerと呼ばれる関数を作成します。

src配下にreducersディレクトリを作成後、そこにslidePuzzleReducer.jsを作成してslidePuzzleReducer関数を定義します。

reducers/slidePuzzleReducer.js

const slidePuzzleReducer = (state, action) => {
	switch (action.type) {
		case "RESIZE_SLIDE_PUZZLE": {
			if (!state.pieces.length) {
				return {
					...state
				};
			}

			const pieces = [...state.pieces];
			const pieceLength = state.pieceLength;
			const width = action.width;
			const height = action.height;

			const rows = Math.sqrt(pieceLength);
			const cols = rows;

			let count = 0;

			for (let i = 0; i < cols; i++) {
				for (let j = 0; j < rows; j++) {
					const pieceWidth = width / rows;
					const pieceHeight = height / cols;

					pieces[count].x *= pieceWidth / pieces[count].width;
					pieces[count].y *= pieceHeight / pieces[count].height;
					pieces[count].basePointX *= pieceWidth / pieces[count].width;
					pieces[count].basePointY *= pieceHeight / pieces[count].height;
					pieces[count].height = pieceHeight;
					pieces[count].width = pieceWidth;

					count++;
				}
			}

			return {
				...state,
				pieces,
				height,
				width
			};
		}

		case "RESET_SLIDE_PUZZLE": {
			return {
				...state,
				pieces: [],
				pieceLength: 0,
				bg: null,
				isLock: true,
				isComplete: false,
				isShuffle: false,
				width: 0,
				height: 0,
				trouble: 0
			};
		}

		case "SHUFFLE_PIECES": {
			const pieces = [...state.pieces];

			pieces.map((piece) => ({
				...piece,
				isShuffle: false
			}));

			const pieceLength = pieces.length;

			const blank = pieces.find((piece) => piece.blank);

			const piecesNextToBlank = pieces.filter((piece, i) => {
				return (
					(piece.id === blank.id - 1 && blank.y === piece.y) || 
					(piece.id === blank.id + 1 && blank.y === piece.y) || 
					(piece.id === blank.id - Math.sqrt(pieceLength) && blank.x === piece.x) ||
					(piece.id === blank.id + Math.sqrt(pieceLength) && blank.x === piece.x)
				);
			});

			const r = Math.floor(Math.random() * piecesNextToBlank.length);

			const movingPiece = piecesNextToBlank[r];

			if (!movingPiece) {
				return {
					...state,
					pieces
				};
			}

			if (movingPiece.isShuffle) {
				return {
					...state,
					pieces: pieces.map((piece) => ({
						...piece,
						isShuffle: false
					}))
				};
			}

			return {
				...state,
				pieces: pieces.map((piece) =>
					piece === movingPiece
						? {
								...piece,
								id: blank.id,
								x: blank.x,
								y: blank.y,
								basePointX: blank.x,
								basePointY: blank.y,
								isShuffle: true
						  }
						: piece === blank
						? {
								...piece,
								id: movingPiece.id,
								x: movingPiece.x,
								y: movingPiece.y,
								basePointX: movingPiece.x,
								basePointY: movingPiece.y,
								isShuffle: false
						  }
						: piece
				)
			};
		}

		case "SORT_PIECES": {
			const pieces = [...state.pieces].sort((a, b) => a.id - b.id);

			return {
				...state,
				pieces
			};
		}

		case "DRAG_START_PIECE": {
			const pieces = [...state.pieces].map((piece) =>
				piece.baseId === action.num
					? {
							...piece,
							isDrag: true
					  }
					: piece
			);

			return {
				...state,
				pieces
			};
		}

		case "LIMIT_MOVEMENT_PIECE": {
			const pieces = [...state.pieces];
			const width = state.width;
			const height = state.height;

			pieces.forEach((piece) => {
				if (piece.isDrag) {
					if (piece.x <= 0) {
						piece.offsetX = 0;
					}

					if (piece.x + piece.width >= width) {
						piece.offsetX = 0;
					}

					if (piece.y <= 0) {
						piece.offsetY = 0;
					}

					if (piece.y + piece.height >= height) {
						piece.offsetY = 0;
					}
				}
			});

			return {
				...state,
				pieces
			};
		}

		case "DRAG_MOVE_PIECE": {
			const pieces = [...state.pieces];
			const pieceLength = pieces.length;
			const elementOffsetX = action.elementOffsetX;
			const elementOffsetY = action.elementOffsetY;

			pieces.forEach((piece) => {
				if (piece.isDrag) {
					if (
						pieces[piece.id - 1] &&
						pieces[piece.id - 1].blank &&
						pieces[piece.id - 1].y === piece.y
					) {
						pieces[piece.id] = {
							...piece,
							offsetX: clamp(elementOffsetX, -piece.width, 0)
						};
					} else if (
						pieces[piece.id + 1] &&
						pieces[piece.id + 1].blank &&
						pieces[piece.id + 1].y === piece.y
					) {
						pieces[piece.id] = {
							...piece,
							offsetX: clamp(elementOffsetX, 0, piece.width)
						};
					} else if (
						pieces[piece.id - Math.sqrt(pieceLength)] &&
						pieces[piece.id - Math.sqrt(pieceLength)].blank &&
						pieces[piece.id - Math.sqrt(pieceLength)].x === piece.x
					) {
						pieces[piece.id] = {
							...piece,
							offsetY: clamp(elementOffsetY, -piece.height, 0)
						};
					} else if (
						pieces[piece.id + Math.sqrt(pieceLength)] &&
						pieces[piece.id + Math.sqrt(pieceLength)].blank &&
						pieces[piece.id + Math.sqrt(pieceLength)].x === piece.x
					) {
						pieces[piece.id] = {
							...piece,
							offsetY: clamp(elementOffsetY, 0, piece.height)
						};
					}
				}
			});

			return {
				...state,
				pieces
			};
		}

		case "DRAG_END_PIECE": {
			const pieces = [...state.pieces];
			const pieceLength = pieces.length;

			const replacePiece = (targetPiece, blank) => {
				pieces[targetPiece.id] = {
					...pieces[targetPiece.id],
					id: blank.id,
					x: blank.basePointX,
					y: blank.basePointY,
					basePointX: blank.basePointX,
					basePointY: blank.basePointY,
					offsetX: 0,
					offsetY: 0
				};

				pieces[blank.id] = {
					...pieces[blank.id],
					id: targetPiece.id,
					x: targetPiece.basePointX,
					y: targetPiece.basePointY,
					basePointX: targetPiece.basePointX,
					basePointY: targetPiece.basePointY,
					offsetX: 0,
					offsetY: 0
				};
			};

			let trouble = 0;

			pieces.forEach((piece) => {
				if (piece.isDrag) {
					if (
						Math.abs(piece.offsetX) > piece.width / 2 ||
						Math.abs(piece.offsetY) > piece.height / 2
					) {
						if (piece.offsetX < 0) {
							const blank = pieces[piece.id - 1];
							replacePiece(piece, blank);
						} else if (piece.offsetX > 0) {
							const blank = pieces[piece.id + 1];
							replacePiece(piece, blank);
						} else if (piece.offsetY < 0) {
							const blank = pieces[piece.id - Math.sqrt(pieceLength)];
							replacePiece(piece, blank);
						} else if (piece.offsetY > 0) {
							const blank = pieces[piece.id + Math.sqrt(pieceLength)];
							replacePiece(piece, blank);
						}

						trouble = state.trouble + 1;
					} else {
						piece.offsetX = 0;
						piece.offsetY = 0;

						trouble = state.trouble;
					}
				}
			});

			return {
				...state,
				pieces: pieces.map((piece) =>
					piece.isDrag
						? {
								...piece,
								isDrag: false
						  }
						: piece
				),
				trouble
			};
		}

		case "CHANGE_SLIDE_PUZZLE_BG": {
			return {
				...state,
				bg: action.url
			};
		}

		case "CHANGE_PIECE_LENGTH": {
			return {
				...state,
				pieceLength: action.num
			};
		}

		case "INITIALIZE_SLIDE_PUZZLE": {
			const pieces = [...state.pieces];
			const pieceLength = action.pieceLength;
			const height = action.height;
			const width = action.width;
			const pieceImages = action.pieceImages;

			const rows = Math.sqrt(pieceLength);
			const cols = rows;

			let count = 0;

			for (let i = 0; i < cols; i++) {
				for (let j = 0; j < rows; j++) {
					const pieceHeight = height / cols;
					const pieceWidth = width / rows;

					const x = j * pieceHeight;
					const y = i * pieceHeight;

					pieces[count] = {
						...pieces[count],
						id: count,
						baseId: count,
						image: pieceImages[count],
						x,
						y,
						basePointX: x,
						basePointY: y,
						blank: count === pieceLength - 1 ? true : false,
						isDrag: false,
						isShuffle: false,
						height: pieceHeight,
						width: pieceWidth,
						offsetX: 0,
						offsetY: 0
					};

					count++;
				}
			}

			return {
				...state,
				pieces,
				pieceLength
			};
		}

		case "START_SHUFFLE_PIECES": {
			return {
				...state,
				isShuffle: true
			};
		}

		case "STOP_SHUFFLE_PIECES": {
			const pieces = [...state.pieces].map((piece) => ({
				...piece,
				isShuffle: false
			}));

			return {
				...state,
				pieces,
				isShuffle: false
			};
		}

		case "LOCK_SLIDE_PUZZLE": {
			return {
				...state,
				isLock: true
			};
		}

		case "UNLOCK_SLIDE_PUZZLE": {
			return {
				...state,
				isLock: false
			};
		}

		case "COMPLETE_SLIDE_PUZZLE": {
			return {
				...state,
				isComplete: true
			};
		}

		case "INCOMPLETE_SLIDE_PUZZLE": {
			return {
				...state,
				isComplete: false
			};
		}

		case "RESET_TROUBLE": {
			return {
				...state,
				trouble: 0
			};
		}

		default:
			throw new Error();
	}
};

slidePuzzleReducerについては後述します。

カスタムフックを作成する

コンポーネントからロジック部分を抽出した関数であるカスタムフックを作成します。

useDragAndDrop

hooks/useDragAndDrop.jsに以下のコードを書き、useDragAndDropフックを作成します。

useDragAndDropフックについては[【React hooks】要素のドラッグ&ドロップを可能にするカスタムフックを作成する][2]をご覧ください。

useResizeObserver

hooks/useResizeObserver.jsに以下のコードを書き、useResizeObserverフックを作成します。

useResizeObserverフックについては[【React hooks】要素のリサイズを検知して何かをするカスタムフックを作成する][3]をご覧ください。

useSlidePuzzle

SlidePuzzleコンポーネントのロジック部分であるuseSlidePuzzleフックを作成します。

hooks/useSlidePuzzle.js

const initialState = {
	isLock: true,
	isShuffle: false,
	isComplete: false,
	bg: null,
	pieces: [],
	pieceLength: 0,
	width: 0,
	height: 0,
	trouble: 0
};

const useSlidePuzzle = (bg, pieceLength) => {
	const [
		element,
		pointer,
		handleMouseDown,
		setElementOffset,
		resetElementOffset,
		setElementPosition
	] = useDragAndDrop();

	const { currentDragElement, elementPosition, elementOffset } = element;

	const { pointerStartPosition, pointerMovePosition } = pointer;

	const [slidePuzzle, dispatch] = useReducer(slidePuzzleReducer, initialState);

	const slidePuzzleNode = useRef(null);
	const intervalId = useRef(null);

	const initializeSlidePuzzle = async () => {
		const width = slidePuzzleNode.current.clientWidth;
		const height = slidePuzzleNode.current.clientHeight;

		const rows = Math.sqrt(pieceLength);
		const cols = rows;
		const squareImageUrl = await getSquareImage(bg, width).then((r) => r);
		const pieceImages = divideImage(squareImageUrl, rows, cols);

		pieceImages.then((images) =>
			dispatch({
				type: "INITIALIZE_SLIDE_PUZZLE",
				pieceLength,
				pieceImages: images,
				height,
				width
			})
		);
	};

	const changeSlidePuzzleBg = useCallback((url) =>
		dispatch({
			type: "CHANGE_SLIDE_PUZZLE_BG",
			url
		})
	);

	const changePieceLength = useCallback((num) =>
		dispatch({
			type: "CHANGE_PIECE_LENGTH",
			num
		})
	);

	const startShufflePieces = useCallback(() =>
		dispatch({
			type: "START_SHUFFLE_PIECES"
		})
	);

	const stopShufflePieces = useCallback(() =>
		dispatch({
			type: "STOP_SHUFFLE_PIECES"
		})
	);

	const lockSlidePuzzle = useCallback(() =>
		dispatch({
			type: "LOCK_SLIDE_PUZZLE"
		})
	);
	const unlockSlidePuzzle = useCallback(() =>
		dispatch({
			type: "UNLOCK_SLIDE_PUZZLE"
		})
	);

	const completeSlidePuzzle = useCallback(() =>
		dispatch({
			type: "COMPLETE_SLIDE_PUZZLE"
		})
	);
	const incompleteSlidePuzzle = useCallback(() =>
		dispatch({
			type: "INCOMPLETE_SLIDE_PUZZLE"
		})
	);

	const resetSlidePuzzle = useCallback(() =>
		dispatch({
			type: "RESET_SLIDE_PUZZLE"
		})
	);

	const resetTrouble = useCallback(() =>
		dispatch({
			type: "RESET_TROUBLE"
		})
	);

	useResizeObserver([slidePuzzleNode], (entries) => {
		const height = entries[0].contentRect.height;
		const width = entries[0].contentRect.width;

		if (height === 0 || width === 0) return;

		dispatch({
			type: "RESIZE_SLIDE_PUZZLE",
			width,
			height
		});
	});

	useEffect(() => {
		if (!slidePuzzle.isShuffle) return;

		let count = 0;

		intervalId.current = setInterval(() => {
			count++;

			dispatch({
				type: "SHUFFLE_PIECES"
			});

			if (count > MAX_SHUFFLE_COUNT) {
				clearInterval(intervalId.current);

				dispatch({
					type: "SORT_PIECES"
				});

				dispatch({
					type: "STOP_SHUFFLE_PIECES"
				});

				dispatch({
					type: "UNLOCK_SLIDE_PUZZLE"
				});
			}
		}, SHUFFLE_SPEED);
	}, [slidePuzzle.isShuffle]);

	// mousedown
	useEffect(() => {
		if (slidePuzzle.isLock) return;

		if (!currentDragElement.current) return;

		const num = parseFloat(
			currentDragElement.current.className.replace(/[^0-9]/g, "")
		);

		dispatch({
			type: "DRAG_START_PIECE",
			num
		});
	}, [elementPosition]);

	// mousemove
	useEffect(() => {
		if (slidePuzzle.isLock) return;

		if (!currentDragElement.current) return;

		dispatch({
			type: "LIMIT_MOVEMENT_PIECE"
		});

		dispatch({
			type: "DRAG_MOVE_PIECE",
			elementOffsetX: elementOffset.x,
			elementOffsetY: elementOffset.y
		});
	}, [elementOffset.x, elementOffset.y]);

	// mouseup
	useEffect(() => {
		if (slidePuzzle.isLock) return;

		if (currentDragElement.current) return;

		dispatch({
			type: "DRAG_END_PIECE"
		});

		dispatch({
			type: "SORT_PIECES"
		});

		resetElementOffset();
	}, [elementPosition]);

	useEffect(() => {
		if (slidePuzzle.isLock) return;

		if (currentDragElement.current) return;

		const matchPieces = slidePuzzle.pieces.filter(
			(piece) => piece.id === piece.baseId
		);

		if (matchPieces.length === slidePuzzle.pieceLength) {
			dispatch({
				type: "COMPLETE_SLIDE_PUZZLE"
			});
		}
	}, [slidePuzzle.pieces]);

	return [
		{
			pieces: slidePuzzle.pieces,
			slidePuzzleNode,
			handleMouseDown
		},
		initializeSlidePuzzle,
		changeSlidePuzzleBg,
		changePieceLength,
		startShufflePieces,
		stopShufflePieces,
		lockSlidePuzzle,
		unlockSlidePuzzle,
		slidePuzzle.isComplete,
		completeSlidePuzzle,
		incompleteSlidePuzzle,
		resetSlidePuzzle,
		slidePuzzle.trouble,
		resetTrouble
	];
};

このフックは引数としてbgとpieceLengthを受け取ります。bgはスライドパズルの絵となる画像のURLであり、pieceLengthはピースの枚数です。

また、このフックはスライドパズルのステート、スライドパズルのDOMノードの参照、イベントハンドラー、ステートを操作する関数群を返します。

スライドパズルのステートはSlidePuzzleコンポーネントが持つステートであり、このステートは複数の階層からなるオブジェクトであるため、useStateフックではなくuseReducerフックで管理します。

useReducerフックは引数としてステートの初期値initialStateとreducerを与えて呼び出すと、現在のステートとそれを更新するために必要なdispatchメソッドを返します。

const [slidePuzzle, dispatch] = useReducer(slidePuzzleReducer, initialState);

このdispatchメソッドはアクションを発生させるメソッドであり、呼び出す際は以下のように引数としてアクションオブジェクトを与えます。

dispatch({ 
  type: 'INITIALIZE',
  x: 0,
  y: 0
})

アクションオブジェクトのプロパティには、アクション名となるtypeプロパティを設定する他、上記のようにreducerでステートを更新する処理で使用する値を渡すこともできます。

アクションが発生すると、reducerがそれをキャッチしてアクション名に紐づいたステートの更新処理が行われます。

reducer

case "INITIALIZE": {
  return {
    ...state,
    x: action.x,
    y: action.y
  };
}

ステートを操作する関数群のうち、特に重要な関数はinitializeSlidePuzzle関数です。

initializeSlidePuzzle関数はスライドパズルを初期化する関数であり、以下のような処理を行います。

  1. getSquareImage関数でuseSlidePuzzleフックが引数として受け取った画像のURLから正四角形の画像を作成し、そのURLを取得する。
  2. divideImage関数を実行して、1.の画像を、与えた分割数で分割した画像を配列で取得する。
  3. dispatch関数を実行して、INITIALIZESLIDEPUZZLEアクションを発生させてスライドパズルのステートを初期化する。

dispatch関数を実行する際にプロパティとしてピースの枚数、2.で取得した配列、スライドパズルのDOMノードから参照したサイズを渡します。

INITIALIZESLIDEPUZZLEアクションが発生すると、reducerによって以下のようにステートを更新してスライドパズルが初期化されます。

ステートのオブジェクトが持つpieces配列にピースを表すオブジェクトを枚数分つっこみます。

このオブジェクトには次のプロパティを持たせます。

プロパティ
id 配列内におけるピースの番号。移動するたびに変わる。
baseId ピースの番号。変わることはない。
image ピースの画像URL。
x(y) ピースの位置
basePointX(Y) ピースの元々の位置
isBlank 空白かどうかを示す値
isDrag ドラッグされているかどうかを示す値。初期値はfalse
isShuffle シャッフルによる移動の対象かどうかを示す値。初期値はfalse
height(width) ピースのサイズ
offsetX(Y) ピースのオフセット。初期値は0

尚、ピースは下記の順で配置されるようにします。

image puzzle

そのため、ピースのオブジェクトも上記と同じ順でpieces配列にセットします。

しかし、x(y)とbasePointX(Y)を算出する際、通常のループ処理では上述のようにセットすることはできません。なので、x(y)とbasePointX(Y)は2重のループ処理で算出します。

また、シャッフル前は、一番最後のピースを空白にするため、isBlankの初期値は、最後にセットしたオブジェクトのみfalseを指定します。

initializeSlidePuzzle関数については以上です。

続いて、このフックが実行されると、以下のことが行われます。

  • あるタイミングでピースがシャッフルされるようにする
  • ドラッグ&ドロップでピースがスライドされるようにする
  • ピースがスライドされる度にクリア判定が行われるようにする
  • ウィンドウサイズの変化に応じてスライドパズルとそのピースの位置・サイズをリサイズ後の値に更新する

あるタイミングでピースがシャッフルされるようにする

あるタイミングというのは、slidePuzzle.isShuffleがtrueに切り替わったときです。このタイミングでピースがシャッフルされるようにします。

その方法として、まずuseEffectフックを以下のようにして呼び出します。

useEffect(() => {
		if (!slidePuzzle.isShuffle) return;

    // ここでシャッフルの処理を行う
	}, [slidePuzzle.isShuffle]);

これにより、slidePuzzle.isShuffleがtrueに切り替わったときにピースのシャッフルが始まります。

次に、第一引数のコールバック関数内でsetInterval関数を呼び出してSHUFFLESPEEDの間隔でSHUFFLEPIECESアクションを発生させてピースをシャッフルします。

SHUFFLE_PIECESアクションが発生すると、reducerによって以下のようにスライドパズルのステートが更新されます。

  1. 一旦すべてのピースオブジェクトのisShuffleプロパティをfalseにする。
  2. 空白に隣接するピースを配列に集める
  3. 2.の配列から1つだけランダムにスライドさせるピースを取得する。
  4. 空白のオブジェクトとピースのオブジェクトのプロパティを入れ替える

上記の1.~4.を繰り返すことでピースのシャッフルを表現しています。

また、ピースが滑らかなスライドでシャッフルされるように、ピースを描画するPieceコンポーネントのうち、中身がtrueであるisShuffleをpropsとして受け取ったPieceコンポーネントのスタイルにtransitionプロパティを指定しています。

const Piece = styled.div`
    // 省略
    transition: ${({ piece }) => (piece.isShuffle ? `all linear 100ms` : "")};
`;

シャッフル数が最大のシャッフル数MAXSHUFFLECOUNTを超えたときに次の処理を行ってシャッフルを停止します。

  • clearInterval関数を実行してsetInterval関数を停止する
  • SORT_PIECESアクションを発生させてpieces配列内のpieceオブジェクトをidで昇順にソートする。
  • STOPSHUFFLEPIECESアクションを発生させてslidePuzzle.isShuffleをfalseに切り替える
  • UNLOCKSLIDEPUZZLEアクションを発せさせてスライドパズルの操作ロックを解除する

ドラッグ&ドロップでピースがスライドされるようにする

先人たちが作ったスライドパズルのほとんどがクリックでピースを動かすように作られていますが、今回作るスライドパズルはドラッグ&ドロップでピースを動かすようにします。

useDragAndDropフックを呼び出して、マウスボタンを押し込んだとき・押し込んだまま動かしたとき・離したときにそれぞれ以下の処理を行います。

マウスボタンを押し込んだとき

useEffectフックを以下のように呼び出すと、マウスボタンを押し込んだときの処理を行うことができます。

useEffect(() => {
    // パズルの操作がロックされていれば終了する
		if (slidePuzzle.isLock) return;

    // 押し込んでいなければ終了する
		if (!currentDragElement.current) return;

		// ここで押下したときの処理を行う
	}, [elementPosition]);

useEffectフックの第二引数の配列にドラッグ要素の位置elementPositionをセットすることで、ドラッグ要素を押し込んだまたは離したときに第一引数のコールバック関数が実行されます。

上記のコールバック関数内でDRAGSTARTPIECEアクションを発生させると、reducerがそのアクションをキャッチし、押し込んだピースのDOMノードのクラス名に含まれる番号とすべてのピースのオブジェクトのbaseIdを比較していき、一致した番号を持つピースのオブジェクトのisDragプロパティをtrueに切り替えます。

マウスボタンを押し込んだまま動かしたとき

useEffectフックを以下のように呼び出すと、マウスボタンを押し込んだまま動かしたときの処理を行うことができます。

useEffect(() => {
		if (slidePuzzle.isLock) return;

		if (!currentDragElement.current) return;

		// do something
	}, [elementOffset.x, elementOffset.y]);

※useEffectフックの第二引数の配列にドラッグ要素のオフセット値elementOffset.x(elementOffset.y)をセットすることで、ドラッグ要素が移動するたびに第一引数のコールバック関数が実行されます。

まず、上記のコールバック関数内でLIMITMOVEMENTPIECEアクションを発生させて、ピースが移動中に枠を超えないようにします。

LIMITMOVEMENTPIECEアクションが発生すると、reducerがそのアクションをキャッチし、isDragプロパティがtrueであるpieceオブジェクトのx(y)プロパティが0以下またはスライドパズルの幅(高さ)以上になったときにoffsetX(Y)を0にして移動を止めます。

次に、DRAGMOVEPIECEアクションを発生させてドラッグでピースが動くようにします。

DRAGMOVEPIECEアクションが発生するとreducerがそのアクションをキャッチし、ドラッグしたピースの隣が空白である場合のみisDragプロパティがtrueであるピースのオブジェクトのoffsetX(Y)プロパティに、ドラッグしているピースのDOMノードのオフセット値elementOffsetX(Y)をセットしてドラッグでピースが動くようにします。

また、隣に別のピースがあるときはピースが動かないようにclamp関数で移動を制限します。

マウスボタンを離したとき

useEffectフックを以下のように呼び出すと、マウスボタンを離したときの処理を行うことができます。

useEffect(() => {
		if (slidePuzzle.isLock) return;

    // 押し込んでいれば終了する
		if (currentDragElement.current) return;

		// do something
	}, [elementPosition]);

上記のコールバック関数内では、まずDRAG_END_PIECEアクションを発生させて、ドロップ時にピースが自身のサイズの半分以上移動していれば空白と入れ替えます。

この処理はreducerが行います。reducerがDRAG_END_PIECEアクションをキャッチして、isDragがtrueであるピースのオブジェクトのoffsetX(Y)プロパティがwidth(height) / 2以上であれば、replacePiece関数でisDragぷがtrueであるピースと空白のプロパティをすべて入れ替えます。

それと同時に、入れ替えが成立した場合は手数のステートであるtroubleを1増やします。

DRAG_END_PIECEアクションについては以上です。

最後に、SORT_PIECESアクションを発生させて、pieces内のピースのオブジェクトをidで昇順に並び替えます。

また、ドロップ後は次のドラッグに備えて、resetElementOffset関数を実行してelementOffsetをリセットします。

ピースがスライドされる度にクリア判定が行われるようにする

ピースがドロップされるたびにクリア判定をします。ピースのドロップ後にクリア判定をするにはuseEffectフックを以下のようにして呼び出します。

useEffect(() => {
  // スライドパズルの操作がロックされていれば以降の処理は行わない
  if (slidePuzzle.isLock) return;

  // ピースがドラッグされていれば以降の処理は行わない
	if (currentDragElement.current) return;

  // ここでクリア判定をする
}, [slidePuzzle.pieces])

クリア判定の方法として、まずピースのオブジェクトが含まれたslidePuzzle.pieces配列のfilterメソッドでピースの元々の番号piece.baseIdと現在の位置番号piece.idを比較して、一致しているピースだけを集めたmatchPieces配列を新たに作成します。

const matchPieces = slidePuzzle.pieces.filter(
			(piece) => piece.id === piece.baseId
		);

そして、matchPieces配列の要素とピースの数を比較し、一致していればすべて揃ったということなのでdispatchでCOMPLETE_SLIDE_PUZZLEアクションを発生させます。

if (matchPieces.length === slidePuzzle.pieceLength) {
			dispatch({
				type: "COMPLETE_SLIDE_PUZZLE"
			});
		}

COMPLETE_SLIDE_PUZZLEアクションが発生すると、reducerによってisCompleteがtrueに切り替えてゲームクリアしたことを示します。

このisCompleteは、ゲームクリアしたときに何かをしたいときに使います。

ウィンドウサイズの変化に応じてスライドパズルとそのピースの位置・サイズをリサイズ後の値に更新する

スライドパズルがリサイズされる度にスライドパズルのサイズとピースの位置・サイズを更新します。

その方法として、useResizeObserverフックを呼び出してスライドパズルのDOMノードを監視し、監視中にDOMノードのリサイズを検知したときにdispatchでRESIZESLIDEPUZZLEアクションを発生させます。またその際、プロパティとしてリサイズ後のサイズを渡します。

const height = entries[0].contentRect.height;
		const width = entries[0].contentRect.width;

		if (height === 0 || width === 0) return;

		dispatch({
			type: "RESIZE_SLIDE_PUZZLE",
			width,
			height
		});

RESIZESLIDEPUZZLEアクションが発生すると、reducerによってまずスライドパズル全体のサイズを受け取ったリサイズ後のサイズで更新します。

次にピースの更新です。ピースの元々の位置basePointX(Y)と現在の位置x(y)、そしてサイズheight(width)の規模を拡大また縮小します。

規模の拡大・縮小の方法は、まずリサイズ後のスライドパズルのサイズをピースの枚数の平方根で割ってリサイズ後のピースのサイズを算出します。

そして、はじめにピースのサイズはそのままリサイズ後のサイズで上書きします。次に、ピースの位置はピースのリサイズ前のサイズに対するリサイズ後のサイズの変化率を元々の位置と現在の位置に掛けて規模を拡大または縮小します。

スライドパズルゲームを表示する

画面上にスライドパズルゲームを表示します。

App.jsを以下のように書き換えます。

App.js

const App = () => {
	const [isStartGame, setIsStartGame] = useState(false);
	const [level, setLevel] = useState(LEVEL.easy);
	const [selectBg, setSelectBg] = useState(BG);
	const [
		slidePuzzle,
		initializeSlidePuzzle,
		changeSlidePuzzleBg,
		changePieceLength,
		startShufflePieces,
		stopShufflePieces,
		lockSlidePuzzle,
		unlockSlidePuzzle,
		isComplete,
		completeSlidePuzzle,
		incompleteSlidePuzzle,
		resetSlidePuzzle,
		trouble,
		resetTrouble
	] = useSlidePuzzle(selectBg, level);

	useEffect(() => {
		initializeSlidePuzzle();
	}, []);

	const startGame = useCallback(() => {
		setIsStartGame(true);
		startShufflePieces();
	}, []);

	useEffect(() => {
		if (!isComplete) return;
		lockSlidePuzzle();
	}, [isComplete]);

	const resetGame = useCallback(() => {
		setIsStartGame(false);
		resetSlidePuzzle();
		initializeSlidePuzzle();
	}, []);

	return (
		<StyledApp>
			<Modal
				title="Game clear!"
				text={`your trouble is ${trouble}.`}
				isShow={isComplete}
			/>
			<SlidePuzzleWrapper>
				<FlexRowSpaceBetween>
					<Display
						className="troble"
						title="Trouble"
						text={trouble}
						fontSize="2vmin"
					/>
					<Display
						className="level"
						title="Level"
						text={
							level === LEVEL.easy
								? "easy"
								: level === LEVEL.normal
								? "normal"
								: level === LEVEL.hard
								? "hard"
								: "easy"
						}
						fontSize="2vmin"
					/>
				</FlexRowSpaceBetween>
				<Margin bottom="1.2vmin" />
				<SlidePuzzle
					className="slide-puzzle"
					slidePuzzle={slidePuzzle}
					boardColor={BOARD_COLOR}
					sideLength="60vmin"
				/>
				<Margin top="3vmin">
					<FlexRowCenter>
						<Button
							bgColor="orange"
							color="#333"
							mRight=".5em"
							onClick={startGame}
							isDisabled={isStartGame ? true : false}
						>
							Start
						</Button>
						<Button
							className="dialog-button"
							color="#333"
							onClick={resetGame}
							mLeft=".5em"
							isDisabled={isStartGame ? false : true}
						>
							Reset
						</Button>
					</FlexRowCenter>
				</Margin>
			</SlidePuzzleWrapper>
		</StyledApp>
	);
};

App.jsを上書き保存すると、画面上にスライドパズルゲームが表示されます。

DEMO

下記のデモで実際にスライドパズルゲームを楽しむことができます。楽しんでみましょう。

参考文献