ツクログネット

【React Hooks + TypeScript】ドラッグ&ドロップで遊べるスライドパズルを作る その1「ページ上にスライドパズルを表示する」

eye catch

React Hooks + TypeScriptでスライドパズルを作る方法です(自己流)。

また、パズルのピースの操作性を実物に近づけるためにクリックではなくドラッグ&ドロップでスライドされるようにします。

連載目次

  1. ページ上にスライドパズルを表示する(この記事)
  2. ピースをドラッグで動かす
  3. ピースの移動範囲を制限する
  4. ピースをシャッフルする
  5. クリア判定を行う
  6. 制限時間を設ける
  7. 難易度を設ける
  8. 好きな画像を選べるようにする
  9. リサイズ

開発環境

開発環境はViteで構築しました。

ディレクトリ構成

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

react-slide-puzzle
 └── src
     ├── assets
     ├── components
     ├── constants
     ├── hooks
     ├── types
     ├── utils
     ├── App.css
     ├── App.tsx
     ├── index.css
     ├── main.tsx
     └── vite-env.d.ts

utils配下には汎用的な関数を定義したファイル、constants配下には定数を定義したファイルを作成していきます。

components配下にはコンポーネントを定義したファイル、hooks配下にはカスタムフックを定義したファイルを作成していきます。

styled-componentsをインストールする

今回作成するスライドパズルのスタイルはCSSファイルではなく、スタイルを適用するコンポーネントと同じファイルに記述します。

これを可能にするにはstyled-componentsを用います。

styled-componentsは、ターミナルで下記のコマンドを入力してプロジェクトにインストールします。

yarn add styled-components

ピースのコンポーネントを作成する

スライドパズルのピースを表すコンポーネントを作成します。名前はSlidePuzzlePieceとします。

また、SlidePuzzlePieceコンポーネントのpropsの型を併せて定義します。

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

export type SlidePuzzlePieceProps = {
	className?: string;
	piece: PieceData;
};
components/SlidePuzzlePiece.tsx
import React from 'react';
import styled from 'styled-components';
import { SlidePuzzlePieceProps } from '../types/SlidePuzzlePiece';

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

export default SlidePuzzlePiece;

SlidePuzzlePieceコンポーネントはpropsとしてpieceオブジェクトを受け取り、piece.numPieceNumberコンポーネントが、piece.imagePieceImageコンポーネントがそれぞれ出力するようにします。

PieceNumberコンポーネントはピースの番号、PieceImageコンポーネントはピースの画像となります。

pieceオブジェクトについては後述します。

空白のコンポーネントを作成する

スライドパズルにはピースが移動できるスペースが必要であるため、このスペースをコンポーネントとして作成します。コンポーネントの名前は空白を意味するBlankとします。

components/Blank
import React from 'react';
import styled from 'styled-components';
import { SlidePuzzlePieceProps } from '../types/SlidePuzzlePiece';

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

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

export default Blank;

propsの型はPick<>SlidePuzzlePiecePropsからclassNameのみを含んだ型を指定します。

スライドパズルのコンポーネントを作成する

作成したSlidePuzzlePieceBlankコンポーネントを組み合わせて、スライドパズル本体となるコンポーネントを作成します。コンポーネント名はSlidePuzzleとします。

また、SlidePuzzleコンポーネントのpropsに指定する型を併せて定義します。

types/SlidePuzzle.ts
import { PieceData } from './SlidePuzzlePiece';

export type SlidePuzzleProps = {
	className?: string;
	size: string;
	pieces: PieceData[];
	pieceQuantity: number;
};
components/SlidePuzzle.tsx
import React, { forwardRef } from 'react';
import styled from 'styled-components';
import SlidePuzzlePiece from './SlidePuzzlePiece';
import Blank from './Blank';
import { SlidePuzzleProps } from '../types/SlidePuzzle';

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

export default SlidePuzzle;

SlidePuzzleコンポーネントは、propsとしてクラス名className以外にpieceオブジェクトが格納されたpieces配列とスライドパズル本体のサイズsizeを受け取ります 。sizeは一辺の長さであり、「400px」のように文字列で受け取ります。

また、SlidePuzzleコンポーネントのDOMノードを親であるAppコンポーネントからRefを通して参照できるようにするため、SlidePuzzleコンポーネントをforwardedRefでラップし、参照される要素となるStyledSlidePuzzleコンポーネントのref属性にrefを指定します。

SlidePuzzleコンポーネントが返すJSXでは、ループ処理でSlidePuzzlePieceコンポーネントをpieces配列内の要素pieceの数だけ追加します。その際、piece.isBlanktrueである場合はBlankコンポーネントを追加します。

StyledSlidePuzzleコンポーネントが受け取るpropspieceQuantityの平方根Math.sqrt(pieceQuantity)grid-template-rowsgrid-template-columnsrepeat()の値に指定します。これにより、例えばpieceQuantityが9であれば3×3のパズルとなります。

grid-template-rowsgrid-template-columnsはCSS Grid Layoutのプロパティですが、これらの説明については省略します。

コンポーネントに渡すデータを作成する

SlidePuzzleコンポーネントを使う際にpropsとして渡すデータを作成します。 また、その際に必要となる関数を併せて作成します。

utils/images.ts
// 画像を読み込んだあとにその画像を返す関数
export 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);
		});
	});
};

// 画像を比率を維持したまま正方形に切り抜いてその画像を返す関数
// imageUrl=元画像のURL
// size=正方形の一辺の長さ
export async function getSquareImage(imageUrl: string, size: number): Promise<string> {
	// 元画像を読み込んで取得する
	const image = await getLoadedImage(loadImage(imageUrl)); //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=分割する数
export 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;
}
hooks/useSlidePuzzle.ts
import { useState, useEffect, useRef } from "react";
import { PieceData } from '../types/SlidePuzzlePiece';

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];
};

export default useSlidePuzzle;

コンポーネントのロジックに関わるコードはコンポーネント内ではなくカスタムフック内に記述します。カスタムフック名はuseSlidePuzzleとします。

SlidePuzzleコンポーネントに渡すデータはpiecesであり、配列として作成します。初期値は空の配列ですが、初期化の際にSlidePuzzlePieceコンポーネントがpropsとして受け取るpieceオブジェクトを格納します。

またスライドパズルのプレイ中はpieces内でpieceオブジェクトの並び順が頻繁に変わるため、piecesはステートとして作成します。

piecesの初期化はinitializePieces関数が行い、useEffectによって初回のコンポーネントのレンダー後とピースの数numberOfPiecesが変わるたびに実行します。

initializePieces関数では、ピースの数numberOfPiecesだけpieceオブジェクトを生成します。pieceオブジェクトにはプロパティとしてピースの左上に表示する番号number、自身が空白であるか否かを示す真偽値isBlank、そしてピースの画像imageを追加します。

ピースの画像は1枚の画像から下記のようにして生成します。

  1. getSquareImage関数で画像を正方形に切り抜く
  2. 1.の画像をdivideImageEqually関数でピースの枚数に分割する

詳しいことはコメント文を読んでください。お願いします。

スライドパズル表示する

では、作成したSlidePuzzleコンポーネント、useSlidePuzzleフックを用いてページ上にスライドパズルを表示します。

はじめに、定数を定義します。

constants/SlidePuzzle.ts
export const BOARD_COLOR = "#997f5d";
export const BOARD_SIZE = "60vmin";
export const SLIDE_PUZZLE_IMAGE = "image url";
export const NUMBER_OF_PIECES = 9;

それから、Appコンポーネント内でuseSlidePuzzleフックを呼び出し、戻り値をSlidePuzzleコンポーネントの属性に指定します。

App.tsx
import React from 'react'
import styled, { createGlobalStyle } from 'styled-components'
import SlidePuzzle from './components/SlidePuzzle'

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

export default App;

App.tsxを上書き保存するとページ上に下記のようなスライドパズルが表示されます。

slide-p-img-1

ここまでのDemo

ここまでのDemoは下記のCodePenでコードと併せて確認できます。

次回

本章については以上です。次回は、ページ上に表示したスライドパズルにドラッグ&ドロップでピースを移動可能にする機能を追加します。