15スライドパズルミニゲーム

【JavaScript】 15スライドパズルを作る[2]:ピースの描画と移動

更新日:2023/07/18

JavaScriptでスライドパズルを作成しようということで始めた企画の2回目です。

今回は、パズルのピースの描画とクリックしたときの移動をアニメーションで表現します。

 

ピースの描画

背景が描画できたので、次はピースを描画します。

こちらも背景と同じように最初に座標計算をしておき、描画時に計算した結果を使用します。

slidepuzzle.js : ピースの描画


    /**
     * ピースの描画用オブジェクト
     * @param layer
     * @param animeLayer
     */
    const pieceDrawFunc = function ( layer , animeLayer ) {

            this.getLayer = ()=> layer;
            this.getAnimeLayer = ()=> animeLayer;

            this.puzzleSize; // 枠を除いたパズルのサイズ
            this.pieceSize;  // 1ピースのサイズ
            this.pieceData; // ピースデータの配列
            this.allPiceNUm; // ピースの総数
            this.pieceData;  // 盤上を分割した座標データ
            this.pieceImage;  // ピースのImageデータ

            this.pieceFillColor = "white";
            this.pieceStrokeColor = "dimgray";

            this.textStyle = {
                fillStyle : "black",
                strokeStyle : "white",
                textAlign : "center",
                textBaseline : "middle",
                lineWidth : 4,
                font : "20px 'arial'",
            };
    };

    pieceDrawFunc.prototype = {

        /**
         * ピースの描画に必要な座標を計算
         */
        init(){

            const layer =  this.getLayer();
            layer.setIndex(0);

            const   {size,frameSize,pieceNum,moveStep} = {...puzzleScreenInfo};
            const   topLeftPos =  frameSize;

            this.clear( );

            this.puzzleSize = size - frameSize * 2;
            const pieceSize = Math.round(this.puzzleSize / pieceNum);
            this.pieceSize = pieceSize;
            this.allPiceNUm = pieceNum * pieceNum;

            const pieceSizehalf =  Math.round(pieceSize / 2 );
            const pieceRect = [ 0 , 0 , pieceSize , pieceSize];

            this.pieceData = [];this.pieceImage = [];

            for( let i = 0 ; i < pieceNum ; i ++ ){
                const topPos = topLeftPos + i * pieceSize;

                for( let j = 0 ; j < pieceNum ; j ++ ){
                    const leftPos = topLeftPos + j * pieceSize;

                    layer.clearRect( pieceRect)
                        .rect( pieceRect , this.pieceFillColor , this.pieceStrokeColor )
                        .text( this.textStyle , (i*4 + j + 1).toString() , pieceSizehalf ,  pieceSizehalf );

                        // 座標データを記憶
                    this.pieceData.push( {
                        topLeftPos : [ leftPos  , topPos ],
                        rect : [ leftPos  , topPos , pieceSize , pieceSize]
                    });
                        // 画像イメージ等を記憶
                    this.pieceImage.push( layer.getImageData( pieceRect) );
                }
            }
            layer.clearRect( [0 ,0 ,  size,size] );

            layer.resetIndex();

        },
        /**
         * ピースを描画
         * @param piecePos ゲーム盤での位置 0~
         * @param pieceNumber ピースに表示する番号
         * @param anime アニメレイヤーに表示するかどうか
         */
        draw( piecePos , pieceNumber , anime = false){
            if( pieceNumber === null ) return;
            if( piecePos > this.allPiceNUm - 1  || pieceNumber >= this.allPiceNUm - 1 )
                throw new Error( "piecePos Max Over" );

            const piecePosData = this.pieceData[ piecePos ];
            const pieceImageData = this.pieceImage[ pieceNumber ];

            const posX =  piecePosData.topLeftPos[0];
            const posY =  piecePosData.topLeftPos[1];

            ((anime) ? this.getAnimeLayer() : this.getLayer()).clearRect( piecePosData.rect )
                .putImageData( pieceImageData , posX , posY);
        },
        /**
         * キャンバスのクリア
         */
        clear() {
            this.getLayer().clearRect( [0 ,0,puzzleScreenInfo.size,puzzleScreenInfo.size]);
        }
    };

スライドパズルでは、盤上を一ピースのサイズで切り分けられます。
切り分けたエリアを位置番号で管理します。
左上が0で右方向に1,2,3と増加、下段にいき4,5,6とカウントします。

スライドパズルの位置番号

上の図は、初期状態のピース番号でもあります。

init()メソッドでは、this.pieceData配列に、各位置の左上座標と矩形情報(左上座標、幅、高さ)を記憶しています。

this.pieceData[
    0 : {
             topLeftPos[ 左 , 上 ] : 位置番号0の左上座標
             rect[ 左 , 上 , 幅 , 高さ ] : 位置番号0の矩形情報
          }
    1 : {
          }

   ・・・

    15 : {
         }
]

同時に、変数pieceRectの位置にピースを一つずつ描画して、this.pieceImage配列に画像データを記憶しています。
※pieceRectの値は変更していないので、毎回同じ位置に描画しています。

this.pieceImage[
    0 : ピース番号0の画像データ
    1 : ピース番号1の画像データ

   ・・・

    15 : ピース番号15の画像データ
}

ここでは番号を描画しているだけですが、画像を盤面に貼り付けて変数pieceRectを変更することで、画像でのスライドパズルをおこなうことができます。

なお、描画の様子がブラウザに表示されるのを避けるために、最初にキャンバスを最下層に移動しています。

draw()メソッドは、指定した番号位置に指定したピース画像を描画します。

引数animeは、値がtrueのときアニメレイヤーに描画し、falseのときピースレイヤーに描画します。

 

ピースの描画のテスト

ここまで作成したコードをブラウザで表示してみます。

DOMの構築が終わりDOMContentLoadedイベントが発生したタイミングで、処理をおこないます。

slidepuzzle.js : 背景描画までのテスト

(()=>{

/**
   ここに
     キャンバスのサイズ設定      レイヤー(キャンバス)操作ヘルパーオブジェクト     キャンバスの作成     背景の描画      ピースの描画
  のコードを記述
*/

    window.addEventListener( "DOMContentLoaded" , ()=> {
         const [backGroundLyer,puzzleLayer,animeLayer] = makeSlidePuzzle("slidepuzzle"  );

         new backGroundDrawFunc( backGroundLyer ).init().draw( );

        const pieceDraw = new pieceDrawFunc( puzzleLayer,animeLayer );
        pieceDraw.init( );
        for( let i = 0; i < 15 ; i ++ ){
            pieceDraw.draw( i , i  );
        }

    });

})();

実行すると、次のような画面が表示されます。

スライドパズル 背景とピースを描画

 

ピースの移動

つぎはいよいよ、ピースの移動処理です。

pieceDrawFunc.prototypeに、移動処理を追加します。

slidepuzzle.js : 移動処理(アニメーション)追加


   pieceDrawFunc.prototype = {

        init(  ){ },  // 既存のメソッド
        draw( ) { },  // 既存のメソッド
        clear() { },  // 既存のメソッド
        /**
         * クリック情報からピース位置を取得
         * @param e クリックイベントデータ
         */
        getClickPiece( e ){
            const pieceSize = this.pieceSize;
            const {frameSize,pieceNum} = {...puzzleScreenInfo};

            const rect = e.target.getBoundingClientRect();
                  // キャンバスサイズとブラウザ上でのサイズ比率
            const scale = this.getLayer().getScale();

                  // ブラウザ座標→キャンバス上でのクリック座標計算
            let [x,y] = [ (e.clientX - rect.left) * scale,(e.clientY - rect.top)*scale];

            if( x >= this.puzzleSize || y >= this.puzzleSize ) return null;

            x -= frameSize; y -= frameSize;// 外枠分差し引く
            return Math.floor(x / pieceSize) + Math.floor(y / pieceSize ) * pieceNum;
        },
        /**
         * ピースの移動
         * @param fromPiece 開始位置
         * @param toPiece 終了入り
         * @param dir 移動方向
         * @param pieceNumber 移動するピース
         */
        move( fromPiece , toPiece , dir , pieceNumber  ){

            const fromData = this.pieceData[ fromPiece ];

                // 移動方向の決定 -1 : 左・上 0 移動なし 1 : 右・下
            const dirY = { "up" : -1 , "down" : 1 };
            const dirX = { "left" : -1 , "right" : 1 };
            const moveX = dir in dirX ? dirX[dir] : 0;
            const moveY = dir in dirY ? dirY[dir] : 0;

            const layer = this.getLayer();

            // アニメーションレイヤに移動するピースを描画
            this.draw( fromPiece , pieceNumber ,  true);
                // ピースレイヤーから移動するピースをクリア
            layer.clearRect( fromData.rect);
                // Promiseを返す
            return new Promise( (resolve, reject) => {

                const animeTime = puzzleScreenInfo.animeTime;

                const animeLayer = this.getAnimeLayer();
                const animeStyle = animeLayer.getCanvas().style;
                animeStyle.top = animeStyle.left = 0;

                    // キャンバス表示比率を取得
                const scale = animeLayer.getScale();

                    // 画面表示上でのピースのサイズを計算
                const pieceSize = Math.floor(this.pieceSize / scale);

                let startTime=null; // アニメーション開始時間

                const animeFunc = time => {

                        // 初回呼び出しは、startTimeを設定するのみ
                    if( startTime === null ) {
                        startTime = time;
                        window.requestAnimationFrame(animeFunc);return;
                    }

                        // 経過時間取得
                    const nowTime = time - startTime;

                        // 終了予定時間よりも経過した?
                    if( nowTime >= animeTime ) {
                            // ピースレイヤーに移動後のピースを描画
                        this.draw( toPiece , pieceNumber );
                            // アニメーションレイヤをクリア
                        animeLayer.clear();
                            // アニメーションレイヤのtop/left位置をリセット
                        animeStyle.top = animeStyle.left = 0;
                            // Promiseを解決
                        resolve( true );return;
                    }

                        // アニメ終了予定時間と現在の経過時間の比率を計算
                    const step = nowTime / animeTime;
                        // 移動距離を求める
                    const cX = Math.floor(moveX * step * pieceSize);
                    const cY = Math.floor(moveY * step * pieceSize);
                        // アニメーションレイヤのtop/leftを変更してアニメーションさせる
                    animeStyle.left = cX + "px";
                    animeStyle.top = cY + "px";

                    window.requestAnimationFrame(animeFunc);
                };
                window.requestAnimationFrame(animeFunc);

            });

        }

    };

getClickPieceメソッドは、ブラウザ上でクリックされた座標からクリックした位置番号を取得するメソッドです。
キャンバスのサイズとブラウザで表示されているサイズが同じとは限らないので、比率等を求めて計算しています。

詳しくは、次のページを参考にしてください。
参考記事:【JavaScript】 Canvas上でクリックした座標を取得する

moveメソッドは、アニメーションの描画をおこなっています。

アニメーションは、キャンバスのスタイル属性topとleftを変更する疑似的なものです。
書いて消して書いて…という繰り返しよりも効率がよいので、この方法を採用しました。

moveメソッドはPromiseオブジェクトを返し、アニメーションが終了した時点でresolveを呼び出します。
そのため、then()で後処理をおこなうことができます。

アニメーションのタイミングは、setInterval()ではなくてwindow.requestAnimationFrame()を使用しています。
このメソッドはブラウザの再描画前に、コールバック関数を呼び出します。

setInterval()は、コールバック関数が複数回呼び出されても、ブラウザの描画が一回も行われていない可能性があり、効率がよくありません。
window.requestAnimationFrame()は確実に描画されるので、効率がよいです。

ただし実行タイミングがブラウザの都合によるので、呼び出された回数による移動距離の計算ができません。
そこで、経過時間を元に、移動距離を求めます。

window.requestAnimationFrame()の引数がタイムスタンプのため、これを利用すると簡単です。

 

クリックイベントの処理

キャンバスをクリックしたときに実行される処理を作成します。

今までテストで行っていた処理をオブジェクト化して、その中でクリックイベントを登録します。

slidepuzzle.js : メイン処理(ゲーム初期化&クリックイベント捕捉)

    /**
     * パズルのメイン処理
     * @param id div要素のID
     */
    const siledePuzzle = function( id ){

         this.puzzleData; // パズルデータ配列(インデックス:位置 値:その位置のピース番号)
        this.allPiceNUm; // ピースの総数

        const [backGroundLyer,puzzleLayer,animeLayer] = makeSlidePuzzle( id );
        this.drawObj = {
            backGround : new backGroundDrawFunc( backGroundLyer ),
            piece : new pieceDrawFunc( puzzleLayer,animeLayer ),
        };

        this.init().reDraw();

        this.clickEnabled = true;

        animeLayer.getCanvas().addEventListener( "click" , e =>{
            if( !this.clickEnabled ) return;

            // クリック座標取得
            const clickNumber = this.drawObj.piece.getClickPiece( e );
            if( clickNumber === null ) {this.clickEnabled = true;return;}

            this.click(clickNumber);

        }, false);

    };

少し長いので、コンストラクタとプロトタイプをわけました。

ゲームのメインとなるデータ(this.puzzleData)は、一次元の数値配列です。
インデックスが位置番号に相当して、値はその位置にあるピース番号です。

this.puzzleData[
     0 : 位置0にあるピースの番号
     1 : 位置1にあるピースの番号
     ・・・・
     15 : 位置15にあるピースの番号
]
※空きスペースのピース番号はnull

this.puzzleDataの初期化は、 siledePuzzle.prototype.init()でおこなっています。

コンストラクタでは、キャンバスを作成して初期画面を表示後に、一番上にあるアニメレイヤーに対してクリックイベントを登録しています。

クリックイベントでは次のような処理をおこなっています。
なお一部のメソッドは、この後紹介するsiledePuzzleオブジェクトのプロトタイプで定義しています。

  1. clickEnabledフラグがfalseなら処理終了
  2. クリックした座標(ブラウザ上の座標)をキャンバス座標に変換して、クリックした位置番号を取得します
  3. clickEnabledフラグにfalseをセット
  4. 以降siledePuzzle.prototype.click()で処理
  5. クリックした位置から見て、上下左右にあるピースの情報(位置、ピースの番号、空きピースかどうか)を取得します
  6. 取得したデータに空きピースがあるかチェックします
  7. 空きピースがなければ終了。clickEnabledフラグにtrueをセット
  8. this.puzzleDataの値を移動後の状態に更新します
  9. 移動アニメーションを表示します。アニメーションが終了したらclickEnabledフラグにtrueをセット

slidepuzzle.js : メイン処理プロトタイプ

    siledePuzzle.prototype={
        /**
         * パズル初期化メソッド
         * @returns {siledePuzzle}

         */
        init(){
            this.puzzleData = [];
            this.allPiceNUm = puzzleScreenInfo.pieceNum * puzzleScreenInfo.pieceNum;

            for( let i = 0 ; i < this.allPiceNUm -1 ; i ++ )
                    this.puzzleData.push( i );

            this.puzzleData[ this.allPiceNUm -1 ] = null;

            this.drawObj.backGround.init();
            this.drawObj.piece.init( );
            return this;
        },
        /**
         * 再描画メソッド
         * @returns {siledePuzzle}

         */
        reDraw(){

            this.drawObj.backGround.draw( );
            this.puzzleData.forEach( (e,i)=>{
                this.drawObj.piece.draw( i , e );
            });
            return this;
        },
        /**
         * クリックされたピースを移動する
         * @param clickNumber
         * @returns {Promise<void>}

         */
        async click( clickNumber ){

            this.clickEnabled = false;
                // クリック周辺のピースを取得
            const aroundInfo = this.getAroundInfo( clickNumber  );
            if( aroundInfo === false || aroundInfo.current === null ) {
                this.clickEnabled = true;return;
            }

                // 周辺データから空きスペースを取得
            const emptyData = aroundInfo.data.filter( e=>e.number===null);
            if( emptyData.length <= 0 ) {thisclickEnabled = true;return;}

                // パズルデータの値を入れ替え
            [this.puzzleData[clickNumber],this.puzzleData[emptyData[0].pos]]
                = [this.puzzleData[emptyData[0].pos],this.puzzleData[clickNumber]];

                // 移動アニメーションをおこなう
            await this.drawObj.piece.move( clickNumber , emptyData[0].pos , emptyData[0].dir , aroundInfo.current );
               // 結果が出るまで停止(Promiseオブジェクトがリターンされる)

              // move()が完了したら、以降のコードが実行される
            this.clickEnabled=true;

        },
        /**
         * 指定した位置の左右上下の情報取得
         * @param pos
         * @returns {boolean|{current: (boolean|*), data: [{number: (boolean|*), pos, dir: string}, {number: (boolean|*), pos, dir: string}, {number: (boolean|*), pos, dir: string}, {number: (boolean|*), pos, dir: string}]}}
         */
        getAroundInfo( pos ){
            const pInfo = this.getPieceInfo( pos );
            if( pInfo === false ) return false;

            const {pieceNum} = {...puzzleScreenInfo};

            const   x = pos % pieceNum, y = Math.floor(pos / pieceNum );

            return {
                current : pInfo,  // クリック位置のピース番号 nullなら空き
                /**
                 * 上下左右のピース番号 
                 *     dir 方向 
                 *     pos その方向の位置番号 
                 *     number その方向のピース番号 nullなら空き falseならピースがない
                 */
                data:[
                    {dir:"left", pos : pos - 1 , number : x <= 0 ? false : this.getPieceInfo( pos - 1 ) },
                    {dir:"right",pos : pos + 1 , number : x >= pieceNum - 1 ? false : this.getPieceInfo( pos + 1 ) },
                    {dir:"up" , pos : pos - pieceNum , number: y <= 0 ? false : this.getPieceInfo( pos - pieceNum ) },
                    {dir:"down" , pos : pos + pieceNum , number : y >= pieceNum - 1 ? false : this.getPieceInfo( pos + pieceNum )}
                    ]
            };
        },
        /**
         * 指定した位置にあるピース番号を取得
         * @param pos
         */
        getPieceInfo( pos ){
            if( pos < 0 || pos >= this.allPiceNUm ) return false;
            return this.puzzleData[pos];
        },

    };

siledePuzzle.prototype.click()はメソッド名の前にasyncを付加して、そのメソッド内でawitキーワードが使用しています。

このとき、awaitで実行される関数はPromiseオブジェクトを返す必要があり、結果が確定するまで以降の処理が行われません。
ただし、処理はclick()メソッドを抜けて呼び出し元の処理を続行します。
つまり、アニメーション中もaddEventListenerで登録したクリックイベントが発生します。

ですが、アニメーションが終了するまでthis.clickEnabledがtrueにならないので、即座にリターンされます。

 

実行する

ここまでのコードを実行してみます。

slidepuzzle.js : 背景描画までのテスト

(()=>{

/**
   ここに
     キャンバスのサイズ設定      レイヤー(キャンバス)操作ヘルパーオブジェクト     キャンバスの作成     背景の描画      ピースの描画      移動処理(アニメーション)追加      メイン処理
      メイン処理プロトタイプ
  のコードを記述
*/

    window.addEventListener( "DOMContentLoaded" , ()=> {

        const puzzle = new siledePuzzle( "slidepuzzle" );

    });

})();

更新日:2023/07/18

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。