<div id="root"></div>
const { useState, useRef, useCallback, useEffect, forwardRef, memo } = React;
const { createGlobalStyle } = styled;
const BOARD_COLOR = "#997f5d";
const BOARD_SIZE = "60vmin";
const SLIDE_PUZZLE_IMAGE = "https://images.ctfassets.net/w6gireivdm0c/61UEuQEwHoSoz3GIpNkF9e/1d18ada4da6c6de4912efa71f57d44aa/NEKO9V9A9131_TP_V4.jpg";
const PIECE_COUNT = 16;
const MAX_SHUFFLE_COUNT = 60;
const SHUFFLE_SPEED = 100;
// 画像を読み込んだあとにその画像を返す関数
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=正方形の一辺の長さ
async function getSquareImage(imageUrl: string, size: number): Promise<string> {
// 元画像を読み込んで取得する
const image = 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=分割する数
async function divideImageEqually(
imageUrl: string,
DivisionCount: 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(DivisionCount);
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;
}
const clamp = (value: number, min: number, max: number): number => {
if (value < min) return min;
else if (value > max) return max;
return value;
};
const getRandomValue = (value: number): number => {
return Math.floor(Math.random() * value);
};
// pieceオブジェクトの型
type PieceData = {
number: number;
image: string;
isBlank: boolean;
};
type DraggingDirection = {
horizontal: 'left' | 'right' | null;
vertical: 'up' | 'down' | null;
};
type MouseStatus = {
isDown: boolean;
isMove: boolean;
isUp: boolean;
};
type DraggingElementStatus = {
translate: Point;
mouseStatus: MouseStatus;
draggingElement: EventTarget & Element | null;
draggingDirection: DraggingDirection;
};
type DraggingElementController = (e: React.MouseEvent<EventTarget & HTMLElement>) => void;
type Direction = 'left' | 'right' | 'up' | 'down' ;
type LineDirection = 'row' | 'col'
const useDraggableElements = (isStyleTransform: boolean = true): [
DraggingElementStatus,
DraggingElementController
] => {
const [translate, setTranslate] = useState<Point>({
x: 0,
y: 0
});
const [mouseStatus, setMouseStatus] = useState<MouseStatus>({
isDown: false,
isMove: false,
isUp: false
});
const startPoint = useRef<Point>({ x: 0, y: 0 });
const prevTranslate = useRef<Point>({ x: 0, y: 0 });
const prevDifference = useRef<Point>({ x: 0, y: 0 });
const draggingElement = useRef<EventTarget & HTMLElement | null>(null);
const draggingDirection = useRef<DraggingDirection>({
horizontal: null,
vertical: null,
});
const isDraggable = (): boolean => draggingElement.current ? draggingElement.current.classList.contains('draggable') : false;
const handleDown = useCallback((e: React.MouseEvent<EventTarget & HTMLElement>): void => {
draggingElement.current = e.currentTarget;
if(!isDraggable()) return;
const matrix = new DOMMatrix(getComputedStyle(draggingElement.current).transform);
prevTranslate.current = {
x: matrix.translateSelf().e,
y: matrix.translateSelf().f
};
const draggableElements = document.getElementsByClassName("draggable") as HTMLCollectionOf<HTMLElement>;
for(let i = 0; i < draggableElements.length; i++) {
draggableElements[i].style.zIndex = `1000`;
}
draggingElement.current.style.position = 'relative';
draggingElement.current.style.zIndex = `1001`;
const x = e.pageX;
const y = e.pageY;
startPoint.current = { x, y };
setMouseStatus(prevMouseStatus => ({
...prevMouseStatus,
isUp: false,
isDown: true
}));
}, []);
const handleMove = (e: MouseEvent): void => {
e.preventDefault();
if(!draggingElement.current) return;
if(!isDraggable()) return;
console.log('mousemove');
const differenceX = e.pageX - startPoint.current.x;
const differenceY = e.pageY - startPoint.current.y;
if (differenceX > prevDifference.current.x) {
draggingDirection.current.horizontal = "right";
}
else if (differenceX < prevDifference.current.x) {
draggingDirection.current.horizontal = "left";
}
if (differenceY > prevDifference.current.y) {
draggingDirection.current.vertical = "down";
}
else if (differenceY < prevDifference.current.y) {
draggingDirection.current.vertical = "up";
}
console.log('directionX: ', draggingDirection.current.horizontal);
console.log('directionY: ', draggingDirection.current.vertical);
setTranslate({
x: prevTranslate.current.x + differenceX,
y: prevTranslate.current.y + differenceY
});
prevDifference.current = {
x: differenceX,
y: differenceY
};
setMouseStatus(prevMouseStatus => ({
...prevMouseStatus,
isMove: true
}));
};
const handleUp = (e: MouseEvent): void => {
if(!draggingElement.current) return;
if(!isDraggable()) return;
console.log('mouseup');
draggingElement.current = null;
setMouseStatus(prevMouseStatus => ({
...prevMouseStatus,
isDown: false,
isMove: false,
isUp: true
}));
};
useEffect(() => {
if(!isStyleTransform) return;
if(!draggingElement.current) return
draggingElement.current.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0)`;
}, [translate]);
// 初回のレンダー後に一度だけ実行
useEffect(() => {
document.body.addEventListener("mousemove", handleMove);
document.body.addEventListener("mouseup", handleUp);
document.body.addEventListener("mouseleave", handleUp);
return () => {
document.body.removeEventListener("mousemove", handleMove);
document.body.removeEventListener("mouseup", handleUp);
document.body.removeEventListener("mouseleave", handleUp);
};
}, []);
return [
{
translate,
mouseStatus,
draggingElement: draggingElement.current,
draggingDirection: draggingDirection.current
},
handleDown
];
};
type PuzzleStatus = {
pieces: PieceData[];
puzzleElementRef: React.RefObject<HTMLDivElement>;
};
type PiecesHandler = {
handleDown: (e: React.MouseEvent<HTMLElement>) => void;
};
const useSlidePuzzle = (
pieceCount: number,
imageUrl: string
): [PuzzleStatus, PiecesHandler] => {
const [
{
translate,
mouseStatus,
draggingElement,
draggingDirection
},
handleDown
] = useDraggableElements(false);
// ピースのデータを管理するステート
const [pieces, setPieces] = useState<PieceData[]>([]);
// シャッフル中であればtrue
const [isShuffling, setIsShuffling] = useState(false);
const [isLocking, setIsLocking] = useState(true);
// DOM 要素を保持するための ref を作成します。
const elementRef = useRef<HTMLDivElement>(null);
// 現在のシャッフル回数
const shuffleCount = useRef(0);
const intervalID = useRef(null);
// 一辺のピース数を取得する関数
const getOneSidePieceCount = (): number => Math.sqrt(pieceCount);
// 空白を取得する関数
const getBlank = (pieces: PieceData[]): PieceData => {
return [...pieces].filter((piece) => piece.isBlank)[0];
};
// 指定した番号に位置するピースを取得する関数
const getPiece = (pieces: PieceData[], index: number): PieceData => {
return [...pieces].filter((piece) => piece.index === index)[0];
};
// ドラッグ中のピースを取得する関数
/*const getDraggingPiece = (pieces: PieceData[]): PieceData => {
return pieces.filter((piece) => piece.isDragging)[0];
};*/
// targetがotherのdirection側に隣接しているか否かを判定する関数
const isNextToOther = (
target: PieceData,
other: PieceData,
direction: Direction
): boolean => {
if(!target || !other) return;
const oneSidePieceCount = getOneSidePieceCount();
if(direction === 'left'){
return target.rowNumber === other.rowNumber &&
target.index === other.index - 1
}
else if(direction === 'right'){
return target.rowNumber === other.rowNumber &&
target.index === other.index + 1;
}else if(direction === 'up') {
return target.colNumber === other.colNumber &&
target.index === other.index - oneSidePieceCount;
}else if(direction === 'down'){
return target.colNumber === other.colNumber &&
target.index === other.index + oneSidePieceCount;
}
return false;
};
// 対象のピースtargetがいずれかの方向で空白blankと隣接しているか否かを判定する関数
const isNextToBlank = (pieces: PieceData[], index: number): boolean => {
const target = getPiece(pieces, index);//pieces[index];
const blank = getBlank(pieces);
return (
isNextToOther(target, blank, 'left') ||
isNextToOther(target, blank, 'right') ||
isNextToOther(target, blank, 'up') ||
isNextToOther(target, blank, 'down')
);
};
// 空白に隣接しているすべてのピースを取得する関数
const getSlidablePieces = (pieces: PieceData[]): PieceData[] => {
const blank = getBlank(pieces);
return [...pieces].filter((piece, i) => isNextToBlank(pieces, piece.index));
};
// 空白のdirection側に隣接しているピースを取得する関数
const getPieceNextToBlank = (
pieces: PieceData[],
direction: Direction
): PieceData => {
const blank = getBlank(pieces);
const slidablePieces = getSlidablePieces(pieces);
const target = slidablePieces.filter(
(piece) => isNextToOther(piece, blank, direction)
)[0];
return target;
};
const isSlidingPiece = (pieces: PieceData[], slidingDirection: LineDirection): boolean => {
const leftPiece = getPieceNextToBlank(pieces, 'left');
const rightPiece = getPieceNextToBlank(pieces, 'right');
const upperPiece = getPieceNextToBlank(pieces, 'up');
const lowerPiece = getPieceNextToBlank(pieces, 'down');
if(slidingDirection === 'col') {
if(
upperPiece && Math.abs(upperPiece.y) > 0 ||
lowerPiece && Math.abs(lowerPiece.y) > 0
) {
return true;
}
}else{
if(
leftPiece && Math.abs(leftPiece.x) > 0 ||
rightPiece && Math.abs(rightPiece.x) > 0
) {
return true;
}
}
return false;
};
// ピースのシャッフルを止める関数
const stopShuffle = (): void => {
clearInterval(intervalID.current);
shuffleCount.current = 0;
// indexプロパティが昇順に並ぶようにソートする
// ピースをindex順に並び替えたあとにx,yをリセット
// シャッフル後のx, yは加算されたり減算されたりして複雑になっているため、移動範囲を指定する際に計算が困難になる
// 移動範囲を制限しやすいようにするため
// 一旦x, yをリセットする。まっさらな状態にする
// ただ、x, yを0にしただけでは、シャッフル前の状態に戻るだけであるため、更新されたindex順に並び替える。これでシャッフル後の状態になる
setPieces((prevPieces) =>
prevPieces
.sort((a, b) => a.index - b.index)
.map((piece) => ({
...piece,
isTransition: false,
x: 0,
y: 0
}))
);
setIsShuffling(false);
};
// isShufflingステートがtrueの間、空白とそれに隣接するいずれかのピースを入れ替える関数
const shufflePieces = (maxShuffleCount: number): void => {
if(shuffleCount.current >= maxShuffleCount) {
stopShuffle();
return;
}
setPieces((prevPieces) => {
/*prevPieces.forEach((piece) => ({
...piece,
isTransition: false
}));*/
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;
}
//indexはisNextToOtherの内部でそれを用いた比較を行っているため、その比較が終わってから入れ替える
target.isTransition = true;
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;
});
shuffleCount.current++;
};
// ピースのシャッフルを開始する関数
const startShuffle = useCallback((maxShuffleCount: number, shuffleSpeed: number): void => {
// 多重の呼び出しを防ぐため
// 中途半端な位置で止まっているときにシャッフルが行われたときに位置をリセットするため
stopShuffle();
setIsShuffling(true);
intervalID.current = setInterval(() => shufflePieces(maxShuffleCount), shuffleSpeed);
}, [intervalID.current]);
// ピースのデータを初期化する関数
const initializePieces = async (): void => {
//if(!puzzleRef.current) return;
console.log('nk: ', elementRef.current);
// パズルのサイズ
const puzzleSize = elementRef.current.clientWidth;
// bgをパズルと同じサイズに切り抜く
const squareImageUrl = await getSquareImage(imageUrl, puzzleSize).then(
(r) => r
);
//squareImageUrlをcols×rowsに分割する
const pieceImageUrls = divideImageEqually(squareImageUrl, pieceCount);
pieceImageUrls.then((imageUrls) => {
const pieces = [];
// ピースの行・列数
const cols = getOneSidePieceCount();
const rows = cols;
let currentRowNumber = 1;
const puzzleElement = elementRef.current;
for (let i = 0; i < pieceCount; i++) {
//const pieceElement = pieceElement.children[i];
/*const pieceElement = puzzleElement.children[i] ? puzzleElement.children[i] : null;
const w = pieceElement ? pieceElement.getBoundingClientRect().width : 0;
const pieceSize = pieceElement ? pieceElement.clientWidth : 0;
console.log('fu: ', pieceElement, pieceSize, w);*/
const piece = {
number: i + 1, // ピースの番号
isBlank: i === pieceCount - 1 ? true : false, // 初めは最後の番号を空白にする
image: imageUrls[i], // ピースの画像
index: i,// pieces内におけるpieceの番号
colNumber: (i % cols) + 1,// 列番号
rowNumber: currentRowNumber,// 行番号
isDragging: false,// 移動中であればtrue
isTransition: false,
x: 0,// ピースの横の移動距離
y: 0,// ピースの縦の移動距離
height: puzzleSize / cols,// ピースの高さ
width: puzzleSize / rows// ピースの高さ
};
pieces[i] = piece;
// i + 1がrowsの倍数になる毎に行番号を1増やす
if((i + 1) % rows === 0) {
currentRowNumber++;
}
}
setPieces(pieces);
});
};
// ピースの数が変わるたびに初期化する
useEffect(() => {
//if(!elementRef.current) return;
initializePieces();
}, [pieceCount, elementRef.current]);
//const restrictedTranslate = useRef({ x: 0, y: 0 });
// ピースをドラッグしたときの処理
// ピースの移動範囲を様々な状況に応じて制限する
useEffect(() => {
if (isShuffling) return;
console.log('drag: 防げなかった');
if(!draggingElement) return;// nullチェック
// 前回ドラッグしたピースの座標が入っているためリセットする
const restrictedTranslate = {
x: 0,
y: 0
};
setPieces(prevPieces =>
prevPieces.map((piece, i) => {
// ドラッグしたピースに紐づくpieceを判定する
if(`piece-${piece.number}` === draggingElement.id){
const blank = getBlank(prevPieces);
const oneSidePieceCount = getOneSidePieceCount();
piece.isDragging = true;
// 上下左右いずれかの方向で空白に隣接しているとき
if(isNextToBlank(prevPieces, piece.index)) {
console.log('隣接!');
// t | b
if(isNextToOther(piece, blank, 'left')) {
console.log('| t | b |');
// ドラッグするピースと異なる軸で空白と隣接しているピースが中途半端な位置で止まっている(交差している)場合は動かないようにする
if(isSlidingPiece(prevPieces, 'col')){
return piece;
}
// 二つ後のpiece//
const nextNextPiece = prevPieces[i + 2];
// ひとつ前のpiece
const prevPiece = prevPieces[i - 1];
// p | t | b | p
if(
isNextToOther(prevPiece, piece, 'left') &&
isNextToOther(nextNextPiece, blank, 'right')
){
restrictedTranslate.x = clamp(
translate.x,
prevPiece.x,
piece.width + nextNextPiece.x//piece.width - Math.abs(nextNextPiece.x)
);
}
// p | t | b |
else if(isNextToOther(prevPiece, piece, 'left')){
restrictedTranslate.x = clamp(
translate.x,
prevPiece.x,
piece.width
);
}
// | t | b | p
else if(isNextToOther(nextNextPiece, blank, 'right')){
restrictedTranslate.x = clamp(
translate.x,
0,
piece.width + nextNextPiece.x//piece.width - Math.abs(nextNextPiece.x)
);
}
}
// b | t
else if(isNextToOther(piece, blank, 'right')) {
console.log(`空白は左にある`);
if(isSlidingPiece(prevPieces, 'col')){
return piece;
}
const prevPrevPiece = prevPieces[i - 2];
const nextPiece = prevPieces[i + 1];
// p | b | t | p
if(
isNextToOther(prevPrevPiece, blank, 'left') &&
isNextToOther(nextPiece, piece, 'right')
){
restrictedTranslate.x = clamp(
translate.x,
-piece.width + prevPrevPiece.x,
nextPiece.x
);
}
// | b | t | p
else if(isNextToOther(nextPiece, piece, 'right')){
restrictedTranslate.x = clamp(
translate.x,
-piece.width,
nextPiece.x
);
}
// p | b | t |
else if(isNextToOther(prevPrevPiece, blank, 'left')){
restrictedTranslate.x = clamp(
translate.x,
-piece.width + prevPrevPiece.x,
0
);
}
}
/*
b
-
t
*/
else if(isNextToOther(piece, blank, 'down')){
console.log(`空白は↑にある`);
if(isSlidingPiece(prevPieces, 'row')){
return piece;
}
const prevPrevPiece = prevPieces[i - oneSidePieceCount * 2];
const nextPiece = prevPieces[i + oneSidePieceCount];
/*
p
-
b
-
t
-
p
*/
if(
isNextToOther(prevPrevPiece, blank, 'up') &&
isNextToOther(nextPiece, piece, 'down')
){
restrictedTranslate.y = clamp(
translate.y,
-piece.height + prevPrevPiece.y,
nextPiece.y
);
}
/*
p
-
b
-
t
-
*/
else if(isNextToOther(prevPrevPiece, blank, 'up')){
restrictedTranslate.y = clamp(
translate.y,
-piece.height + prevPrevPiece.y,
0
);
}
/*
-
b
-
t
-
p
*/
else if(isNextToOther(nextPiece, piece, 'down')){
restrictedTranslate.y = clamp(
translate.y,
-piece.height,
nextPiece.y
);
}
}
/*
t
-
b
*/
else if(isNextToOther(piece, blank, 'up')){
console.log(`空白は↓にある`);
if(isSlidingPiece(prevPieces, 'row')){
return piece;
}
const nextNextPiece = prevPieces[i + oneSidePieceCount * 2];
const prevPiece = prevPieces[i - oneSidePieceCount];
/*
p
-
t
-
b
-
p
*/
if(
isNextToOther(nextNextPiece, blank, 'down') &&
isNextToOther(prevPiece, piece, 'up')
){
restrictedTranslate.y = clamp(
translate.y,
prevPiece.y,
piece.height - Math.abs(nextNextPiece.y)
);
}
/*
-
t
-
b
-
p
*/
else if(isNextToOther(nextNextPiece, blank, 'down')){
restrictedTranslate.y = clamp(
translate.y,
0,
piece.height - Math.abs(nextNextPiece.y),
);
}
/*
p
-
t
-
b
-
*/
else if(isNextToOther(prevPiece, piece, 'up')){
restrictedTranslate.y = clamp(
translate.y,
prevPiece.y,
piece.height
);
}
}
}
// 空白に隣接していない
else{
//else if(
// piece.row === blank.row ||
// piece.col === blank.col
//){
console.log('空白はない');
if(piece.rowNumber === blank.rowNumber) {
const prevPiece = prevPieces[i - 1];
const nextPiece = prevPieces[i + 1];
// p | t | p
if(
isNextToOther(prevPiece, piece, 'left') &&
isNextToOther(nextPiece, piece, 'right')
){
restrictedTranslate.x = clamp(
translate.x,
prevPiece.x,
nextPiece.x
);
}
// p | t |
else if(isNextToOther(prevPiece, piece, 'left')){
restrictedTranslate.x = clamp(
translate.x,
prevPiece.x,
0
);
}
// | t | p
else if(isNextToOther(nextPiece, piece, 'right')){
restrictedTranslate.x = clamp(
translate.x,
0,
nextPiece.x
);
}
}else if(piece.colNumber === blank.colNumber){
const prevPiece = prevPieces[i - oneSidePieceCount];
const nextPiece = prevPieces[i + oneSidePieceCount];
/*
p
-
t
-
p
*/
if(
isNextToOther(prevPiece, piece, 'up') &&
isNextToOther(nextPiece, piece, 'down')
){
restrictedTranslate.y = clamp(
translate.y,
prevPiece.y,
nextPiece.y
);
}
/*
p
-
t
-
*/
else if(isNextToOther(prevPiece, piece, 'up')){
restrictedTranslate.y = clamp(
translate.y,
prevPiece.y,
0
);
}
/*
-
t
-
p
*/
else if(isNextToOther(nextPiece, piece, 'down')){ console.log('sita')
restrictedTranslate.y = clamp(
translate.y,
0,
nextPiece.y
);
}
}
}
piece.x = restrictedTranslate.x;
piece.y = restrictedTranslate.y;
return {
...piece
}
}else{
return piece;
}
})
);
}, [translate, isShuffling]);
// ドロップ後の処理
// 空白の上でドロップしたときにピースと空白を入れ替える
useEffect(() => {
if (isShuffling) return;
console.log('drop: 防げなかった');
if(!mouseStatus.isUp) return;
setPieces(prevPieces => {
prevPieces.forEach((piece, i) => {
if(piece.isDragging) {
piece.isDragging = false;
if(
Math.abs(piece.x) >= piece.width ||
Math.abs(piece.y) >= piece.height
){
const pieceColNumber = piece.colNumber;
const pieceRowNumber = piece.rowNumber;
const pieceIndex = piece.index;
const blank = getBlank(prevPieces);
const blankColNumber = blank.colNumber;
const blankRowNumber = blank.rowNumber;
const blankIndex= blank.index;
const unidirectionalPieceNum = getOneSidePieceCount();
piece.index = blankIndex;
blank.index = pieceIndex;
// →へ着地
if(
isNextToOther(piece, blank, 'left') ||
isNextToOther(piece, blank, 'right')
){
piece.x = 0;
piece.colNumber = blankColNumber;
blank.colNumber = pieceColNumber;
}
// ↓へ着地
else if(
isNextToOther(piece, blank, 'up') ||
isNextToOther(piece, blank, 'down')
){
piece.y = 0;
piece.rowNumber = blankRowNumber;
blank.rowNumber = pieceRowNumber;
}
prevPieces[i] = {...blank};
prevPieces[blankIndex] = {...piece};
}
}
});
return [...prevPieces];
});
}, [
mouseStatus.isUp, isShuffling
]);
return [
{ pieces, puzzleElementRef: elementRef },
{ handleDown, startShuffle }
];
};
type SlidePuzzlePieceProps = {
className?: string;
piece: PieceData;
handleDown: (e: React.MouseEvent<HTMLElement>) => void;
};
const StyledSlidePuzzlePiece = styled.div`
padding: 0.3vmin;
position: relative;
transform: translate3d(${({ piece }) => piece.x}px, ${({ piece }) => piece.y}px, 0);
transition: ${({ piece }) => (piece.isTransition ? `all linear 100ms` : "")};
`;
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, handleDown }: SlidePuzzlePieceProps) => {
return (
<StyledSlidePuzzlePiece
id={`piece-${piece.number}`}
className={className}
piece={piece}
onMouseDown={handleDown}
>
<>
<PieceNumber className="piece-number">{piece.number}</PieceNumber>
<PieceImage className="piece-image" src={piece.image}></PieceImage>
</>
</StyledSlidePuzzlePiece>
);
};
const StyledBlank = styled.div`
padding: 0.3vmin;
`;
const Blank = ({ className }: Pick<SlidePuzzlePieceProps, "className">) => {
return <StyledBlank className={className} />;
};
type SlidePuzzleProps = {
className?: string;
size: string;
pieces: PieceData[];
pieceQuantity: number;
handleDown: (e: React.MouseEvent<HTMLElement>) => void;
};
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, handleDown }, ref: React.ForwardedRef<HTMLDivElement>) => {
return (
<StyledSlidePuzzle
className={className}
ref={ref}
size={size}
pieceQuantity={pieces.length}
>
{pieces.map((piece, index) =>
!piece.isBlank ? (
<SlidePuzzlePiece
className="draggable"
key={piece.number}
piece={piece}
handleDown={handleDown}
/>
) : (
<Blank key={piece.number} />
)
)}
</StyledSlidePuzzle>
);
});
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"};
`;
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;
& .shuffle-button {
margin-left: 8px;
}
`;
const App = () => {
const [
{ pieces, puzzleElementRef },
{ handleDown, startShuffle }
] = useSlidePuzzle(PIECE_COUNT, SLIDE_PUZZLE_IMAGE);
const buttonColors = {
background: '#333',
color: 'white'
}
return (
<>
<GlobalStyle />
<StyledApp>
<SlidePuzzle
className="slide-puzzle"
pieces={pieces}
size={BOARD_SIZE}
ref={puzzleElementRef}
handleDown={handleDown}
/>
<Button
className="shuffle-button"
colors={buttonColors}
onClick={() => startShuffle(MAX_SHUFFLE_COUNT, SHUFFLE_SPEED)}
>shuffle</Button>
</StyledApp>
</>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
View Compiled
This Pen doesn't use any external CSS resources.