🖼️

React.jsでスライドパズル(3×3)を作る方法

2024/01/22に公開

完成したもの

□ スタジオジブリが公開している提供画像を使用してパズルを作った

□ 最短ルートでの正解

□ 作成したソースコード
https://github.com/sktaz/slide_pazzle_blossom/pull/1

React.jsで3×3のスライドパズルを作る

npx create-react-appを行い、追加のライブラリを無しでパズルを作る
※ コード整形用にprettierのライブラリを利用したことは除く

□ 利用した画像はスタジオジブリが公開している提供画像
https://www.ghibli.jp/info/013344/
 □ 実際に使用した画像はカオナシ(https://www.ghibli.jp/works/chihiro/#frame&gid=1&pid=20)

前提条件

□ Node.js (npm) をインストールしていること
入っていない場合は下記の公式サイト(https://nodejs.org/en/download)よりインストールする。

□ インストールされたかを確認する
terminalで下記を実行し、バージョンが表示されればOK。

npm -v
npx -v

1.Reactウェブアプリのプロジェクトを作成する

□ 任意のフォルダをVisual Studio Codeで開きReactアプリのプロジェクトを作成する。

npx create-react-app slide_puzzle

実行するか聞かれたらyを入力しenter

□ Visual Studio Codeでslide_puzzleフォルダを開き直す。

□ Reactウェブアプリが起動するかを確認する

npm start

http://localhost:3000/にアクセスしたら初期表示がされていることを確認

(任意) コード整形用のライブラリの設定

□ コード整形用のライブラリをinstall

npm i -D prettier

.prettierignoreファイルを作成し下記のように設定。

src/*.svg
src/**/*.svg

package.jsonscripts項目内にformatを下記のように追加

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "format": "prettier --ignore-path ./.prettierignore --write src/**/*"
  },

□ Visual Studio Codeのターミナルで下記を実行しコードが整形されるかを確認する
※ コード整形したくなったら下記を実行でコード整形される
※ Visual Studio Codeの設定で保存時に自動整形する設定も可能だが本題ではないので省略

npm run format

2.パズルを作るための準備

srcフォルダ内にSlidePazzle.jsファイルを作成する。今は空ファイルでOK。

□ スライドパズルのメイン画像を配置する
https://www.ghibli.jp/info/013344/ の好きな作品をクリックし、パズルにしたい画像を選ぶ。
/slide_puzzle/src内に配置する。

□ スライドパズルのblankタイル用の画像を配置する
/slide_puzzle/public内に配置する。
・130px × 130pxで作成してある画像であること。
・用意するのが面倒であれば下記からdownloadして配置してください。
https://github.com/sktaz/slide_pazzle_blossom/blob/main/public/blank_tile.png

□ .cssファイルを作成する(reset.cssとgame.css)
/slide_puzzle/src/style/reset.cssファイルを作成。
今は空ファイルでOK。

/slide_puzzle/src/style/game.cssファイルを作成。
今は空ファイルでOK。

□ ブラウザによってWEBサイトの見え方が変わってしまうのを無くすリセットCSSを用意する。
世の中ですでに色々出回っているので、よく使っている以下を利用する。
https://github.com/nicolas-cusan/destyle.css/blob/master/destyle.css のものをコピーして作成したreset.cssに貼り付ける。

□ 作成したSlidePazzle.jsとcssファイルを読み込む
/slide_puzzle/src/index.jsを下記のように変更する。

-import App from './App'
+import SlidePazzle from './SlidePazzle'
+import './style/reset.css'
+import './style/game.css'
-    <App />
+    <SlidePazzle />

変更後の全体は下記

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import reportWebVitals from './reportWebVitals'
import './style/reset.css'
import './style/game.css'
import SlidePazzle from './SlidePazzle'

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
  <React.StrictMode>
    <SlidePazzle />
  </React.StrictMode>
)

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

3.正解パズルを表示する枠を作成

まず正解のパズルを表示する枠を作成する

□ やることのイメージ
正解の画像は390px×390pxで表示するため、画像を入れる枠のcssの意味は下記となる。

□ SlidePazzle.jsとigame.cssに下記を追加する

/* 
/slide_puzzle/src/SlidePazzle.js
*/

const SlidePazzle = () => {
    return (
        <>
            <div className="seikai-waku">
            </div>
        </>
    )
}

export default SlidePazzle
/* 
/slide_puzzle/src/style/game.css
*/

.image-frame {
    width: 410px;
    height: 410px;
    background-color: #010a38;
    border: 10px solid #4d4d4d;
    border-radius: 10px;
    margin: 0 auto;
    display: flex;
    flex-wrap: wrap;
}

□ 枠が表示されていることを確認する

4.正解パズルを表示する

canvasを利用し正解パズルを描画する。

□ やることのイメージ

□ SlidePazzle.jsを下記のように変更する。

/* 
/slide_puzzle/src/SlidePazzle.js
*/

import React, { useState, useEffect } from 'react'
import defaultImage from './chihiro020.jpg'


const SlidePazzle = () => {
    /* base64エンコードした正解の画像を格納する **/
    const [seikaiImage, setSeikaiImage] = useState(null)
    
    /** 正解の画像を取得する */
    useEffect(() => {
        // <canvas>要素を生成

        const canvasElem = document.createElement('canvas')
        // <canvas>のサイズを設定
        canvasElem.width = 390
        canvasElem.height = 390
        // 2Dグラフィックを描画するためのメソッドやプロパティをもつオブジェクトを取得
        const ctx = canvasElem.getContext('2d')

        // 新たな<img>要素を作成
        const imageObj = new Image()
        // img要素のソースのパスを設定  イメージ: <img src="defaultImage">
        imageObj.src = defaultImage

        imageObj.onload = () => {
            // defaultImageを390×390で描画する
            ctx.drawImage(imageObj, 0, 0, 390, 390)
            // 画像をbase64エンコード
            const encodedDefaultImage = canvasElem.toDataURL()
            // seikaiImageにエンコード後の値を設定する
            setSeikaiImage(encodedDefaultImage)
        }
    }, [])

    return (
        <>
            <div className="image-frame">
                {seikaiImage ? <img className="" src={seikaiImage}></img> : <></>}
            </div>
        </>
    )
}

export default SlidePazzle

□ 正解の画像が表示されていることを確認する

5.スライドパズルを表示する枠を作成

□ SlidePazzle.jsを下記のように変更する。

~~省略~~
    <div className="image-frame">
	{seikaiImage ? <img className="" src={seikaiImage}></img> : <></>}
    </div>

+    <br></br>
+
+    <div className="image-frame">
+    </div>
~~省略~~    

□ 正解画像の下に枠が表示されていることを確認する

6.スライドパズルを表示する

SlidePazzle.jsを下記のように変更する。
□ やることのイメージ

□ stateの設定を追加

+ /** タイル画像を入れる配列 */
+ const [tileImageList, setTileImageList] = useState([])

□ スライドするタイル画像を生成するuseEffectを追加

    /** タイル画像を生成する */
    useEffect(() => {
        if (!seikaiImage) return

        // <canvas>要素を生成
        const canvas = document.createElement('canvas')
        // <canvas>のサイズを設定
        canvas.width = 130
        canvas.height = 130

        // 2Dグラフィックを描画するためのメソッドやプロパティをもつオブジェクトを取得
        const ctx = canvas.getContext('2d')

        // 新たな<img>要素を作成
        const img = new Image()
        // img要素のソースのパスを設定  イメージ: <img src="defaultImage">
        img.src = seikaiImage


        img.onload = async () => {
            // 130px × 130pxで描画する用の設定を格納した配列を作成
            // 補足: canvas内の390×390内のうち、○番目の画像はxとy軸のどこに描画するかという設定を定義する
            // ctx.drawImage(img, 描画を始めるx座標, 描画を始めるy座標, 元の画像の描画使用範囲幅, 元の画像の描画使用範囲高さ, 0, 0, 描画する幅, 描画する幅)
            const imageConfigureList = [
                {
                    orderNum: 1, // 画像の順番
                    customFunc: () => ctx.drawImage(img, 0, 0, 130, 130, 0, 0, 130, 130) // 画像をどの位置で描画するかの関数呼び出し
                },
                {
                    orderNum: 2,
                    customFunc: () => ctx.drawImage(img, 130, 0, 130, 130, 0, 0, 130, 130)
                },
                {
                    orderNum: 3,
                    customFunc: () => ctx.drawImage(img, 260, 0, 130, 130, 0, 0, 130, 130)
                },

                {
                    orderNum: 4,
                    customFunc: () => ctx.drawImage(img, 0, 130, 130, 130, 0, 0, 130, 130)
                },
                {
                    orderNum: 5,
                    customFunc: () => ctx.drawImage(img, 130, 130, 130, 130, 0, 0, 130, 130)
                },
                {
                    orderNum: 6,
                    customFunc: () => ctx.drawImage(img, 260, 130, 130, 130, 0, 0, 130, 130)
                },

                {
                    orderNum: 7,
                    customFunc: () => ctx.drawImage(img, 0, 260, 130, 130, 0, 0, 130, 130)
                },
                {
                    orderNum: 8,
                    customFunc: () => ctx.drawImage(img, 130, 260, 130, 130, 0, 0, 130, 130)
                },
                {
                    orderNum: 9,
                    customFunc: () => ''
                }
            ]


            // 画像を表示する順番List
            const displayOrder = [1, 8, 2, 7, 4, 3, 6, 5, 9]

            // 130×130の画像を格納する配列
            const list = []

            for (let row = 0; row < 3; row++) {
                for (let col = 0; col < 3; col++) {
                    // 画像を表示する順番Listの値を左から取り出していく
                    const selectedNum = displayOrder.shift()
                    const configure = imageConfigureList.find((imageConfigure) => {
                        return imageConfigure.orderNum == selectedNum
                    })


                    // 番号が9番目の場合はblankタイルを設定する    
                    if (selectedNum == 9) {
                        list.push({
                            id: row.toString() + '-' + col.toString(),
                            src: `${window.location.origin}/blank_tile.png`,
                            orderNum: selectedNum
                        })
                    } else
                    // それ以外は130pxで描画した画像を設定する
                    {
                        // jsonオブジェクトの項目として設定していたcustomFunc関数を呼び出し画像を描画
                        configure.customFunc()

                        // 画像をbase64エンコード
                        const encodedImage = canvas.toDataURL()

                        // 配列に{:id, :src, :orderNum}を格納
                        list.push({
                            id: row.toString() + '-' + col.toString(),
                            src: encodedImage,
                            orderNum: selectedNum
                        })
                    }
                }
            }

            // 画像が入った配列を取得 [{:id, :src, :orderNum}]
            const items = await Promise.all(list)
            setTileImageList(items)
        }
    }, [seikaiImage])


□ tileImageListをmapで表示する

    <div className="image-frame">
	{tileImageList.map((item, index) => {
	    return (
		<img
		    key={`image_${index}`}
		    id={item.id}
		    src={item?.src}
		    num={item?.orderNum}
		/>
	    )
	})}
    </div>  

□ 想定通り表示できているかを確認する

7.ドラッグ&ドロップできるようにする

SlidePazzle.jsを下記のように変更する。

□ やることのイメージ

□ 変数定義を追加

/* base64エンコードした正解の画像を格納する **/
const [seikaiImage, setSeikaiImage] = useState(null)

/** タイル画像を入れる配列 */
const [tileImageList, setTileImageList] = useState([])

+/** クリックした画像 */
+let clickedTile
+
+/** ドラッグした先 */
+let toTile
+
+/** ドラッグした回数 */
+let sumCount = 0

□ ドラッグを検知できるようにイベントリスナーを追加

    /** タイル画像のドラッグを検知できるようにイベントリスナーを追加 */
    useEffect(() => {
        if (tileImageList.length != 9) return

        const tile00 = document.getElementById('0-0')
        const tile01 = document.getElementById('0-1')
        const tile02 = document.getElementById('0-2')

        const tile10 = document.getElementById('1-0')
        const tile11 = document.getElementById('1-1')
        const tile12 = document.getElementById('1-2')

        const tile20 = document.getElementById('2-0')
        const tile21 = document.getElementById('2-1')
        const tile22 = document.getElementById('2-2')

        const list = [tile00, tile01, tile02, tile10, tile11, tile12, tile20, tile21, tile22]

        list.map((item) => {
            if (item) {
                item.addEventListener('dragstart', dragStart)

                item.addEventListener('dragover', dragOver)
                item.addEventListener('dragenter', dragEnter)
                item.addEventListener('dragleave', dragLeave)

                item.addEventListener('drop', dragDrop)
                item.addEventListener('dragend', dragEnd)
            }
        })
    }, [tileImageList])

    /** dragStart時に呼び出す関数 */
    const dragStart = (event) => {
        clickedTile = event.target // クリックしてドラッグしようとした画像を取得
    }

    /** dragOver時に呼び出す関数 */
    const dragOver = (event) => {
        event.preventDefault()
    }

    /** dragEnter時に呼び出す関数 */
    const dragEnter = (event) => {
        event.preventDefault()
    }

    /** dragLeave時に呼び出す関数 */
    const dragLeave = (event) => {
        event.preventDefault()
    }

    /** dragDrop時に呼び出す関数 */
    const dragDrop = (event) => {
        toTile = event.target // ドラッグ先の画像を取得
    }

    /** dragEnd時に呼び出す関数 */
    const dragEnd = () => {
        // ドラッグ先がblankでなければreturn
        // 補足:blank画像以外にdropさせないようにするための処理
        if (!toTile.getAttribute('src').includes('blank')) {
            return
        }


        // 上・下・右・左にしか動かせないように設定
        // 補足: 斜めに画像をドラッグ&ドロップできないようにするための処理
        const currCoords = clickedTile.id.split('-') // "0-0" -> ["0", "0"]
        const row = parseInt(currCoords[0])
        const col = parseInt(currCoords[1])

        const toCoords = toTile.id.split('-')
        const row2 = parseInt(toCoords[0])
        const col2 = parseInt(toCoords[1])

        const moveLeft = row == row2 && col2 == col - 1
        const moveRight = row == row2 && col2 == col + 1

        const moveUp = col == col2 && row2 == row - 1
        const moveDown = col == col2 && row2 == row + 1

        const isMovable = moveLeft || moveRight || moveUp || moveDown

        if (isMovable) {
            // imageの入れ替え
            const currImg = clickedTile.src
            const otherImg = toTile.src

            clickedTile.src = otherImg
            toTile.src = currImg

            // numの入れ替え(numは正解にたどりついたかの判定に利用するため、入れかえを行う)
            const currNum = clickedTile.getAttribute('num')
            const otherNum = toTile.getAttribute('num')
            clickedTile.setAttribute('num', otherNum)
            toTile.setAttribute('num', currNum)

            sumCount = sumCount + 1
            document.getElementById('sumCount').innerText = sumCount
        }
    }

□ ドラッグした回数の表示を追加する

    <div className="image-frame">
	{tileImageList.map((item, index) => {
	    return (
		<img
		    key={`image_${index}`}
		    id={item.id}
		    src={item?.src}
		    num={item?.orderNum}
		/>
	    )
	})}
    </div>
+    <br></br>
+    <div>
+	<span>動かした回数: </span>
+	<span id="sumCount" className="">
+	    {sumCount}
+	</span>
+   </div>

□ 上・下・左・右に動かせるか確認する

8.正解判定を追加する

SlidePazzle.jsを下記のように変更する。

□ やることイメージ

□ メッセージを格納するstateを追加

/* base64エンコードした正解の画像を格納する **/
const [seikaiImage, setSeikaiImage] = useState(null)

/** タイル画像を入れる配列 */
const [tileImageList, setTileImageList] = useState([])     
+ /** 成功メッセージ */
+ const [message, setMessage] = useState(null)

□ 正解したかチェックする関数を追加

    /** 正解したかチェックする関数 */
    const checkResult = () => {
        const tile00 = document.getElementById('0-0')
        const tile01 = document.getElementById('0-1')
        const tile02 = document.getElementById('0-2')

        const tile10 = document.getElementById('1-0')
        const tile11 = document.getElementById('1-1')
        const tile12 = document.getElementById('1-2')

        const tile20 = document.getElementById('2-0')
        const tile21 = document.getElementById('2-1')
        const tile22 = document.getElementById('2-2')

        const list = [tile00, tile01, tile02, tile10, tile11, tile12, tile20, tile21, tile22]


        // elemetの項目numの値が1~9に並んだら正解判定する
        const isCorrect = list.every((item, index) => {
            return item.getAttribute('num') == index + 1
        })

        if (isCorrect && !message) {
            setMessage(`正解です! ${sumCount}回で正解`)
            list.map((item, index) => {
                if (item) {
                    item.removeEventListener('dragstart', dragStart)

                    item.removeEventListener('dragover', dragOver)
                    item.removeEventListener('dragenter', dragEnter)
                    item.removeEventListener('dragleave', dragLeave)

                    item.removeEventListener('drop', dragDrop)
                    item.removeEventListener('dragend', dragEnd)
                }
            })
        } else {
            setMessage('')
        }
    }

dragEnd関数の最後に正解したかチェックする関数の呼び出しを追加する

if (isMovable) {
    // imageの入れ替え
    const currImg = clickedTile.src
    const otherImg = toTile.src

    clickedTile.src = otherImg
    toTile.src = currImg

    // numの入れ替え(numは正解にたどりついたかの判定に利用するため、入れかえを行う)
    const currNum = clickedTile.getAttribute('num')
    const otherNum = toTile.getAttribute('num')
    clickedTile.setAttribute('num', otherNum)
    toTile.setAttribute('num', currNum)

    sumCount = sumCount + 1
    document.getElementById('sumCount').innerText = sumCount
}

+/** 正解したかどうかチェックする */
+checkResult()

□ スライドパズルを解いたら正解の表示がされるかを確認する



スライドパズルの機能は完成です!
任意で見た目を整える後続の手順を実施してください。

□ (参考) displayOrderに設定する値を変えることで初期表示のパズルの順番を変更できます。

const displayOrder = [1, 8, 2, 7, 4, 3, 6, 5, 9]

9.見た目を整える

□ game.cssを下記に変更

/* 
/slide_puzzle/src/style/game.css
*/

h1,
h2,
h3 {
    margin-bottom: 10px;
}

.content {
    text-align: center;
    margin: 30px;

}

.content__head {
    margin-bottom: 20px;
    font-size: 2.3em;
}

.content__main {
    display: flex;
    font-size: 2em;
}

.content__block {
    margin: 0 auto;
}

.content__frame {
    width: 410px;
    height: 410px;
    background-color: #010a38;
    border: 10px solid #4d4d4d;
    border-radius: 10px;
    margin-bottom: 40px;
    margin-right: 20px;
}

.puzzle_image {
    width: 130px;
    height: 130px;
    border: 1px solid #010a38;
}

.content__footer {
    font-size: 1.2em;
}

□ SlidePazzle.jsのjsx部分を下記に変更

return (
<>
    <div class="content">
	<div className="content__head">
	    <h1>スライドパズル</h1>
	</div>
	<div className='content__main'>
	    <div className='content__block'>
		<h3>問題</h3>
		<div className="content__frame">
		    {tileImageList.map((item, index) => {
			return (
			    <img className='puzzle_image'
				key={`image_${index}`}
				id={item.id}
				src={item?.src}
				num={item?.orderNum}
			    />
			)
		    })}
		</div>
	    </div>

	    <div className='content__block'>
		<h3>正解の画像</h3>
		<div className="content__frame">
		    {seikaiImage ? <img className="" src={seikaiImage}></img> : <></>}
		</div>
	    </div>
	</div>
	<div className='content__footer'>

	    <h1>{message}</h1>
	    <span>動かした回数: </span>
	    <span id="sumCount" className="">
		{sumCount}
	    </span>
	</div>
    </div>

</>
)

□ パズルを解いて見た目を確認する

これで作成手順は全て終わりです!

参考:パズルの解答

□ gif分かりにくければこのサイト(https://deniz.co/8-puzzle-solver/)で正解の最短ルートを確認できる。
・ EnterCustomStateで182743650を入力し、Searchをクリック。
・ 0の位置をblankと見なすと動かす方向がわかる。

今回のソースコード

https://github.com/sktaz/slide_pazzle_blossom

Discussion