🖼️

HTML&CSSとピュアなJavaScriptだけでパズルゲームを作る方法

2024/01/28に公開

概要

□ スライドパズルのゲームを作る

□ 基本に立ち返りHTMLとCSSとピュアなJavaScriptだけで作成する
・パズルの実装はシンプルで簡単。
・考え方さえ理解すれば早いため、実装の前にイメージで説明する。

□ パズルを試したい人は https://jd253t.csb.app (codesandboxのpreview)から試せる
※下の埋め込み表示ではJavascriptが想定通り動作しないので上記のURLから試してください
※パズルはPCからの操作のみ対応

※codesandboxのpreviewが安定しないことがあります。もし表示が崩れていたらソースコードを貼っているのでローカルで試してください

イメージ.パズルを作る方法

□ 1.canvas要素(390×390で)を作成する

□ 2.写真を390×390でcanvas内に描画する

□ 3.スライドパズル画像を作る定義を作成 (正解の画像とBlank画像を利用)

□ 4.場所順に、画像1~9をdisplayOrderの値に基づき描画する

□ 5.各tile画像にdrag処理のイベントリスナーを追加する

□ 6.ドラッグ終了時にクリックした画像と宛先画像(Blank画像)を入れ替える

□ 7.斜めにはドラッグ&ドロップできないようにする

□ 8.正解判定を行う (ordernumの値を利用)



入れ替え前(初期表示時)

入れ替え後(正解時の表示)

実装手順.パズルを作る方法

0.パズルに利用したい写真の用意 (任意)

本記事でupload済みの写真があるのでこの手順0はskipしてもOKです
自分の写真を利用したい場合は下記を参考にして写真を用意してください。

□ slide_pazzle_winterという空のレポジトリをGitHubに作成する

□ パズルにしたい写真をGitHubにuploadする。サイズはなんでもOK。
※ canvasで描画する際にサイズ指定するので、元の写真のサイズはどんなサイズでもOKです。

https://github.com/sktaz/slide_pazzle_winter/blob/main/image/pazzle_image.jpg
ちなみにこの写真は観光で行った時に撮影した江の島シーキャンドルの湘南の宝石です。
すごく綺麗だった*^^*

□ blank画像をGitHubにuploadする
https://github.com/sktaz/slide_pazzle_winter/blob/main/image/blank_tile.png

補足: blank画像はここで利用します

□ (補足)GitHubへの写真のuploadが必要な理由
パズルの写真はJavascriptから読み込みCanvasを利用して描画する。
Canvasの仕様でローカルPCのfile://を参照して描画ができないため、GitHubのPublicのレポジトリなどに写真をuploadしておく必要がある。
※ Canvas: HTML5とJavaScriptでブラウザ上で図を描くことができる仕様

1.使うファイルの雛形を用意する

□ 任意のフォルダを作成し、Visual Studio Codeで開く
本記事ではpazzle_gameというフォルダを作成した

□ 今回使用するファイルたちを空ファイルで作成する
※今は空ファイルでOK
/pazzle_game/index.html
/pazzle_game/pazzle.css
/pazzle_game/pazzle.js
/pazzle_game/reset.css

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

□ index.htmlの雛形を作成する

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Slide Puzzle</title>
    <link rel="stylesheet" href="pazzle.css">
    <link rel="stylesheet" href="reset.css">
    <script src="pazzle.js"></script>
</head>

<body>
    動作確認用
    テストテストテスト
</body>

</html>

2.index.htmlにブラウザからアクセスできることを確認する

□ Visual Studio Codeでindex.htmlを右クリックし、Copy Pathを押す

□ ブラウザのURL欄に貼り付けてEnter
例:file:///Users/develop/pazzle_game/index.html

□ テストの文言が表示されていることを確認する

3.パズルの作成

まず初めに手順3-0で完成系を示します。
後続の手順3-1以降で完成系に至るまでの手順を説明します。

3-0.完成系

□ index.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Slide Puzzle</title>
    <link rel="stylesheet" href="pazzle.css">
    <link rel="stylesheet" href="reset.css">
    <script src="pazzle.js"></script>
</head>

<body>
    <div class="content">
        <div class="content__head">
            <h1>スライドパズル</h1>
        </div>

        <div class="content__main">
            <div class="content__item">
                <h2 class="content__description">問題</h2>
                <div class="content__frame">
                    <div id="pazzle"></div>
                </div>
            </div>


            <div class="content__item">
                <h2 class="content__description">正解</h2>
                <div class="content__frame">
                    <div id="seikai"></div>
                </div>
            </div>
        </div>

        <div class='content__footer'>
            <div id="success" class="message--success"></div>
            <div>
                <span>動かした回数:</span>
                <span id="sumCount">
                </span>
            </div>
        </div>
    </div>
</body>

</html>



□ pazzle.js

/* 共通関数: 画像の読み込み */
function loadImage(src) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.onload = () => {
            resolve(img)
        };
        img.src = src;
    });
}


/** ページが読み込まれたら実行 */
window.onload = () => {
    loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/pazzle_image.jpg").then(loadedImage => {
        // 正解画像を390×390で描画する
        const seikaiImage = drawSeiakImage(loadedImage)
        // blank画像を読み込む
        loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/blank_tile.png").then(blankTile => {
            // スライド用のタイル画像を生成する(引数: 390×390の正解画像, blank画像)
            createSlidePazzleImage(seikaiImage, blankTile)
        }).catch(e => {
            console.log('onload error', e);
        });

    }).catch(e => {
        console.log('onload error', e);
    });
};


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

    // 読み込んだ画像を390×390で描画する
    ctx.drawImage(loadedImage, 0, 0, 390, 390)

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

    // img要素を生成する
    // (補足)作っているものイメージ: <img src="base64エンコードしたした画像">
    let tile = document.createElement("img");
    tile.src = seikaiImage;

    // index.htmlファイルに定義したseikaiのdivを取得し、生成したimg要素を描画する
    document.getElementById("seikai").append(tile);

    // seikaiImageを戻り値として返す
    return seikaiImage
}



/** スライド用のタイル画像を生成する(引数: 390×390の正解画像, blank画像) **/
function createSlidePazzleImage(seikaiImage, blankTile) {

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

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

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

        const imageConfigureList = [
            {
                orderNum: 1, // 画像の順番
                customFunc: () => ctx.drawImage(image, 0, 0, 130, 130, 0, 0, 130, 130) // 画像をどの位置で描画するかの関数呼び出し
            },
            {
                orderNum: 2,
                customFunc: () => ctx.drawImage(image, 130, 0, 130, 130, 0, 0, 130, 130)
            },
            {
                orderNum: 3,
                customFunc: () => ctx.drawImage(image, 260, 0, 130, 130, 0, 0, 130, 130)
            },

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

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


        for (let row = 0; row < 3; row++) {
            for (let col = 0; col < 3; col++) {

                const selectedNum = displayOrder.shift()


                const configure = imageConfigureList.find((imageConfigure) => {
                    return imageConfigure.orderNum == selectedNum
                })

                let tile = document.createElement("img");

                // 以下で作っているもののイメージ: 
                // <img id="0-0" ordernum="1" class="tile__image" src="base64エンコードしたデータ"></img>
                // <img id="0-1" ordernum="8" class="tile__image" src="base64エンコードしたデータ"></img> ...

                tile.id = row.toString() + "-" + col.toString();
                tile.setAttribute("orderNum", selectedNum);
                tile.className = "tile__image"

                // 番号が9番目の場合はblankタイルを設定する    
                if (selectedNum == 9) {
                    tile.src = blankTile.src;
                } else {
                    // タイルの画像を描く
                    configure.customFunc()
                    // 画像をbase64エンコード
                    const encodedImage = canvas.toDataURL()
                    tile.src = encodedImage;
                }

                // index.htmlファイルに定義したpazzleのdivを取得し、生成したimg要素を描画する
                document.getElementById("pazzle").append(tile);


                // dragが起きた時に関数呼び出しされるようにリスナーを追加
                tile.addEventListener("dragstart", dragStart);
                tile.addEventListener("dragover", dragOver);
                tile.addEventListener("dragenter", dragEnter);
                tile.addEventListener("dragleave", dragLeave);
                tile.addEventListener("drop", dragDrop);
                tile.addEventListener("dragend", dragEnd);
            }
        }

    }
}


/** クリックした画像 */
let clickedTile

/** ドラッグした先 */
let toTile

/** ドラッグした回数 */
let sumCount = 0


/** 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('orderNum')
        const otherNum = toTile.getAttribute('orderNum')
        clickedTile.setAttribute('orderNum', otherNum)
        toTile.setAttribute('orderNum', currNum)

        // dragした回数をカウントする
        sumCount = sumCount + 1
        document.getElementById('sumCount').innerText = sumCount
    }

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


/** 成功時のメッセージ */
let message;

/** 正解したかチェックする関数 */
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('orderNum') == index + 1
    })

    if (isCorrect && !message) {
        // 補足: もしgetElementsByClassName使用したい場合はgetElementsByClassNameの戻り値は配列なので要素の番号を指定すること
        // 例: getElementsByClassName('message--success')[0]innerText
        document.getElementById('success').innerText = `正解です! ${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 {
        document.getElementById('success').innerText = ""
    }
}



□ pazzle.css

/** ページ共通の設定 */
.content {
    text-align: center;
    margin: 30px;
}


/** ページのタイトル表示 */
.content__head {
    margin-bottom: 20px;
    font-size: 2.3em;
}


/** content__itemを入れる親*/
.content__main {
    /** content__frameを横並び・中央寄せにする */
    display: flex;
    justify-content: center;

    /** ウィンドウの幅を縮めてもパズルの画像が崩れないように最低幅を指定 */
    min-width: 980px;
}

/** 画像の説明とパズルの枠を入れる*/
.content__item {
    margin-right: 50px;
    margin-bottom: 40px;
    font-size: 1.8em;
}

/** 問題と正解の文言 */
.content__description {
    margin-bottom: 20px;
}

/** 画像を入れている枠 */
.content__frame {
    width: 410px;
    height: 410px;
    background-color: #010a38;
    border: 10px solid #4d4d4d;
    border-radius: 10px;

}

/* タイル画像の境界の線  */
.tile__image {
    width: 130px;
    height: 130px;
    border: 1px solid #adb5bd;
}

完成系のコードは以上となります。
続きは完成系に至るまでの手順を説明します。

3-1.問題と正解の枠を作成する

□ index.htmlを以下のように作成する

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Slide Puzzle</title>
    <link rel="stylesheet" href="pazzle.css">
    <link rel="stylesheet" href="reset.css">
    <script src="pazzle.js"></script>
</head>

<body>
    <div class="content">
        <div class="content__head">
            <h1>スライドパズル</h1>
        </div>

        <div class="content__main">
            <div class="content__item">
                <h2 class="content__description">問題</h2>
                <div class="content__frame">
                    <div id="pazzle"></div>
                </div>
            </div>


            <div class="content__item">
                <h2 class="content__description">正解</h2>
                <div class="content__frame">
                    <div id="seikai"></div>
                </div>
            </div>
        </div>
    </div>
</body>

</html>

□ pazzle.cssを以下のように作成する

/** ページ共通の設定 */
.content {
    text-align: center;
    margin: 30px;
}


/** ページのタイトル表示 */
.content__head {
    margin-bottom: 20px;
    font-size: 2.3em;
}


/** content__itemを入れる親*/
.content__main {
    /** content__frameを横並び・中央寄せにする */
    display: flex;
    justify-content: center;

    /** ウィンドウの幅を縮めてもパズルの画像が崩れないように最低幅を指定 */
    min-width: 980px;
}

/** 画像の説明とパズルの枠を入れる*/
.content__item {
    margin-right: 50px;
    margin-bottom: 40px;
    font-size: 1.8em;
}

/** 問題と正解の文言 */
.content__description {
    margin-bottom: 20px;
}

/** 画像を入れている枠 */
.content__frame {
    width: 410px;
    height: 410px;
    background-color: #010a38;
    border: 10px solid #4d4d4d;
    border-radius: 10px;

}



□ index.htmlをブラウザで開き、問題と正解の枠が表示されていることを確認する

3-2.正解の画像を描画する

□ imageが読み込んでから次の挙動に移れるように共通関数を設定する (pazzle.js)
※ Javascriptは処理を待たずに次のコードを実行するので、画像を読み込んだ上で処理を行わせたい場合はPromiseを利用する必要がある。

/* 共通関数: 画像の読み込み */
function loadImage(src) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.onload = () => {
            resolve(img)
        };
        img.src = src;
    });
}

□ ページ全体読み込まれた時に正解画像を390×390で描画する (pazzle.js)

/** ページが読み込まれたら実行 */
window.onload = () => {
    // パズルに使用したい画像を読み込む
    loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/pazzle_image.jpg").then(loadedImage => {
        // 正解画像を390×390で描画する
        const seikaiImage = drawSeiakImage(loadedImage)
    }).catch(e => {
        console.log('onload error', e);
    });
};


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

    // 読み込んだ画像を390×390で描画する
    ctx.drawImage(loadedImage, 0, 0, 390, 390)

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

    // img要素を生成する
    // (補足)作っているものイメージ: <img src="base64エンコードしたした画像">
    let tile = document.createElement("img");
    tile.src = seikaiImage;

    // index.htmlファイルに定義したseikaiのdivを取得し、生成したimg要素を描画する
    document.getElementById("seikai").append(tile);

    // seikaiImageを戻り値として返す
    return seikaiImage
}



□ index.htmlをブラウザで開き、正解の画像が表示されていることを確認する

3-3.Blank画像の読み込みを追加する

スライドパズルは読み込んだ正解画像とBlank画像を使って生成する。
このため、Blank画像の読み込みが前提にあって、初めてスライドパズル画像が生成できるので、まずBlank画像の読み込みを追加する。

□ Blank画像の読み込み追加 (pazzle.js)

/** ページが読み込まれたら実行 */
window.onload = () => {
    loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/pazzle_image.jpg").then(loadedImage => {
        // 正解画像を390×390で描画する
        const seikaiImage = drawSeiakImage(loadedImage)
+        // blank画像を読み込む
+       loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/blank_tile.png").then(blankTile => {
+            console.log(blankTile)
+        }).catch(e => {
+            console.log('onload error', e);
+        });
+
+    }).catch(e => {
+        console.log('onload error', e);
+    });
};



□ index.htmlをブラウザで開き、ブラウザの開発者ツールのコンソールログを確認しimgタグがログに出力されていることを確認する。

3-4.問題のパズル画像を描画する

□ 問題の画像を描画する関数(createSlidePazzleImage)を定義する (pazzle.js)

/** スライド用のタイル画像を生成する(引数: 390×390の正解画像, blank画像) **/
function createSlidePazzleImage(seikaiImage, blankTile) {

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

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

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

        const imageConfigureList = [
            {
                orderNum: 1, // 画像の順番
                customFunc: () => ctx.drawImage(image, 0, 0, 130, 130, 0, 0, 130, 130) // 画像をどの位置で描画するかの関数呼び出し
            },
            {
                orderNum: 2,
                customFunc: () => ctx.drawImage(image, 130, 0, 130, 130, 0, 0, 130, 130)
            },
            {
                orderNum: 3,
                customFunc: () => ctx.drawImage(image, 260, 0, 130, 130, 0, 0, 130, 130)
            },

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

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


        for (let row = 0; row < 3; row++) {
            for (let col = 0; col < 3; col++) {

                const selectedNum = displayOrder.shift()


                const configure = imageConfigureList.find((imageConfigure) => {
                    return imageConfigure.orderNum == selectedNum
                })

                let tile = document.createElement("img");

                // 以下で作っているもののイメージ: 
                // <img id="0-0" ordernum="1" class="tile__image" src="base64エンコードしたデータ"></img>
                // <img id="0-1" ordernum="8" class="tile__image" src="base64エンコードしたデータ"></img> ...

                tile.id = row.toString() + "-" + col.toString();
                tile.setAttribute("orderNum", selectedNum);
                tile.className = "tile__image"

                // 番号が9番目の場合はblankタイルを設定する    
                if (selectedNum == 9) {
                    tile.src = blankTile.src;
                } else {
                    // タイルの画像を描く
                    configure.customFunc()
                    // 画像をbase64エンコード
                    const encodedImage = canvas.toDataURL()
                    tile.src = encodedImage;
                }

                // index.htmlファイルに定義したpazzleのdivを取得し、生成したimg要素を描画する
                document.getElementById("pazzle").append(tile);
            }
        }

    }
}

□ createSlidePazzleImageを呼び出し問題の画像を描画する (pazzle.js)


/** ページが読み込まれたら実行 */
window.onload = () => {
    loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/pazzle_image.jpg").then(loadedImage => {
        // 正解画像を390×390で描画する
        const seikaiImage = drawSeiakImage(loadedImage)
        // blank画像を読み込む
        loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/blank_tile.png").then(blankTile => {
-            console.log(blankTile)
+            // スライド用のタイル画像を生成する(引数: 390×390の正解画像, blank画像)
+            createSlidePazzleImage(seikaiImage, blankTile)
        }).catch(e => {
            console.log('onload error', e);
        });

    }).catch(e => {
        console.log('onload error', e);
    });
};




□ index.htmlをブラウザで開き、問題の画像が出力されていることを確認する

□ 参考:現時点でのpazzle.jsの全体

/* 共通関数: 画像の読み込み */
function loadImage(src) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.onload = () => {
            resolve(img)
        };
        img.src = src;
    });
}


/** ページが読み込まれたら実行 */
window.onload = () => {
    loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/pazzle_image.jpg").then(loadedImage => {
        // 正解画像を390×390で描画する
        const seikaiImage = drawSeiakImage(loadedImage)
        // blank画像を読み込む
        loadImage("https://raw.githubusercontent.com/sktaz/slide_pazzle_winter/main/image/blank_tile.png").then(blankTile => {
            // スライド用のタイル画像を生成する(引数: 390×390の正解画像, blank画像)
            createSlidePazzleImage(seikaiImage, blankTile)
        }).catch(e => {
            console.log('onload error', e);
        });

    }).catch(e => {
        console.log('onload error', e);
    });
};


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

    // 読み込んだ画像を390×390で描画する
    ctx.drawImage(loadedImage, 0, 0, 390, 390)

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

    // img要素を生成する
    // (補足)作っているものイメージ: <img src="base64エンコードしたした画像">
    let tile = document.createElement("img");
    tile.src = seikaiImage;

    // index.htmlファイルに定義したseikaiのdivを取得し、生成したimg要素を描画する
    document.getElementById("seikai").append(tile);

    // seikaiImageを戻り値として返す
    return seikaiImage
}



/** スライド用のタイル画像を生成する(引数: 390×390の正解画像, blank画像) **/
function createSlidePazzleImage(seikaiImage, blankTile) {

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

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

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

        const imageConfigureList = [
            {
                orderNum: 1, // 画像の順番
                customFunc: () => ctx.drawImage(image, 0, 0, 130, 130, 0, 0, 130, 130) // 画像をどの位置で描画するかの関数呼び出し
            },
            {
                orderNum: 2,
                customFunc: () => ctx.drawImage(image, 130, 0, 130, 130, 0, 0, 130, 130)
            },
            {
                orderNum: 3,
                customFunc: () => ctx.drawImage(image, 260, 0, 130, 130, 0, 0, 130, 130)
            },

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

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


        for (let row = 0; row < 3; row++) {
            for (let col = 0; col < 3; col++) {

                const selectedNum = displayOrder.shift()


                const configure = imageConfigureList.find((imageConfigure) => {
                    return imageConfigure.orderNum == selectedNum
                })

                let tile = document.createElement("img");

                // 以下で作っているもののイメージ: 
                // <img id="0-0" ordernum="1" class="tile__image" src="base64エンコードしたデータ"></img>
                // <img id="0-1" ordernum="8" class="tile__image" src="base64エンコードしたデータ"></img> ...

                tile.id = row.toString() + "-" + col.toString();
                tile.setAttribute("orderNum", selectedNum);
                tile.className = "tile__image"

                // 番号が9番目の場合はblankタイルを設定する    
                if (selectedNum == 9) {
                    tile.src = blankTile.src;
                } else {
                    // タイルの画像を描く
                    configure.customFunc()
                    // 画像をbase64エンコード
                    const encodedImage = canvas.toDataURL()
                    tile.src = encodedImage;
                }

                // index.htmlファイルに定義したpazzleのdivを取得し、生成したimg要素を描画する
                document.getElementById("pazzle").append(tile);
            }
        }

    }
}

3-4.タイル画像の境界のスタイルを追加する

□ pazzle.cssに下記を追加する

/* タイル画像の境界の線  */
.tile__image {
    width: 130px;
    height: 130px;
    border: 1px solid #adb5bd;
}

□ index.htmlをブラウザで開き、タイル画像の境界のスタイルがあることを確認する

3-5.タイル画像を動かせるようにする

□ dragが発生した時に動く関数を定義する (pazzle.js)

/** クリックした画像 */
let clickedTile

/** ドラッグした先 */
let toTile


/** 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('orderNum')
        const otherNum = toTile.getAttribute('orderNum')
        clickedTile.setAttribute('orderNum', otherNum)
        toTile.setAttribute('orderNum', currNum)
    }
}

□ createSlidePazzleImage関数にdragの関数呼び出しを追加する (pazzle.js)
dragが起きた時に関数呼び出しされるようにリスナーを追加

// 番号が9番目の場合はblankタイルを設定する    
if (selectedNum == 9) {
    tile.src = blankTile.src;
} else {
    // タイルの画像を描く
    configure.customFunc()
    // 画像をbase64エンコード
    const encodedImage = canvas.toDataURL()
    tile.src = encodedImage;
}

document.getElementById("pazzle").append(tile);


+ // dragが起きた時に関数呼び出しされるようにリスナーを追加
+ tile.addEventListener("dragstart", dragStart);
+ tile.addEventListener("dragover", dragOver);
+ tile.addEventListener("dragenter", dragEnter);
+ tile.addEventListener("dragleave", dragLeave);
+ tile.addEventListener("drop", dragDrop);
+ tile.addEventListener("dragend", dragEnd);



□ index.htmlをブラウザで開き、タイル画像を動かせることを確認する

3-6.動かした回数をカウントできるようにする

□ index.htmlに動かした回数を表示するエリアを追加する (index.html)
content__footerを追加する

            <div class="content__item">
                <h2 class="content__description">正解</h2>
                <div class="content__frame">
                    <div id="seikai"></div>
                </div>
            </div>
        </div>

+        <div class='content__footer'>
+            <div>
+                <span>動かした回数:</span>
+                <span id="sumCount">
+                </span>
+            </div>
+        </div>
    </div>

□ ドラッグした回数を格納する変数を追加する (pazzle.js)

/** クリックした画像 */
let clickedTile

/** ドラッグした先 */
let toTile

+ /** ドラッグした回数 */
+ let sumCount = 0

□ ドラッグした回数をカウントする処理をdragEnd関数の末尾に追加する (pazzle.js)

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('orderNum')
const otherNum = toTile.getAttribute('orderNum')
clickedTile.setAttribute('orderNum', otherNum)
toTile.setAttribute('orderNum', currNum)

+ // dragした回数をカウントする
+ sumCount = sumCount + 1
+ document.getElementById('sumCount').innerText = sumCount
}



□ index.htmlをブラウザで開き、タイル画像を動かすと回数がカウントアップすることを確認

3-7.正解したかチェックする処理を追加する

□ 3-7でやりたいことイメージ
orderNuumの値が1~9の順番になったら正解したと判定できる処理を追加する

初期表示時

正解時

□ index.htmlに正解時のメッセージを表示するエリアを追加する (index.html)

<div class='content__footer'>
+    <div id="success" class="message--success"></div>
    <div>
	<span>動かした回数:</span>
	<span id="sumCount">
	</span>
    </div>
</div>

□ 正解したかチェックするcheckResult関数を定義する (pazzle.js)

/** 成功時のメッセージ */
let message;

/** 正解したかチェックする関数 */
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('orderNum') == index + 1
    })

    if (isCorrect && !message) {
        // 補足: もしgetElementsByClassName使用したい場合はgetElementsByClassNameの戻り値は配列なので要素の番号を指定すること
        // 例: getElementsByClassName('message--success')[0]innerText
        document.getElementById('success').innerText = `正解です! ${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 {
        document.getElementById('success').innerText = ""
    }
}



□ dragEnd関数の最後にcheckResult関数の呼び出しを追加する (pazzle.js)

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

clickedTile.src = otherImg
toTile.src = currImg

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

// dragした回数をカウントする
sumCount = sumCount + 1
document.getElementById('sumCount').innerText = sumCount
}

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



□ index.htmlをブラウザで開き、正解するとメッセージ表示がされることを確認する

これでスライドパズルの完成です🖼️🌟

4.パズルの解答

本記事での設定displayOrder = [1, 8, 2, 7, 4, 3, 6, 5, 9]となっている場合

□ 0

□ 1

□ 2

□ 3

□ 4

□ 5

□ 6

□ 7

□ 8

□ 9

□ 10

□ 11

□ 12

参考.パズルの初期表示を変える方法

displayOrderの設定値を変更すると初期表示が変わる。
※もちろん初期表示の順番が変わるので、正解ルートも変わります。
※表示順によっては解けない問題もあるので解答の検索(https://deniz.co/8-puzzle-solver/)で確認してください。

pazzle.js
(本記事での設定)

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

参考.解答の検索

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

今回作成したソースコード

今回作成したコードは以下レポジトリに置いてあります。
https://github.com/sktaz/slide_pazzle_winter

React.js編はこちら

https://zenn.dev/ringo_to/articles/b021534da072e5

Discussion