React Hooks + TypeScriptでスライドパズルを作る方法です(自己流)。
また、パズルのピースの操作性を実物に近づけるためにクリックではなくドラッグ&ドロップでスライドされるようにします。
連載目次
- ページ上にスライドパズルを表示する(この記事)
- ピースをドラッグで動かす
- ピースの移動範囲を制限する
- ピースをシャッフルする
- クリア判定を行う
- 制限時間を設ける
- 難易度を設ける
- 好きな画像を選べるようにする
- リサイズ
開発環境
開発環境は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の型を併せて定義します。
// pieceオブジェクトの型
export type PieceData = {
number: number;
image: string;
isBlank: boolean;
};
export type SlidePuzzlePieceProps = {
className?: string;
piece: PieceData;
};
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.num
をPieceNumber
コンポーネントが、piece.image
をPieceImage
コンポーネントがそれぞれ出力するようにします。
PieceNumber
コンポーネントはピースの番号、PieceImage
コンポーネントはピースの画像となります。
piece
オブジェクトについては後述します。
空白のコンポーネントを作成する
スライドパズルにはピースが移動できるスペースが必要であるため、このスペースをコンポーネントとして作成します。コンポーネントの名前は空白を意味する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
のみを含んだ型を指定します。
スライドパズルのコンポーネントを作成する
作成したSlidePuzzlePiece
とBlank
コンポーネントを組み合わせて、スライドパズル本体となるコンポーネントを作成します。コンポーネント名はSlidePuzzleとします。
また、SlidePuzzle
コンポーネントのpropsに指定する型を併せて定義します。
import { PieceData } from './SlidePuzzlePiece';
export type SlidePuzzleProps = {
className?: string;
size: string;
pieces: PieceData[];
pieceQuantity: number;
};
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.isBlank
がtrue
である場合はBlank
コンポーネントを追加します。
StyledSlidePuzzle
コンポーネントが受け取るpropspieceQuantity
の平方根Math.sqrt(pieceQuantity)
をgrid-template-rows
とgrid-template-columns
のrepeat()
の値に指定します。これにより、例えばpieceQuantity
が9であれば3×3のパズルとなります。
※grid-template-rows
とgrid-template-columns
はCSS Grid Layoutのプロパティですが、これらの説明については省略します。
コンポーネントに渡すデータを作成する
SlidePuzzle
コンポーネントを使う際にpropsとして渡すデータを作成します。
また、その際に必要となる関数を併せて作成します。
// 画像を読み込んだあとにその画像を返す関数
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;
}
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枚の画像から下記のようにして生成します。
getSquareImage
関数で画像を正方形に切り抜く- 1.の画像を
divideImageEqually
関数でピースの枚数に分割する
詳しいことはコメント文を読んでください。お願いします。
スライドパズル表示する
では、作成したSlidePuzzle
コンポーネント、useSlidePuzzle
フックを用いてページ上にスライドパズルを表示します。
はじめに、定数を定義します。
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
コンポーネントの属性に指定します。
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を上書き保存するとページ上に下記のようなスライドパズルが表示されます。
ここまでのDemo
ここまでのDemoは下記のCodePenでコードと併せて確認できます。
次回
本章については以上です。次回は、ページ上に表示したスライドパズルにドラッグ&ドロップでピースを移動可能にする機能を追加します。