ツクログネット

【React Hooks + TypeScript】ドラッグ&ドロップで遊ぶスライドパズルを作る その3「ピースの動きを実物と同様にする」

eye catch

前回の記事では、ページ上に表示したスライドパズルのピースをドラッグで動くような実装をしました。 ですが、今のままではドラッグしているピースが他のピースに重なったり、枠を越えてしまうため、実物とは程遠い状態です。

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

連載目次

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

ディレクトリ構成

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

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

実装の手順

以下の手順で実装を行います。

  1. pieceに新たなプロパティを追加する
  2. 新たな関数を定義する
  3. ドラッグしたピースの移動を制限する
  4. ピースのドロップ後に移動が完了していればそのピースと空白の順番を入れ替える

1. pieceに新たなプロパティを追加する

pieceに対して新たに以下のプロパティを追加しています。

  • isDragging
  • colNumber
  • rowNumber
  • height
  • width

各プロパティの詳細についてはコメント文の通りです。使いどころについては、まずisDraggingはドロップしたピースを判定する際に必要となります。

heightwidthは移動範囲を決める際に必要となります。

colNumberrowNumberは、ピース同士やピースと空白が隣り合っているかどうかを判定する際に必要となります。

また、列番号colNumberと行番号rowNumberの算出方法は少し複雑になっているため、その方法について説明します。

列番号の算出方法

列番号は、

/*
    1 | 2 | 3
    1 | 2 | 3
    1 | 2 | 3
*/

のように横へ1ずつ増やしたいため、(i + 1) % colsのようにして(i + 1)が列数colsの倍数になったとき(2行目に移ったとき)に1に戻るようにします。

行番号の算出方法

行番号は

/*
    1 | 1 | 1
    2 | 2 | 2
    3 | 3 | 3
*/

のように縦に増やしたいため、currentRowNumber(初期値=1)を用意した後、

if((i + 1) % rows === 0) {
    currentRowNumber++;
}

のようにして、(i + 1)が行数rowsの倍数になったときにcurrentRowNumberを1増やすようにします。

なぜこのようなことをするのかというと、(i + 1) % rows(i + 1)rowsの倍数になったときに0になるため、この仕組みを利用して例えばrowsが3であれば、行番号rowは、iが「3, 6, 9」のときに1ずつ増えるため、結果的に縦に「1,2,3」となるように行番号が割り振られるのです。

2. 新たな関数を定義する

新たに以下の関数を定義しています。

  • getOneSidePieceCount
  • getBlank
  • getPiece
  • isNextToOther
  • isNextToBlank
  • getSlidablePieces
  • getPieceNextToBlank
  • isSlidingPiece

これらの関数についてはコメント文の通りですが、 使いどころを簡単に説明すると、関数名の先頭にisが付く関数はピースの周りを判定する際に用いられ、getが付く関数はisが付く関数内で用いられています。

また、先ほどpieceに追加した行番号rowNumberや列番号colNumberをはじめとしたプロパティはこれらの関数内で用いられています。

3. ピースの移動を制限する

ドラッグしたピースが、進む先にあるピースや壁にぶつかるように移動を制限します。

手順

  1. ドラッグしたピースが移動可能またはでき得る位置にあるかどうかを判定する
  2. ピースの進行方向に立ち塞がっているのはピースなのか壁なのかを判定する
  3. translateの変化を抑える

1.ドラッグしたピースが移動可能またはでき得る位置にあるかどうかを判定する

ピースの移動を制限する際、まずはドラッグしたピースが移動可能または移動でき得る位置にあるかどうかを判定する必要があります。

移動可能または移動でき得る位置とは

移動可能または移動でき得る位置とは、ドラッグしたピースが以下のような位置にあるときです。

  • 隣に空白がある
  • 隣にはないが同じライン上に空白がある

隣に空白があれば、当然その方向へ移動可能であるといえます。

また、隣ではなくとも同じライン上にあれば、同じライン上で他に空白と隣り合っているピースがその方向へ移動することで、その後ろに移動した分のスペースができるため、その後に続くピースは移動でき得る位置にあるといえます。

typescript slide puzzle img2-0

判定方法

これらの判定は以下のようにして行います。

          // 上下左右いずれかの方向で空白に隣接しているとき
					if(isNextToBlank(prevPieces, piece.index)) {
            // 省略
          }
          // 空白に隣接していない
					else{
            // 省略
          }

やっていることは、isNextToBlank関数を用いて、空白がドラッグしたピースの隣にあるかどうかを判定しています。 尚、この判定が偽であれば、同じライン上にあることを想定して処理を進めます。

2.ピースの進行方向に立ち塞がっているのはピースなのか壁なのかを判定する

先ほどの判定によってドラッグしたピースが移動可能な位置にあった場合は、ピースの進行方向に立ち塞がっているのはピースなのかそれとも壁なのかを確認します。 また、ピースの進行方向には、負と正の方向があるため、その両方向で確認をする必要があります。 とはいえ、スライドパズルでは常に両方向にピースや壁が立ち塞がっているような造りになっており、また、それらが両方向で立ち塞がるパターンは以下の3通りであることも決まっています。

  • 両方向にピースがある
  • 負の方向にピース、正の方向に壁がある
  • 負の方向に壁、正の方向にピースがある

つまり、これらのパターン毎の移動の制限を行えば、どのピースをドラッグしても、進行方向にあるピースや壁にぶつかるようになるのです。

これらの判定は、隣に空白があるときとそうでないときでそれぞれ以下のように行っています。

隣に空白があるとき

隣に空白が隣接している場合、そこから更に上下左右の方向別に判定を行う必要があります。 何故なら、空白はピースが移動する度に隣接する方向が変わるためです。また、それに伴ってこの後移動を制限する際、空白が隣接している方向の移動範囲に指定する値の符号が、空白が隣接している方向によって変わるためでもあります。 そのため、ドラッグする度に空白が隣接している方向を確認する必要があるのです。

以下は、右に空白が隣接しているときの判定です。

          // 上下左右いずれかの方向で空白に隣接しているとき
					if(isNextToBlank(prevPieces, piece.index)) {
            // 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')
							){
                /* ... */
              }

							// p | t | b |
							else if(isNextToOther(prevPiece, piece, 'left')){
                /* ... */
              }

							// | t | b | p
							else if(isNextToOther(nextNextPiece, blank, 'right')){
                /* ... */
              }					
						}
          }else{
            // ...
          }

やっていることは、まずisNextToOther関数を用いて空白が右に隣接しているかどうかを判定しています。

次に、isSlidingPiece関数を用いて以下のようなドラッグするピースの進行方向とは異なる軸で空白と隣接しているピースが中途半端な位置で止まっているかどうかを判定し、止まっている場合はそのピースにぶつかるようにしています。

typescript slide puzzle img1

その後は、piecesから移動するピースの1つ前のピースprevPieceと2つ後(空白の1つ後)のピースnextNextPieceを取得後、isNextToOther関数を用いて、それらと移動するピースpieceの並びが先ほど挙げた3パターンの並びに一致しているかどうかをそれぞれ判定しています。

その判定方法は、まず両方向にピースがあるかどうかの判定は、以下のように1つ前のピースが左に且つ、2つ後のピースが右に隣接しているかどうかをチェックしています。

	if(
    isNextToOther(prevPiece, piece, 'left') &&
		isNextToOther(nextNextPiece, blank, 'right')
	){/*...*/}

続いて、負の方向にピース、正の方向に壁があるかどうかの判定は、1つ前のピースprevPieceがpieceの左に隣接しているかどうかをチェックしています。

else if(isNextToOther(prevPiece, piece, 'left')){/*...*/}

最後に、負の方向に壁、正の方向にピースがあるかどうかの判定は、2つ後のピースnextNextPieceが空白blankの右に隣接しているかどうかをチェックしています。

else if(isNextToOther(nextNextPiece, blank, 'right')){/*...*/}	

尚、2つ目以降の判定で壁があるかどうかのチェックを行っていませんが、そもそも行う必要がないのです。何故なら、1つ目の判定以降をelse if文で繋げているため、1つ目の判定で両方向にピースがなかった場合、残りのパターンは必然的に右か左のどちらか一方にピースがあり、もう一方は何もないということになります。何もないということはその先は壁であるため、壁があるかどうかの判定は行う必要がないのです。

隣に空白がないとき

隣に空白がないときは、同じライン上に空白があることを想定して3パターンの判定を行います。

また、これらの判定はx,y方向でそれぞれ行います。x方向の判定を例に挙げると以下の通りです。

          // 上下左右いずれかの方向で空白に隣接しているとき
					if(isNextToBlank(prevPieces, piece.index)) {
            // 省略
          }else{
            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')
							){							
								/* ... */
							}

							// p | t |
							else if(isNextToOther(prevPiece, piece, 'left')){						
								/* ... */
							}

							// | t | p
							else if(isNextToOther(nextPiece, piece, 'right')){
								/* ... */
							}
						}else if(piece.colNumber === blank.colNumber){
							// ...						
						}
          }

空白のことを考えなくて良い分、わかりやすい判定になっています。 やっていることは、piecesから移動するピースpieceの前後にあるpieceを取得後、それらが両方向に隣接している場合と片方でのみ隣接している場合で移動の制限を振り分けるようにしています。

3.translateの変化を抑える

では、先ほど挙げた3パターンの判定別に移動の制限を行い、どのピースをドラッグしても進行方向にあるピースや壁にぶつかるようにします。

その方法は、ドラッグしているピースの移動距離translateを、進行方向に立ち塞がるピースの移動距離や壁0を超えないようにclamp関数を用いてtranslateの変化を負の方向と正の方向でそれぞれ制限します。

typescript slidepuzzle img4

尚、このとき指定する値は以下のように、進む先にあるものによって決まっています。

進む先にあるもの 指定する値
ピース そのピースの移動距離
0
ピース(間に空白) (進む方向の符号)空白の幅+そのピースの移動距離
壁(間に空白) (進む方向の符号)空白の幅

進む先にあるピースとの間に空白がある場合は、以下のようにその先にあるピースがこちらに向かって移動すると、その分だけ空白が狭まることになります。

typescript slidepuzzle img5-3

そのため、上記のような空白の幅を空白の先にあるピースの移動分だけ狭める計算を行います。

制限後のtranslateは、restrictedTranslateとして扱います。

では、その制限を行っている部分のコードを見ていきます。

隣に空白があるとき

先ほどと同様に、右に空白が隣接しているときの移動の制限のみ例に挙げます。

// 省略

const restrictedTranslate = {
			x: 0,
			y: 0
		};

// 省略

          // 上下左右いずれかの方向で空白に隣接しているとき
					if(isNextToBlank(prevPieces, piece.index)) {
            // t | b
						if(isNextToOther(piece, blank, 'left')) {

							// 省略

							// 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)
								);
							}					
						}
          }else{
            // 省略
          }

// 省略
隣に空白がないとき

先ほどと同様に、x方向の移動の制限のみ例に挙げます。

// 省略

          // 上下左右いずれかの方向で空白に隣接しているとき
					if(isNextToBlank(prevPieces, piece.index)) {
            // 省略
          }else{
            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){
							// ...
							}						
						}
          }

// 省略

ドロップ時にピースの移動が完了していればそのピースと空白の順番を入れ替える

最後に、ピースがドロップされたときについてです。

ピースがドロップされたときに移動が完了していれば、そのピースと空白の順番を入れ替えます。「移動が完了している」とはピースが空白にぴったり重なるところでドロップされたときを指します。

手順

手順は次の通りです。

  1. ピースがドロップされたときに処理が行われるようにする
  2. ドラッグしていたピースを判定する
  3. ドロップされたピースの移動が完了しているかどうかを判定し、移動が完了していれば順番を入れ替える

1. ピースがドロップされたときに処理が行われるようにする

まず、useEffectmouseStatus.isUpを用いて、ピースがドロップされたときに処理が行われるようにしています。

useEffect(() => {
    if(!mouseStatus.isUp) return;

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

mouseStatus.isUpは、要素をドロップしたときにtrueに切り替わるため、上記のような判定を行ってドロップされたときのみ処理が行われるようにします。

2. ドラッグしていたピースを判定する

次に、以下の部分でpieces内のpieceからドラッグしていたピースを取得しています。取得後はまず、ドラッグが終了しているためpiece.isDraggingfalseに戻します。

setPieces(prevPieces => {
			prevPieces.forEach((piece, i) => {
        if(piece.isDragging) {
					piece.isDragging = false;

          // 省略

        }
      });
});

3. ピースの移動が完了していればドロップしたピースと空白の順番を入れ替える

最後に、ドロップされたピースの移動が完了しているかどうかを判定し、完了していればそのピースと空白の順番を入れ替えます。

移動が完了しているかどうか(空白の真上でドロップされたかどうか)は、ピースの移動した距離がピースのサイズと一致しているかどうかの判定で分かります。

if(
	Math.abs(piece.x) >= piece.width ||
	Math.abs(piece.y) >= piece.height
){
  // 省略
}

移動が完了していた場合は、そのピースを示すpieceと空白を示すpieceの順番を入れ替えます。

ですが、ただ単に

prevPieces[i] = {...blank};
prevPieces[blankIndex] = {...piece};

のように入れ替えるだけではいけません。

なぜなら、pieces内でpiece同士の順番が入れ替わっても、pieceが持つ順番や位置を示す以下のプロパティは入れ替え前の状態のままであるためです。

  • index
  • colNumber
  • rowNumber
  • x
  • y

このままでは、それ以降のピースがドラッグされたときに行われるピース周辺の判定が正しく行われなくなります。

ですから、piece同士を入れ替える前に、以下のようにして先にこれらのプロパティを変更する必要があります。

またその際、colNumber, rowNumber, x, yの変更は、ドラッグしていた方向によって振り分ける必要があります。

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

ここまでのコード

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

次回

以上で第三回を終わります。次回はピースをシャッフルする機能を追加します。