ツクログネット

【React Hooks + TypeScript】ドラッグ&ドロップで遊ぶスライドパズルを作る その4「ピースをシャッフルする」

eye catch

前回の記事では、実物のスライドパズルと同様に、ピースがパズル内にできるスペースのみ移動するようにしました。

ですが、今のままでは初めから絵柄が揃った完成した状態であるため、今回はプレイヤーが始めるタイミングでピースがシャッフルされるような機能を実装します。

連載目次

  1. ページ上にスライドパズルを表示する
  2. ピースをドラッグで動かす
  3. ピースの移動範囲を制限する
  4. ピースをシャッフルする(この記事)
  5. 完成画面を表示する
  6. 制限時間を設ける
  7. 難易度を設ける
  8. 好きな画像を選べるようにする
  9. 画面サイズの変化に合わせてそれまでの比率を維持したままスライドパズルがリサイズされるようにする

ディレクトリ構成

現在のsrc配下は下記の通りです。

react-slide-puzzle

 └── src
     ├── assets
     ├── components
     |   ├── Blank.tsx
     |   ├── SlidePuzzle.tsx
     |   └── SlidePuzzlePiece.tsx
     ├── constants
     |   └── slidePuzzle.ts
     ├── hooks
     |   ├── useDraggableElements.ts
     |   └── useSlidePuzzle.ts
     ├── types
     |   ├── SlidePuzzle.ts
     |   └── SlidePuzzlesPiece.ts
     ├── utils
     |   ├── clamp.ts
     |   └── images.ts
     ├── App.css
     ├── App.tsx
     ├── index.css
     ├── main.tsx
     └── vite-env.d.ts

シャッフルの仕組み

シャッフルの仕組みは、空白に隣接しているピースの中からランダムに1つ選んだピースを、空白の方向へ移動させるという一連の動作を繰り返し行うことで表現します。

ts slide puzzle shuffle img1

実装する

では、シャッフルの仕組みを理解したところで、実装を行っていきます。

実装の手順は次の通りです。

  1. piecesから空白に隣接しているpieceを抽出する
  2. 1.で抽出したpieceの中からランダムに1つpieceを取得する
  3. 2.で取得したpieceと空白を示すpieceの位置を入れ替える
  4. 1.~3.の処理を繰り返し行う

1. piecesから空白に隣接しているpieceを抽出する

まず、piecesからgetSlidablePiecesという関数を用いて空白に隣接しているピースを示すpieceを抽出します。抽出されたpieceは配列として返されます。

// 空白の上下左右で隣接しているピースを抽出する
const slidablePieces = getSlidablePieces(prevPieces);

getSlidablePieces関数については以下の通りです。

// 空白に隣接しているすべてのピースを取得する関数
	const getSlidablePieces = (pieces: PieceData[]): PieceData[] => {
		const blank = getBlank(pieces);
		return [...pieces].filter((piece, i) => isNextToBlank(pieces, piece.index));
	};

2. 1.で抽出したpieceの中からランダムに1つpieceを取得する

次に、getRandomValueという関数を用いて、先ほど抽出したピースの中からランダムに1つpieceを取得します。

// ランダムに一つだけピースを取得する
const r = getRandomValue(slidablePieces.length);
const target = slidablePieces[r];

3. 2.で取得したpieceと空白を示すpieceの位置を入れ替える

そして、ランダムに取得したピースと空白の位置が入れ替わるように、互いのプロパティ値を入れ替えます。対象のピースが空白の左側に隣接している場合を例に示すと以下の通りです。

const targetIndex = target.index;
const targetColNumber = target.colNumber;
const targetRowNumber = target.rowNumber;

const movementWidth = target.width;

if(isNextToOther(target, blank, 'left')) {
	target.x += movementWidth;
	target.colNumber = blankColNumber;

	blank.x -= movementWidth;
	blank.colNumber = targetColNumber;					
}

// 省略

target.index = blankIndex;		
blank.index = targetIndex;

入れ替えるプロパティは位置に関係するプロパティです。

  • xまたはy
  • colNumberまたはrowNumber
  • index

xまたはyの入れ替えについて

xまたはyは、ピースと空白が互いの方向へ移動するように、移動幅movementWidthを加算・減算します。

対象のピースが空白の左に隣接している場合であれば、movementWidthをtarget.xには加算、blank.xには減算することになります。

if(isNextToOther(target, blank, 'left')) {
  target.x += movementWidth;
  blank.x -= movementWidth;			
}

colNumberまたはrowNumberの入れ替えについて

colNumberまたはrowNumberは、水平方向の入れ替えであれば列の順番、垂直方向であれば行の順番が変わることになるため、プロパティ側もそうなるように入れ替えます。

水平方向の入れ替えであれば以下のように入れ替えます。

if(
  isNextToOther(target, blank, 'left') ||
  isNextToOther(target, blank, 'right')
) {
	target.colNumber = blankColNumber;
	blank.colNumber = targetColNumber;					
}

indexの入れ替えについて

indexは、方向関係なしに入れ替えます。

target.index = blankIndex;		
blank.index = targetIndex;

プロパティの入れ替え後

プロパティの入れ替え後は、その変更を画面上のスライドパズルに反映させるために、入れ替え後のpieceを入れ替え前のpieceに上書きしてpiecesステートが更新されるようにします。

setPieces((prevPieces) => {
  // 省略

  // pieces内のpieceを書き換える
	// ステートの中身を書き換えないと画面が更新(再描画)されないため
	const newPieces = prevPieces.map(piece => {
		if(piece.index === target.index) {
			return target;
		}else if(piece.index === blank.index) {
			return blank;
		}

		return piece;
	});

	return newPieces;
});

4. 1.~3.の処理を繰り返し行う

最後に、これまでの処理が繰り返し行われるようにします。

手順は以下の通りです。

  1. setIntervalメソッドで繰り返す
  2. 繰り返す回数を決める

1. setIntervalメソッドで繰り返す

これまでの処理を繰り返し行うにはsetIntervalメソッドを用います。

まず、shufflePiecesという関数を作成し、その中にこれまでの処理を閉じ込めます。

const shufflePieces = (): void => {
		setPieces((prevPieces) => {
			const blank = getBlank(prevPieces);
			const blankIndex = blank.index;
			const blankColNumber = blank.colNumber;
			const blankRowNumber = blank.rowNumber;

			// 空白の上下左右で隣接しているピースを抽出する
			const slidablePieces = getSlidablePieces(prevPieces);

			// movablePiecesからランダムに一つだけピースを取得する
			const r = getRandomValue(slidablePieces.length);
			const target = slidablePieces[r];

			const targetIndex = target.index;
			const targetColNumber = target.colNumber;
			const targetRowNumber = target.rowNumber;

			const movementWidth = target.width;

			if(isNextToOther(target, blank, 'left')) {
				target.x += movementWidth;
				target.colNumber = blankColNumber;

				blank.x -= movementWidth;
				blank.colNumber = targetColNumber;					
			}else if(isNextToOther(target, blank, 'right')){
				target.x -= movementWidth;
				target.colNumber = blankColNumber;

				blank.x += movementWidth;
				blank.colNumber = targetColNumber;
			}else if(isNextToOther(target, blank, 'up')){
				target.y += movementWidth;	
				target.rowNumber = blankRowNumber;

				blank.y -= movementWidth;
				blank.rowNumber = targetRowNumber;			
			}else if(isNextToOther(target, blank, 'down')){
				target.y -= movementWidth;
				target.rowNumber = blankRowNumber;

				blank.y += movementWidth;
				blank.rowNumber = targetRowNumber;						
			}

			target.index = blankIndex;		
			blank.index = targetIndex;

			// pieces内のpieceを書き換える
			// ステートの中身を書き換えないと画面が更新(再描画)されないため
			const newPieces = prevPieces.map(piece => {
				if(piece.index === target.index) {
					return target;
				}else if(piece.index === blank.index) {
					return blank;
				}

				return piece;
			});

			return newPieces;
		});
	};

次に、setIntervalメソッドを用いて、shufflePieces関数が繰り返し呼び出されるようにします。

この処理は、新たにstartShuffleという関数を作成し、その中で行います。

// ピースのシャッフルを開始する関数
	const startShuffle = useCallback(shuffleSpeed: number): void => {
		setInterval(() => shufflePieces(), shuffleSpeed);
	}, []);

startShuffle関数は、引数としてシャッフルの速さshuffleSpeedを受け取ります。 受け取ったshuffleSpeedは、setIntervalメソッドの第二引数に与えます。

2. シャッフルの回数を決める

今のままではsetIntervalメソッドによる繰り返し処理が無限に行われてしまうため、繰り返す回数(シャッフル数)を決めてその回数に達したときに止まるようにします。

まず、startShuffleとshufflePieces関数が引数として最大シャッフル数を受け取るようにします。

const shufflePieces = (maxShuffleCount: number): void => {/* ... */}
// ピースのシャッフルを開始する関数
	const startShuffle = useCallback(maxShuffleCount: number, shuffleSpeed: number): void => {
		setInterval(() => shufflePieces(maxShuffleCount), shuffleSpeed);
	}, []);

これにより、startShuffle関数を使う側がシャッフル数を決められるようになりました。

次に、シャッフルが行われる度にシャッフルの回数をカウントします。

まず、useRefフックを用いてシャッフル数を保持するための変数を作成します。

// 現在のシャッフル回数
const shuffleCount = useRef(0);

次に、shufflePieces関数の末尾でshuffleCount.currentをインクリメントし、シャッフルが行われる度にシャッフル数がカウントされるようにします。

const shufflePieces = (maxShuffleCount: number): void => {
  // 省略

  shuffleCount.current++;
}

最後に、シャッフルの回数shuffleCount.currentが最大シャッフ回数maxShuffleCountに達したときに、シャッフルが止まるようにします。

まず、useRefフックを用いてsetIntervalメソッドが返すIDを保持するための変数intervalIDを作成します。

const intervalID = useRef(null);

そして、setIntervalメソッドが返すIDをintervalIDに代入します。

// ピースのシャッフルを開始する関数
	const startShuffle = useCallback(maxShuffleCount: number, shuffleSpeed: number): void => {
		intervalID.current = setInterval(() => shufflePieces(maxShuffleCount), shuffleSpeed);
	}, []);

続いて、シャッフルを止めるstopShuffleという関数を作成します。

// ピースのシャッフルを止める関数
	const stopShuffle = (): void => {
		clearInterval(intervalID.current);

		shuffleCount.current = 0;

		// currentIdプロパティが昇順に並ぶようにソートする
		// ピースをcurrentId順に並び替えたあとにx,yをリセット
		setPieces((prevPieces) =>
			prevPieces
				.sort((a, b) => a.index - b.index)
				.map((piece) => ({
					...piece,
					x: 0,
					y: 0
				}))
		);

		setIsShuffling(false);
	};

stopShuffle関数では、以下のことを行っています。

  • shufflePieces関数の繰り返し実行を止める
  • シャッフル回数をリセットする
  • pieces内のpieceからシャッフルの痕跡を無くす

それぞれ詳しく見ていきます。

shufflePieces関数の繰り返し実行を止める

clearIntervalメソッドで、startShuffle関数内で呼び出したsetIntervalメソッドによるshufflePieces関数の繰り返し実行を止めています。

clearInterval(intervalID.current);
シャッフル回数をリセットする

再びshufflePiece関数が呼ばれた場合、シャッフル回数shuffleCount.currentは最大シャッフル数maxShuffleCountに達した状態のままであるため、次回に備えてリセットします。

shuffleCount.current = 0;
pieces内のpieceからシャッフルの痕跡を消す

シャッフル終了後のpiece.x, yの値は、加算されたり減算されたりして複雑になっているため、この後に移動範囲を指定する際に計算が困難になります。そのため、一旦piece.x, yの値を0にリセットし、まっさらな状態にしています。

prevPieces
				.map((piece) => ({
					...piece,
					x: 0,
					y: 0
				}))

ただ、上記のようにpiece.x, yを0にしただけでは、ピースの並びがシャッフル前の状態に戻るだけであるため、sortメソッドでシャッフルによって入れ替わったindexの順にpieceを並び替えています。

prevPieces
				.sort((a, b) => a.index - b.index)
				.map((piece) => ({
					...piece,
					x: 0,
					y: 0
				}))

これによってピースの並びがシャッフル後の状態に変わります。

最後に、最大シャッフル数に達したときにシャッフルを止める処理をshufflePieces関数の先頭に追加します。

const shufflePieces = (maxShuffleCount: number): void => {
  if(shuffleCount.current >= maxShuffleCount) {
			stopShuffle();

      // ここでshufflePieces関数を終了させる
			return;
		}

  // 省略
}

この処理を先頭で行う理由は、シャッフルを止めるかどうかは次のシャッフルが行われる前に決定するためです。

ボタンでシャッフルを始める

startShuffle関数が完成したことで、シャッフルの実装は完了しました。

それでは、このstartShuffle関数を実行してシャッフルが行われるようにします。

では、どのタイミングで実行するのかというと、今回はボタンを用意し、それが押されたときに実行されるようにします。

シャッフルボタンを実装する

まず、シャッフルを開始させるボタンを実装します。

styled-componentsを用いて以下のようなボタンのコンポーネントを作成します。

type ButtonComponentProps = {
	label?: string;
	isDisabled?: boolean;
	colors?: {
		background?: string;
		color?: string;
	};
	callback?: () => void;
	children: React.ReactNode;
	sizes?: {
		font?: string;
		height?: string;
		width?: string;
		padding?: string;
	};
};

const Button = styled.button<ButtonComponentProps>`
	background-color: ${({ colors }) => (colors && colors.background) || "lightgray"};
	border: none;
	border-radius: 0.6vmin;
	cursor: ${({ isDisabled }) => (isDisabled ? "cursor" : "pointer")};
	color: ${({ colors }) => (colors && colors.color) || "#333333"};
	font-size: ${({ sizes }) => (sizes && sizes.font) || "1em"};
	height: ${({ sizes }) => (sizes && sizes.height) || "auto"};
	outline: none;
	opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)};
	padding: ${({ sizes }) => (sizes && sizes.padding) || "1em 2em"};
	pointer-events: ${({ isDisabled }) => (isDisabled ? "none" : "auto")};
	user-select: none;
	width: ${({ sizes }) => (sizes && sizes.width) || "auto"};
`;

シャッフルボタンを画面上に表示する。

作成したButtonコンポーネントを用いて画面上のスライドパズルの横にシャッフルボタンを表示させます。

では、Appコンポーネントが返すJSX内にButtonコンポーネントを以下のよう追加します。

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

	& .shuffle-button {
		margin-left: 8px;
	}
`;

const App = () => {
	// 省略

	const buttonColors = {
		background: '#333',
		color: 'white'
	}
	return (
		<>
			<GlobalStyle />
			<StyledApp>
				<SlidePuzzle
					className="slide-puzzle"
					pieces={pieces}
					size={BOARD_SIZE}
					ref={puzzleElementRef}
					handleDown={handleDown}
				/>
				<Button
					colors={buttonColors}
					onClick={() => startShuffle(MAX_SHUFFLE_COUNT, SHUFFLE_SPEED)}
				>shuffle</Button>
			</StyledApp>
		</>
	);
};

ポイントはButtonコンポーネントのonClick属性に startShuffle関数を指定し、ボタンが押されたときに実行されるようにしている点です。

これらの変更によって、画面上に新たにシャッフルボタンが表示され、それを押すと以下のようにシャッフルが行われるようになりました。

ts slide puzzle shuffle gif1

シャッフルの多重を防ぐ

今の状態では、シャッフル中に再びシャッフルボタンが押された場合、先に実行されたsetIntervalメソッドは止まることなく裏で動き続けるため、次に実行されるsetIntervalと並行して動くことになり、多重にシャッフルが行われてしまいます。

これを防ぐために、新たにsetintervalメソッドが実行される前に、それまで実行されていたsetintervalメソッドを止めます。

// ピースのシャッフルを開始する関数
	const startShuffle = useCallback((maxShuffleCount: number, shuffleSpeed: number): void => {
		// 多重の呼び出しを防ぐため
		// 中途半端な位置で止まっているときにシャッフルが行われたときに位置をリセットするため
		stopShuffle();

		// 省略
	}, [intervalID.current]);

先頭でstopShuffle関数を呼び出して、それまで実行されていたシャッフルを途中で終了させてから、再びシャッフルが行われるようにしています。

これでシャッフルの多重を防ぐことができました。

シャッフルを滑らかにする

ようやくシャッフルが行われるようになりましたが、見ての通り今のシャッフルはピースの動きがカクカクしています。

なので、ピースの動きにアニメーションを加え、滑らかにシャッフルが行われるようにします。

CSSのtransitionプロパティを用いる

滑らかなシャッフルを行うには、CSSのtransitionプロパティを用います。

まず、pieces内のpieceにisTransitionというプロパティを追加します。初期値はfalseです。

const initializePieces = async (): void => {
  // 省略

  pieceImageUrls.then((imageUrls) => {
			// 省略

			for (let i = 0; i < pieceCount; i++) {
				// 省略

				const piece = {
			    // 省略

					isTransition: false,
				};

			  // 省略
			}

			setPieces(pieces);
		});
}

次に、シャッフルでスライドさせる対象となったpieceのisTransitionがtrueに切り替わるようにします。

	const shufflePieces = (maxShuffleCount: number): void => {
		// 省略

		setPieces((prevPieces) => {
			// 省略

			target.isTransition = true;

			// 省略

			return newPieces;
		});

		// 省略
	};

最後に、ピースの要素に対し、isTransition=trueのときにtransitionプロパティが適用されるようします。

const StyledSlidePuzzlePiece = styled.div`
	// 省略

	transition: ${({ piece }) => (piece.isTransition ? `all linear 100ms` : "")};
`;

これで以下のように、滑らかなシャッフルが行われるようになりました。

ts slide puzzle shuffle gif2

ですが、あと1つ行わなければいけないことがあります。それは、シャッフルが終了したときに全てのpieceのisTransitionをfalseに戻すことです。

// ピースのシャッフルを止める関数
	const stopShuffle = (): void => {
		// 省略

		setPieces((prevPieces) =>
			prevPieces
				.sort((a, b) => a.index - b.index)
				.map((piece) => ({
					...piece,

          // 省略

					isTransition: false,
				}))
		);

		// 省略
	};

falseに戻す理由は、戻さないとピースのドラッグ時もイージングが適用され続けるため、以下のようにピースが挙動不審になるからです(それワシやないかい!)。

ts slide puzzle shuffle gif3

シャッフル中はドラッグ&ドロップを禁止する

最後に1つ問題があります。それは、シャッフル中にピースのドラッグ&ドロップができてしまう点です。

なぜそれが問題なのかというと、正しくシャッフルされなくなるからです。

具体的には、シャッフルの途中でピースがドラッグ&ドロップされると、ドラッグによってpiece.x, yにドラッグした分の値が上書きされたり、ドロップによってpiece.x, yが0にリセットされると、本来進むべき方向へスライドされなくなるからです。

このような理由から、シャッフル中はピースのドラッグ&ドロップによる移動はできないようにします。

ではまず、useStateを用いてシャッフル中であるかどうかの状態を保持するステートを作成します。

// シャッフル中であればtrue
const [isShuffling, setIsShuffling] = useState(false);

次に、シャッフルが開始されたときにisShufflingステートがtrueに切り替わるようにします。

// ピースのシャッフルを開始する関数
	const startShuffle = useCallback((maxShuffleCount: number, shuffleSpeed: number): void => {
		setIsShuffling(true);

		// 省略
	}, [intervalID.current]);

そして、isShufflingステートがtrueである場合は、useEffectによるピースがドラッグ&ドロップされたときに行う処理は行わないようにします。

// ピースをドラッグしたときの処理
	// ピースの移動範囲を様々な状況に応じて制限する
	useEffect(() => {
		if (isShuffling) return;

    // 省略
}, [translate, isShuffling]);
// ドロップ後の処理
// 空白の上でドロップしたときにピースと空白を入れ替える
useEffect(() => {
		if (isShuffling) return;

  // 省略
}, [
		mouseStatus.isUp, isShuffling
]);

最後に、シャッフルを止めたときにisShufflingをfalseに戻して、ドロップ&ドロップが可能となるようにします。

// ピースのシャッフルを止める関数
	const stopShuffle = (): void => {
		// 省略

		setIsShuffling(false);
	};

ここまでのコード

これまでに書いたコードやその実行結果は下記のCodePenで確認できます。

次回

以上で終わります。次回はパズルが完成したときに完成画面が表示されるようにします。