ツクログネット

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

eye catch

自己流でReact+ES6を用いたスライドパズルを作りましたので、どのようにして作ったのかを書き綴っていきます。

また今回作ったスライドパズルは、操作性を実物に近づけるためにクリックではなくドラッグ&ドロップで遊べるようにしました。

連載目次

本稿の「ドラッグ&ドロップで遊べるスライドパズルを作る」は連載記事であり、以下の章に分けています。

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

本章では、1.の「ページ上にスライドパズルを表示する」について説明します。

開発環境の構築

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

ディレクトリ構成

プロジェクトの作成後は、ディレクトリ構成を以下のように変更します。

react-slide-puzzle
 └── src
     ├── components
     ├── hooks
     ├── App.css
     ├── App.js
     ├── App.test.js
     ├── index.css
     ├── index.js
     ├── logo.svg
     ├── serviceWorker.js
     └── utils.js

変更点は、src配下にutils.js、componentsフォルダ、hooksフォルダを新規作成しています。

今後、utilsファイルにはアプリ内で使用する定数や汎用的な関数を定義します。

componentsフォルダには、コンポーネントを定義するファイル、hooksフォルダには、カスタムフックを定義するファイルを入れます。

コンポーネントの作成

でははじめに、スライドパズルをページ上に表示するための部品であるコンポーネントを作成します。

スライドパズルは下記のように、SlidePuzzle、Piece、Blankコンポーネントで構成されています。

slide-p-img-2

Pieceコンポーネントの定義

componentsフォルダ内にPiece.jsを作成し、そこに各ピースを表すPieceコンポーネントを定義します。

components/Piece.js
import React from 'react'
import styled from 'styled-components'

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

export default Piece

Pieceコンポーネントについては次の通りです。

Pieceコンポーネントが返す要素について

Pieceコンポーネントが返すReact要素は、ピースのパネル部分を表すStylePieceコンポーネントをはじめ、パネルの左上に描かれる元々の番号を表すNumコンポーネント、パネルの絵となる画像を表すImgコンポーネントで構成されています。

slide-p-img-3

尚、これらのコンポーネントは直接スタイルを当てるためにstyled-componentsで作成しています。

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

yarn add styled-components

Pieceコンポーネントがpropsとして受け取る値について

Pieceコンポーネントがprops(プロパティをデータとして持つオブジェクト)として受け取る値については下記の通りです。

プロパティ名
className クラス名を文字列で受け取ります。指定したクラスは、Pieceコンポーネントが返す要素の親要素に追加される。
piece ピースのデータを表すpieceオブジェクトを受け取ります。受け取ったpieceオブジェクトはコンポーネント内で展開され、その内、piece.baseIdプロパティをNumコンポーネントが返す要素のテキストとして使い、piece.imageプロパティをImgコンポーネントのsrc属性に指定します。

Blankコンポーネントを定義する

componentsフォルダ内にBlank.jsを作成し、そこにスライドパズルの空白部分を表すBlankコンポーネントを定義します。

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

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

export default Blank;

Blankコンポーネントはstyled-componentsで作成します。

SlidePuzzleコンポーネントを定義する

componentsフォルダ内にSlidePuzzle.jsを作成し、そこにこれまでに作成したPieceコンポーネントとBlankコンポーネントを組み合わせて、スライドパズルを表すSlidePuzzleコンポーネントを定義します。

components/SlidePuzzle.js
import React from 'react'
import styled from 'styled-components'
import Piece from './Piece'
import Blank from './Blank'

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

export default SlidePuzzle

SlidePuzzleコンポーネントについては次の通りです。

SlidePuzzleコンポーネントがpropsとして受け取る値について

SlidePuzzleコンポーネントがpropsとして受け取る値については下記の通りです。

プロパティ名
className クラス名を文字列で受け取ります。SlidePuzzleコンポーネントが返すReact要素の親要素のクラス名となります。
pieces pieceオブジェクトが格納されているステート(配列)を受け取ります。受け取ったpieceオブジェクトはPieceコンポーネントにpropsとして渡されます。
boardColor ボードの色を文字列で受け取ります。受け取った値はpropsとしてStyledSlidePuzzleコンポーネントに渡され、background-colorプロパティの値に指定されます。
size ボードの一辺の長さを文字列で受け取ります。受け取った値はpropsとしてStyledSlidePuzzleコンポーネントに渡され、widthとheightプロパティの値に指定されます。

外側からDOMノードを参照可能にする

SlidePuzzleコンポーネントをforwardRef関数で囲い、forwardRef関数に与えるコールバック関数が第二引数としてrefを受け取ることで、SlidePuzzleコンポーネントが返す要素(DOMノード)を外側(親コンポーネント内)から参照できるようになります。

参照可能にすることで、親コンポーネント内でレンダー後にスライドパズルのサイズ等を取得できます。

SlidePuzzleコンポーネントが返す要素について

StyledSlidePuzzleコンポーネントを親要素として、その中にPieceコンポーネントをmapメソッドを使ってピースの枚数分(pieces内のpieceオブジェクトの数)レンダーされるようにしています。だだし、piece.blankがtrueであるときはPieceコンポーネントではなくBlankコンポーネントがレンダーされるようにします。

StyledSlidePuzzleコンポーネントがpropsとして受け取る値

StyledSlidePuzzleコンポーネントがpropsとして受け取る値については下記の通りです。

プロパティ名
className クラス名を文字列で受け取ります。受け取ったクラス名はStyledSlidePuzzleコンポーネントが返す要素のクラス名となります。
boardSize ボードのサイズを文字列で受け取ります。受け取った値はheightとwidthプロパティの値に指定されます。
boardColor ボードのカラーを文字列で受け取ります。受け取った値はbackground-colorプロパティの値に指定されます。
pieceQuantity ピースの枚数を数値で受け取ります。受け取った値はCSS Grid Layoutの記述内で使用されます。

CSS Grid Layoutでピースを敷き詰める

ピースはCSS Grid Layoutを用いてタイルのように敷き詰めます。 CSS Grid Layoutの記述場所は、StyledSlidePuzzleコンポーネント内です。

const StyledSlidePuzzle = styled.div`
    // 省略

    // 行
    grid-template-rows: repeat(${({pieceQuantity}) => Math.sqrt(pieceQuantity)}, auto);
    // 列    
    grid-template-columns: repeat(${({pieceQuantity}) => Math.sqrt(pieceQuantity)}, auto);

    // 省略
`; auto);

display: grid;を指定後、続けてgrid-template-rowsgrid-template-columnsプロパティの値を下記のように指定することで、各ピース(グリッドアイテム)はタイル状に配置されます。

// 行
    grid-template-rows: repeat(${({pieceQuantity}) => Math.sqrt(pieceQuantity)}, auto);
    // 列    
    grid-template-columns: repeat(${({pieceQuantity}) => Math.sqrt(pieceQuantity)}, auto);

grid-template-rowsとgrid-template-columnsプロパティの値は、共にrepeat関数を用いて指定します。このとき、repeat関数に第一引数としてピース数pieceQuantityの平方根Math.sqrt(pieceQuantity)与えることで、例えばpieceQuantityが9であれば、その平方根Math.sqrt(pieceQuantity)は3であるため、グリッドアイテムは3×3となるように配置されます。

第二引数はautoを指定し、全ピース(グリッドアイテム)のサイズが均等でコンテナ内にぴったり収まるようにしています。

各ピースのデータをステートで管理する

ここからは、SlidePuzzleコンポーネントにpropsとして渡す各ピースのデータを生成します。 生成した各ピースのデータはステート(配列)内で管理します。

ではまず、各ピースのデータを一括管理するステートを定義します。

hooks/useSlidePuzzle.js
import { useState } from 'react';

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

    return [
        puzzlePieces,
    ];
};

export default useSlidePuzzle;

useStateを用いて、ステート名がpuzzlePieces、初期値が空配列[]のステートを定義しています。各ピースのデータは、この[]内で次のように管理します。

[
    {/* piece */}, {/* piece */}, {/* piece */},
    {/* piece */}, {/* piece */}, {/* piece */},
    {/* piece */}, {/* piece */}, {/* piece */}
]

次に、各ピースのデータを生成後、それらをpuzzlePiecesステート内にセットします。

hooks/useSlidePuzzle.js
import { useState, useRef, useCallback, useEffect } from 'react';

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 = [];

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

export default useSlidePuzzle;

useSlidePuzzleフックを、引数として2つの値(quantityとbg)を受け取るように変更しています。quantityはピースの数、bgはスライドパズルの完成画像のパスを表します。

各ピースのデータはオブジェクト形式で生成します。どのようにして生成するのかというと、useEffectを使ってピースの枚数quantityが変わる度にinitializePieces関数を実行し、quantityの数だけ生成します。

initializePieces関数が行う処理は、まずgetSquareImage関数でbgをスライドパズルと同じ大きさに変更します。

const width = slidePuzzleRef.current.clientWidth;
const height = slidePuzzleRef.current.clientHeight;

// 画像をスライドパズルと同じ大きさにする
const slidePuzzleImage = await getSquareImage(bg, width).then((r) => r);

その際、スライドパズルのサイズをDOMノードを参照して取得するため、useRefを用いてrefオブジェクトを作成後、それをSlidePuzzleコンポーネントのref属性に指定することで、レンダー後にDOMノードを参照するようにしています。

getSquareImage関数は以下の通りです。

utils.js
export 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");
	//console.log(result)

	return result;
}

尚、getSquareImage関数を実行する際は、getSquareImage関数内でloadImage関数を実行してPromiseによる非同期処理で画像bgの読み込みを行っているため、awaitでgetSquareImage関数の処理(Promise)が終了するまでそれ以降の処理を止めています。

loadImage関数は下記の通りです。

utils.js
export 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 getSquareImage(url, sideLength) {/* 省略 */}

また、awaitはasyncで宣言された関数内でのみ使用できるため、initializePieces関数をasync関数として定義しています。

getSquareImage関数の処理が終わると、その結果(スライドパズルと同じ大きさにリサイズした画像のPromise)をslidePuzzleImageとして取得します。

その後、divideImage関数でslidePuzzleImageをピースの枚数に分割します。

// 画像をquantity等分する
const pieceImages = divideImage(slidePuzzleImage, quantity);

divideImage関数は、引数として画像のパスurlと分割数divisionNumberを受け取り、画像をタイル状に分割し、それらの画像を配列に格納します。

utils.js
// 省略

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

分割された画像を格納した配列は、Promiseオブジェクトでラッピングされた形で返されます。このオブジェクトの名前をpieceImagesとしています。

最後に、ピースの情報をプロパティとして持つpieceオブジェクトをピースの枚数だけ生成し、それらをpuzzlePiecesステート内にセットして上書きします。

pieceオブジェクトを生成する際は、まずPromiseオブジェクトのpieceImagesから分割された画像が格納された配列を取り出す必要があります。

それには、pieceImagesのthenメソッドを呼び出します。

pieceImages.then((images) => {/*...*/})

thenメソッドを呼び出す際、第一引数としてコールバック関数を渡しますが、このコールバック関数は引数としてPromiseが成功したときに実行されるresolve関数に引数として渡した値を受け取ります。
ですが、divideImage関数内ではresolveを呼び出していません。その場合は、returnで返した値を引数として受け取ります。
つまり、このコールバック関数が引数として受け取る値は、分割された画像が格納された配列となります。
ということで、分割された画像が格納された配列はこのコールバック関数内で取得することができます。

分割された画像が格納された配列imagesの取得後は、ループ処理を行ってpieceオブジェクトをピースの枚数(quantity)だけ生成します。

その際、pieceオブジェクトには下記のプロパティを持たせています。

プロパティ名
baseId 元々の位置番号。この番号がピースの左上に表示する番号となる。
image ピースの画像
isBlank 空白を示す真偽値。isBlankがtrueのpieceは空白となる。

このようにして生成したpieceオブジェクトは、一旦pieces配列に格納し、全ての生成が終わったあとにpuzzlePiecesステートに入れます。

定数を定義する

スライドパズルのボードの色やサイズを定数としてconstants.jsに定義します。

constants.js
// ボードのカラー
export const BOARD_COLOR = "#997f5d";

// ボードの一辺の長さ
// BOARD_SIZE四方のボードにする
export const BOARD_SIZE = "60vmin";

// パズルの絵となる画像の絶対パス
export const BG = "ここに絶対パスを指定する";

定数としてボードのカラーBOARD_COLOR、ボードの一辺の長さBOARD_SIZE、パズルの絵となる画像URLBGを定義しています。尚、指定した値については、この値でなければいけないということはありません。各々で決めてください。

ページ上にスライドパズルを表示する

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

App.js
import React from 'react'
import styled, { createGlobalStyle } from 'styled-components'
import SlidePuzzle from './components/SlidePuzzle'
import useSlidePuzzle from './hooks/useSlidePuzzle'
import { BG, BOARD_SIZE, BOARD_COLOR } from './constants'

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

上記のコードについては次の通りです。

グローバルスタイルを定義する

styled-componentsのcreateGlobalStyle関数を使ってGlobalStyleコンポーネントを作成し、そのコンポーネント内に全ページで適用されるスタイルであるグローバルスタイルを定義します。

グローバルスタイルは、html要素やbody要素に背景や文字のカラーをはじめ、フォントなど全ページで共通なスタイルをhtml要素とbody要素に指定します。

全ページにグローバルスタイルを適用するには、GlobalStyleコンポーネントをAppコンポーネントが返す要素の親要素(上記ではStyledAppコンポーネント)の真上に配置します。

// 省略

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

  return (
    <>
			<GlobalStyle />
			<StyledApp>
      {/* 省略 */}
      </StyledApp>
  )
}

export default App

スライドパズルを画面中央に表示する

スライドパズルを画面の中央に表示させるために、SlidePuzzleコンポーネントの親コンポーネントであるStyledAppコンポーネント内に以下のようなFlexboxを用いた上下左右で中央に配置するスタイルを記述しています。

display: flex;
justify-content: center;
align-items: center;
height: 100vh;

尚、上記のスタイルで高さをheight: 100vh;のように指定し、親要素がビューポートの高さいっぱいとなるようにしていますが、これはFlexboxを用いて垂直方向で子要素を画面の中央に配置するには、親要素の高さを画面と同じ高さに指定する必要があるためです。

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

slide-p-img-1

ここまでのコード全体

これまでに書いたコードは下記のCodePenで確認できます。

次回

本章は以上で終わります。次回は、ページ上に表示したスライドパズルのピースをドラッグ&ドロップで動かせるようにします。