ツクログネット

ドラッグ&ドロップで遊べる9パズルの作り方

eye catch

HTML+CSS+JavaScriptによる9パズルの作り方です。

Demo

始めに完成したものを以下に挙げます。

スタートボタンを押すとパズルのピースがシャッフル→タイムがカウントされてプレイ可能となります。 また、ピースはクリックではなく、ドラッグ&ドロップで移動します。 パズルの絵を完成させると見事クリアです。クリアするとタイムをシェアできたりリプレイ可能となります。

HTMLを書く

始めに以下のHTMLを書きます。

<div class="puzzle"> <div class="canvas-container"> <canvas class="canvas" id="canvas"></canvas> <div class="canvas-piece-container canvas-piece-container-1"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-2"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-3"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-4"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-5"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-6"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-7"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-8"> <canvas class="canvas-piece"></canvas> </div> <div class="canvas-piece-container canvas-piece-container-9"> <canvas class="canvas-piece"></canvas> </div> </div> <div class="center"> <div class="timer"></div> </div> <div class="center"> <button class="btn btn-tweet"><a class="twitter-share-button" data-text="aaa" data-size="large" data-dnt="true" target="_blank">結果をシェアする</a></button> </div> <div class="center"> <button class="btn btn-start">Start!</button> </div> <div class="center"> <button class="btn btn-replay">Replay!</button> </div> </div>

HTMLのポイントは以下の通りです。

2種類のcanvasを定義する

9パズルは2種類のcanvasで構成されています。

  • 基盤となるcanvas.canvas
  • 各ピースとなるcanvas.canvas-piece

また、これら2つのcanvasを後にJavaScriptでレスポンシブ対応するために各canvasをコンテナ(.canvas-container, .canvas-piece-container)で囲います。

タイマーを定義する

完成に掛かる時間を記録するためのタイマー.timerを定義します。

各ボタンを定義する

9パズルを始めるためのスタートボタン.btn-startとリプレイするためのリプレイボタン.btn-replay、更にはタイムをTwitterでシェアできるシェアボタン.btn-tweetを定義します。

CSS(SCSS)を書く

次に以下のCSSを書きます。

$body-color: #232830; $canvas-color: #2c303c; $start-btn-color: #F8823C; $tweet-btn-color: #03A9F4; $replay-btn-color: #58BE89; $gray: #666666; $light-gray: #eee; $white: #ffffff; *, *::before, *::after { box-sizing: border-box; } * { //font-size: 24px; font-size:4vw; @media screen and (min-width:600px){ font-size:16px; } } html { box-sizing: inherit; height:100%; width:100%; } body { background: $body-color; box-sizing: inherit; display:flex; align-items:center; justify-content:center; height:100%; margin: 0; padding: 0; width:100%; } .puzzle { width:80%; @media screen and (min-width:600px){ width:auto; } } .canvas-container { padding-top: 100%; position: relative; @media screen and (min-width:600px){ padding-top:400px; width:400px; } &::before { background: $canvas-color; content:''; display:block; position: absolute; top:-0.1em; left:-0.1em; bottom:-0.1em; right:-0.1em; } } .canvas, .canvas-piece { display: block; position: absolute; top: 0; left: 0; width: 100%; } .canvas-piece-container { .canvas-piece { background: $canvas-color; border: .1em solid $canvas-color; } &-2, &-5, &-8 { left: calc(100% / 3) !important; } &-3, &-6, &-9 { left: calc(100% / 3 * 2) !important; } &-4, &-5, &-6 { top: calc(100% / 3) !important; } &-7, &-8, &-9 { top: calc(100% / 3 * 2) !important; } } .canvas-piece-container { padding-top: calc(100% / 3); position: absolute; top: 0; left: 0; width: calc(100% / 3); } .blank { z-index: 100; } .target { z-index: 101; } .btn { border: none; border-radius: 0.2em; border-bottom-style: solid; border-bottom-width: 0.2em; color: $light-gray; cursor: pointer; font-size:1.2em; outline:none; padding:1em 1.8em; > a { display: inline-block; } &:active { border-bottom: none; transform:translateY(0.2em); } &-start{ background:$start-btn-color; border-bottom-color:darken($start-btn-color,20%); } &-tweet{ background:$tweet-btn-color; border-bottom-color:darken($tweet-btn-color,20%); } &-replay { background-color:$replay-btn-color; border-bottom-color:darken($replay-btn-color,20%); } } .timer { color: #999; font-family: 'Orbitron', sans-serif; } .none {//ボタンを非表示 display:none; } .center { text-align:center; } .mx-auto { margin-left: auto; margin-right: auto; } .mb-1 { margin-bottom:1em; }

CSSのポイントは以下の通りです。

canvasのアスペクト比が常に1:1となるようにする

9パズルは全ての要素が正方形なので、ビューポートの幅に応じてcanvasのアスペクト比が常に1:1を保つようにcanvasとそれを囲うコンテナに以下のCSSを適用します。

コンテナに適用するCSS

.canvasを囲うコンテナ.canvas-containerpadding-topwidthの値を等しくします。また.canvas.canvas-containerいっぱいに広げるためと、.canvas-containerを基準に.canvas-piece-containerをゼッタイ配置するためにposition:relative;を指定します。

.canvas-container { height: 0; padding-top: 100%; position: relative; }

.canvas-piece-containerにはpadding-top: calc(100% / 3);width: calc(100% / 3);を指定して.canvas-containerの1/3サイズとなるように配置します。

.canvas-piece-container { padding-top: calc(100% / 3); position: absolute; top: 0; left: 0; width: calc(100% / 3); }

canvasに設定するCSS

canvasにはそれぞれのコンテナ領域全体を常に覆うように伸縮させるために以下を適用します。

.canvas, .canvas-piece { display: block; position: absolute; top: 0; left: 0; width: 100%; }

ピースとなるcanvasを3×3で配置する

ピースとなる.canvas-pieceとそれを囲う.canvas-piece-container.canvas-containerを基準に3×3でゼッタイ配置します。

.canvas-piece-container-2, .canvas-piece-container-5, .canvas-piece-container-8 { left: calc(100% / 3) !important; } .canvas-piece-container-3, .canvas-piece-container-6, .canvas-piece-container-9 { left: calc(100% / 3 * 2) !important; } .canvas-piece-container-4, .canvas-piece-container-5, .canvas-piece-container-6 { top: calc(100% / 3) !important; } .canvas-piece-container-7, .canvas-piece-container-8, .canvas-piece-container-9 { top: calc(100% / 3 * 2) !important; }

JavaScript(TypeScript)を書く

最後にJavaScriptを書きます。

以下の順でクラスの作成とそのオブジェクトを生成します。

StartBtnクラスを作成・インスタンス化

スタートボタンに関するStartBtnクラスを作成し、インスタンス化します。

class StartBtn { protected elem: Element; protected isShow: boolean; constructor(isShow, elem = 'btn-start') { //ボタン要素の参照 this.elem = document.getElementsByClassName(elem)[0]; //初期状態でボタンを表示させるかどうか this.isShow = isShow; //trueであれば表示 if(this.isShow) { this.show(); }else { this.hide(); } //ボタンを押すとパズルスタート this.elem.addEventListener('click', () => { puzzle.start(); }, false); } //ボタンを非表示にするメソッド hide(): void { this.elem.classList.add('none'); } //ボタンを表示するメソッド show(): void { this.elem.classList.remove('none'); } } const startBtn = new StartBtn(true);

このクラスは.start-btn要素の参照elemプロパティと初期状態でボタンを表示させるかどうかを決めるisShowプロパティを持ちます。

また、ボタンを表示させるshowメソッドと非表示にするhideメソッドを併せて持ちます。

スタートボタンがクリックされたらpuzzleオブジェクトのstart()メソッドが実行され、パズルがスタートするようにしています。

インスタンス化の際は引数isShowtrueを代入します。

TweetBtnクラスを作成・インスタンス化

パズルの完成に掛かった時間を記録としてTwitterでシェアするためのボタンに関するTweetBtnクラスを作成・インスタンス化します。このクラスはStartBtnクラスを継承します。

class TweetBtn extends StartBtn { private anchor: Element; constructor(isShow, elem = 'btn-tweet'){ //StartBtnクラスのプロパティを引き継ぐ super(isShow, elem); //ボタンのa要素の参照 this.anchor = this.elem.getElementsByTagName('a')[0]; //ボタンを押すとツイート画面に遷移してタイムをシェア this.elem.addEventListener('click', () => this.tweet()); } //タイムをシェアできるようにするメソッド private tweet(): void { //シェア画面を表示するURLにタイムの文字列を挿入 let text = `https://twitter.com/share?url=http://tsukulog.local/&text=your time is ${timer.timeText}`; //URL文字列をUTF-8値にエンコードしてURLには使えない記号や文字を変換して使える形式にする let encoded = encodeURI(text); //href属性に追加 this.anchor.setAttribute("href",encoded); } } const tweetBtn = new TweetBtn(false);

このクラスは継承によってStartBtnのプロパティとメソッドを持っている他、.btn-tweetの子要素であるa要素の参照anchorとタイムのシェアに関するtweetメソッドを持ちます。

tweetメソッドはtimerオブジェクトで得られたタイムを埋め込んだURLを取得し、それをencodeURIメソッドに渡してエンコードしてURLとして使える形式に変換しています。エンコードしたURLencodedはa要素のhref属性に追加しています。

.btn-tweetを押すとtweetメソッドが実行され、ツイート画面が開きタイムをシェアできるようにしています。

シェアボタンはパズルをクリアしたときに表示させたいので、インスタンス化の際はメンバ変数isShowfalseを代入して初期状態では非表示にします。

ReplayBtnクラスを作成・インスタンス化

パズルをリプレイするためのリプレイボタンに関するReplayBtnクラスを作成・インスタンス化します。このクラスもStartBtnクラスを継承します。

class ReplayBtn extends StartBtn { constructor(isShow, elem = 'btn-replay') { //StartBtnクラスのプロパティを引き継ぐ super(isShow, elem); //ボタンをクリックするとパズルをリプレイ this.elem.addEventListener('click', () => puzzle.replay(), false); } } const replayBtn = new ReplayBtn(false);

このクラスはStartBtnのプロパティとメソッドのみを持ちます。

.replay-btnが押されるとpuzzleオブジェクトのreplayメソッドが実行され、9パズルが初期状態に戻るようにしています。

このボタンもシェアボタンと同じくクリア後に表示させたいのでインスタンス化の際はメンバ変数isShowfalseを代入します。

Timerクラスを作成・インスタンス化

タイマーに関するTimerクラスを作成・インスタンス化します。

class Timer { private _time: Element; private _timeText: string; private count: number; private timer: number; private sec: any; private min: any; private hour: any; constructor() { //.timerの参照 this._time = document.getElementsByClassName('timer')[0]; //タイムのテキスト this._timeText = this._time.innerHTML; //カウンター this.count = 0; //setIntervalを代入する変数 this.timer = null; //秒 this.sec = null; //分 this.min = null; //時間 this.hour = null; } //他のクラスで参照するためにgetterを定義 get timeText(): string { return this._timeText; } //.timer内にタイムの文字列を挿入するメソッド private addTime(): void { this._time.innerHTML = this.digital(); } //デジタル表記でタイムの文字列を返すメソッド private digital(): string{ //秒を取得 this.sec = this.count / 10; //分を取得 this.min = Math.floor(this.sec / 60); //%で60秒に到達したら再び0からカウント。toFixedで小数点以下を削除 this.sec = (this.sec % 60).toFixed(0); //時間を取得 this.hour = Math.floor(this.min / 60); //60分に到達したら0に戻してまたそこからカウント this.min = this.min % 60; //二桁(10秒)になるまで0を付けて二桁に if (this.sec < 10) { this.sec = `0${this.sec}`; } if (this.min < 10) { this.min = `0${this.min}`; } if (this.hour < 10) { this.hour = `0${this.hour}`; } //デジタル表記でタイムの文字列を返す return this.hour + ":" + this.min + ":" + this.sec; } //タイマーをスタートするメソッド public start(): void { this.timer = setInterval(() => { this.count++; this.addTime(); },100); } //タイマーをストップするメソッド public stop(): void { clearInterval(this.timer); } //タイマーをリセットするメソッド public reset(): void { this.count = 0; this.addTime(); } } const timer = new Timer;

このクラスはcountから秒・分・時間を取得してそれらをデジタル表記の文字列で返すdigitalメソッドを始め、digitalで返した文字列を.timer内に挿入するaddTimeメソッドと挿入した文字列を他のクラスで参照するためのtimeTextメソッド、タイマーをスタートするstartメソッド、タイマーをストップするstopメソッド、そしてタイマーをリセットするresetメソッドを持ちます。

スタートボタンが押されるとstartメソッドが実行されてタイマーがスタートし、countが100ミリ秒毎にインクリメントされます。次にdigitalメソッドでインクリメントされているcountから秒sec・分min・時間hourを算出し、それらを二桁未満の間のみゼロ埋めしてデジタル表示の文字列を返しています。その文字列をaddTimeメソッド内で.timer要素内に挿入することで、タイムがページ上に表示されます。

パズルをクリアするとstopメソッドが実行されてタイマーが停止し、countがインクリメントを止めるのでページ上にはクリアしたときのタイムが表示されます。更にクリアするとシェアボタンとリプレイボタンが表示され、シェアボタンが押されるとtimeTextメソッドで返されるクリアしたときのタイムの文字列がツイートのURLに埋め込まれます。リプレイボタンが押されるとcountを0に戻してaddTimeメソッドでリセットされたタイムを.timer要素内に上書きします。

Canvasクラスを作成

9パズルの基盤となるCanvasクラスを作成します。

class Canvas { protected _container: any; protected _elem: any; constructor() { //.canvas-containerの参照 this._container = document.querySelector('.canvas-container'); //.canvasの参照 this._elem = document.getElementById('canvas'); //ウィンドウがリサイズされるとresizeメソッドを実行 window.addEventListener('resize', () => this.resize(), false); } get elem(): any { return this._elem; } get container(): any { return this._container; } //.canvasをリサイズするメソッド protected resize(): void { //.canvasのサイズを更新 this._elem.width = this._container.clientWidth; this._elem.height = this._container.clientHeight; } }

Canvasクラスは.canvas要素の参照elemとそれを囲うコンテナcontainerの参照、そしてresizeメソッドを持ちます。

resizeメソッドは.canvasのサイズをコンテナのサイズに更新し、ウィンドウがリサイズされる度に実行されます。

PieceCanvasクラスを作成

各ピースのcanvasに関するPieceCanvasクラスを作成します。このクラスはCanvasクラスを継承します。

class PieceCanvas extends Canvas { private _ctxs: CanvasRenderingContext2D[]; private scales: number[]; private img: HTMLImageElement; private isInit: boolean; private bgWidth: number; constructor() { //Canvasクラスのプロパティを引き継ぐ super(); //元画像を生成 this.img = new Image; this.img.crossOrigin = "Anonymous"; this.img.src = "https://images.unsplash.com/photo-1504518856809-6b093a8ee667?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=600&h=600&fit=crop&s=650d0929b82003ab8ea062dfc73df213/"; //.canvas-pieceのコンテキストを格納する配列 this._ctxs = []; //scaleを格納する配列 this.scales = []; //.canvas-pieceを初期化するかどうか this.isInit = false; //画像の幅 this.bgWidth = 0; //.canvas-piece-containerの参照 this._container = document.getElementsByClassName("canvas-piece-container"); //.canvas-pieceの参照 this._elem = document.getElementsByClassName("canvas-piece"); //.canvas-pieceのコンテキストを全て取得 for(let i = 0; i < this._elem.length; i++) { this._ctxs[i] = this._elem[i].getContext("2d"); } //画像の読み込み完了後にresizeメソッドを実行 this.img.addEventListener('load', () => this.resize(), false); } get ctxs(): CanvasRenderingContext2D[] { return this._ctxs; } get elem(): any { return this._elem; } get container(): any { return this._container; } //.canvas-pieceとそれに描画されている画像をリサイズするメソッド protected resize(): void { //Canvasクラスのプロパティを引き継ぐ super.resize(); //.canvas-pieceを初期化 if(this.isInit) { for(let i = 0; i < this._ctxs.length; i++) { this._ctxs[i].clearRect(0, 0, this._elem[i].width, this._elem[i].height); } } else { this.isInit = true; } //.canvas-pieceに描画する画像のサイズ this.bgWidth = this.img.width / 3; //.canvas-pieceのサイズを更新 for(let i = 0; i < this._elem.length; i++) { this._elem[i].width = this._container[i].clientWidth; this._elem[i].height = this._container[i].clientHeight; } //bgWidthを1としたときのそれに対する_elem.widthの割合(1に対してどれぐらいか)。setTransformのデフォルト値が1だから for(let i = 0; i < this._elem.length; i++) { this.scales[i] = this._elem[i].width / this.bgWidth; } //描画されている画像をscaleスケールに変形(伸縮) for(let i = 0; i < this._ctxs.length; i++) { this._ctxs[i].setTransform(this.scales[i], 0, 0, this.scales[i], 0, 0); } } }

このクラスはCanvasクラスと同様のことを行っていますが、いくつか違う点があります。それは.canvas-pieceに描画される画像の元画像img.canvas-piece9個のコンテキストctxsと描画される絵の伸縮率scalesを持っている点です。

更に、imgの読み込み完了後とウィンドウのリサイズ後に.canvas-pieceのサイズを更新し、それと同時に.canvas-pieceに描画されている絵(画像・文字)をそれまでの比率を保ったまま再描画しているところがCanvasクラスにはない点です。

Pieceクラスを作成

ピースに関するPieceクラスを作成します。

class Piece { private canvas: PieceCanvas; private img: HTMLImageElement; private _elem: any; private _container: any; constructor() { //PieceCanvasクラスから生成されたオブジェクト this.canvas = new PieceCanvas; //.canvas-piece this._elem = this.canvas.elem; //.canvas-piece-container this._container = this.canvas.container; //元画像を生成 this.img = new Image; this.img.crossOrigin = "Anonymous"; this.img.src = "https://images.unsplash.com/photo-1504518856809-6b093a8ee667?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=600&h=600&fit=crop&s=650d0929b82003ab8ea062dfc73df213/"; //.canvas-pieceにクラスを追加 this.addNumClass(); //画像の読み込み完了後に.canvas-pieceにピースの絵を描画 this.img.addEventListener('load', (e) => { this.drawPicture(); }); //ウィンドウがリサイズされると.canvas-pieceにピースの絵を再描画 window.addEventListener('resize', () => { this.drawPicture(); }); } get elem(): any { return this._elem; } get container(): any { return this._container; } //.canvas-pieceと.canvas-piece-containerに番号入りのクラスを追加するメソッド private addNumClass(): void { const piece = this.canvas.elem; for(let i = 0; i < piece.length; i++) { //.canvas-pieceと.canvas-piece-containerに.num-1~9クラスを追加 //括弧をして先に計算させてから文字列にしている。 //https://www.ajaxtower.jp/js/ope/index16.html piece[i].classList.add(`num-${(i + 1)}`); piece[i].parentNode.classList.add(`num-${(i + 1)}`); //最後の.canvas-pieceには.blankクラスも追加する if(i >= piece.length-1){ piece[piece.length-1].classList.add('blank'); } } } //.canvas-pieceにピースの絵を描画するメソッド private drawPicture(): void { //.canvas-pieceに描画する画像のサイズ const width = this.img.width / 3; const height = this.img.height / 3; //元画像のサイズ const bgWidth = this.img.width; const bgHeight = this.img.height; //元画像から使用する9枚分の領域を格納する配列 const piecesVerticalArea = []; const piecesHorizontalArea = []; //元画像から9枚分の領域を取得 for (let i = 0; i * height < bgHeight; i++) { for (let j = 0; j * width < bgWidth; j++) { //垂直方向の座標を9枚分格納 piecesVerticalArea.push(i * height); //水平方向の座標を9枚分格納 piecesHorizontalArea.push(j * width); } } //元画像を生成 const img = new Image; img.crossOrigin = "Anonymous"; img.src = "https://images.unsplash.com/photo-1504518856809-6b093a8ee667?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=600&h=600&fit=crop&s=650d0929b82003ab8ea062dfc73df213/"; img.addEventListener('load', () => { for(let i = 0; i < this.canvas.ctxs.length - 1; i++) { const ctx = this.canvas.ctxs[i]; //.canvas-pieceに画像を描画 ctx.drawImage(img, piecesHorizontalArea[i], piecesVerticalArea[i], width, height, 0, 0, width, height); //.canvas-pieceの左上に番号を描画 ctx.beginPath(); ctx.rect(0,0, width, height); ctx.fillStyle = "white"; ctx.font = `${this.canvas.elem[i].width / 3}px cursive`; ctx.fillText(`${i+1}`, this.canvas.elem[i].width / 8, this.canvas.elem[i].height / 3); } }); } }

このクラスでは元画像から分割された画像や文字といった絵を.canvas-pieceに描画したり、クラスを追加してピースを完成させます。

絵の描画はdrawPictureメソッドで行います。始めに元画像からfor文の2重ループによって9等分した座標を縦piecesVerticalAreaと横piecesHorizontalAreaそれぞれ9つ取得します。

for文の2重ループによる座標の取得は以下の順で行われます。

  1. (0 * height, 0 * width)の座標→(0 * height, 1 * width)の座標→(0 * height, 2 * width)の座標
  2. (1 * height, 0 * width)の座標→(1 * height, 1 * width)の座標→(1 * height, 2 * width)の座標
  3. (2 * height, 0 * width)の座標→(2 * height, 1 * width)の座標→(2 * height, 2 * width)の座標

次にその座標からそれぞれどこまでの領域を使用範囲とするのかを決めますが、この領域は元画像の1/3の幅widthと高さheightとなります。

最後に元画像の上に取得したそれぞれの領域を重ねたときにその領域内に収まる部分を使用範囲として.canvas-pieceにそれぞれdrawImageメソッドで描画します。同時にfillTextメソッドで空白となる一番最後を除く各.canvas-pieceの左上に番号を描画してピースを完成させます。

このdrawPiectureメソッドは元画像が読み込まれたときとウィンドウがリサイズされたときに実行されます。

クラスの追加はaddNumClassメソッドで行ないます。.canvas-pieceとそれを囲うコンテナ.canvas-piece-container.num-1~9クラスを追加し、更に一番最後の.canvas-pieceには空白を意味する.blankクラスを追加します。これらのクラスは後に登場するクリア判定で意味を成します。

PieceInfoクラスを作成

ピースを操作する際に必要となるピースの情報に関するPieceInfoクラスを作成します。

class PieceInfo { public static getName(piece, index): {} { return { 'elem':piece, 'parent':piece.parentNode, 'parentX':piece.parentNode.clientLeft, 'parentY':piece.parentNode.clientTop, 'parentWidth':piece.parentNode.clientWidth, 'parentHeight':piece.parentNode.clientHeight, 'index':index, 'x':piece.clientLeft, 'y':piece.clientTop, 'width':piece.clientWidth, 'height':piece.clientHeight }; } }

このクラスはgetNameメソッドのみを持ちます。中身はcanvas要素elemとその親要素parentに、それらの座標・サイズ(x(y), width(height), parentX(parentY), parentWidth(parentHeight ) )、そしてcanvasのインデックスindexがセットされています。

Puzzleクラスを作成・インスタンス化

9パズル全体に関するPuzzleクラスを作成します。

class Puzzle { private canvas: Canvas; public piece: Piece; public static isPlaying: boolean = false; private pieces: Piece[]; private count: number; private readonly max: number; private ascendingOrderPieces: Piece[]; private _elem: any; constructor() { //canvasオブジェクト this.canvas = new Canvas; //pieceオブジェクト this.piece = new Piece; //ピースをシャッフルするために使う配列 this.pieces = []; //シャッフルしている間にインクリメントするカウンター this.count = 0; //countの上限 this.max = 20; //昇順で.canvas-pieceを格納する配列 this.ascendingOrderPieces = []; //.canvas this._elem = this.canvas.elem; //パズルをリセットするために必要 for(let i = 0; i < this.piece.elem.length; i++) { this.ascendingOrderPieces[i] = this.piece.elem[i]; } } get elem(): any { return this._elem; } //パズルをスタートするメソッド public start(): void { this.count = 0; this.pieces = []; startBtn.hide(); //シャッフルするためにpiecesに格納 for(let i = 0; i < this.piece.elem.length; i++) { this.pieces[i] = this.piece.elem[i]; } //ピースをシャッフル this.shuffle(); } //ピースをシャッフルするメソッド private shuffle(): void { const requestId = window.requestAnimationFrame(this.shuffle.bind(this)); let pieceLen = this.pieces.length; //シャッフル while (pieceLen){ const r = Math.floor(Math.random() * pieceLen); let getCurrentVal = this.pieces[--pieceLen]; this.pieces[pieceLen] = this.pieces[r]; this.pieces[r] = getCurrentVal; } //コンテナの子要素を全て削除 for(let i = 0; i < this.piece.container.length; i++){//小要素を全て削除 while(this.piece.container[i].firstChild) this.piece.container[i].removeChild(this.piece.container[i].firstChild); } //シャッフルした子要素をコンテナに追加 for(let i = 0; i < this.piece.container.length; i++){//shuffleした子要素を追加 this.piece.container[i].appendChild(this.pieces[i]); } //shuffleメソッドが実行される度にインクリメント this.count++; //countが20に到達したらシャッフルを止めてパズルスタート if(this.count >= this.max){ Puzzle.isPlaying = true; window.cancelAnimationFrame(requestId); timer.start(); return; } } //パズルをリプレイするメソッド public replay(): void { timer.reset(); tweetBtn.hide(); replayBtn.hide(); startBtn.show(); this.reset(); } //ピースの並びを基に戻すメソッド private reset(): void { //コンテナの子要素を全て削除 for(let i = 0; i < this.piece.container.length; i++){ while(this.piece.container[i].firstChild) this.piece.container[i].removeChild(this.piece.container[i].firstChild); } //コンテナ内のピースをそれぞれ元の場所に戻す for(let i = 0; i < this.piece.container.length; i++){ this.piece.container[i].appendChild(this.ascendingOrderPieces[i]); } } } const puzzle = new Puzzle;

このクラスはパズルのスタート・シャッフル・リセット・リプレイといったパズル全体を制御します。

スタートはstartメソッドで行い、スタートボタンが押されると実行されます。やっていることはスタートボタンを非表示にして、各ピース.canvas-pieceをシャッフル用の配列piecesに代入してshuffleメソッドを実行してpieces内のピースをシャッフルします。

シャッフルはshuffleメソッドで行ない、startメソッドが実行されると実行されます。「Fisher-Yates」のシャッフルアルゴリズムを採用し、それをrequestAnimationFrameメソッドでシャッフルをアニメーションで表現しています。

シャッフルアニメーションは以下のようにして行われています。

  1. requestAnimationFrameメソッドでshuffleメソッドを繰り返し実行する。またアニメーションの停止に必要となるため、requestAnimationFramerequestIdに代入しておく。
  2. Math.randomメソッドにピースの枚数pieceLenを掛けて0~8までのうちのランダムな値rを取得する。
  3. ここからがwhile文によるシャッフルアルゴリズムである。pieces内の--pieceLen(8)番地つまり最後尾の要素をgetCurrentValとして取得する。ここで--pieceLenでカウントダウンしたのでpieceLenは8となる。
  4. 1.で取得したランダムな値rの番地にあるpieces内の要素をpieces内のpieceLen(8)番地に代入する。
  5. 3.の逆でgetCurrentValpiece内のr番地に代入する。
  6. 2.~4.をpieceLenが0(false)になるまで行う。ここまでがシャッフルアルゴリズムである。
  7. コンテナ内のピースをすべて削除する。
  8. シャッフルしたピースをコンテナに追加していく。これでコンテナ内のピースはシャッフル後の並びとなる。
  9. countをインクリメントする。
  10. countmax(20)に達していたらisPlayingtrueに切り替えてピースのドラッグ操作を許可し、cancelAnimationFrameメソッドでシャッフルアニメーションを停止する。更にtimerオブジェクトのstartメソッドを実行し、タイマーをスタートしてreturn;shuffleメソッドから抜ける。達していなければ再び1.から順に行う。

リプレイはreplayメソッドで行い、replayボタンが押されると実行されてtimerオブジェクトのresetメソッドが実行されてタイマーをリセットします。 続けてtweetBtnオブジェクトとreplayBtnオブジェクトのhideメソッドが実行されてシェアボタンとリプレイボタンを非表示にし、startBtnオブジェクトのshowメソッドが実行されてスタートボタンが表示されます。 最後に、自身のresetメソッドを実行してピースを元の位置に戻します。

Pointerクラスを作成・インスタンス化

先人たちが制作した9パズルの殆どがピースをクリックで操作するものでした。ですが、今回は操作性を実物に近づけるためにクリックではなくドラッグ&ドロップでピースを操作します。この操作性を実現するPointerクラスを作成・インスタンス化します。

class Pointer { private isDragging: boolean; private dragRight: boolean; private dragLeft: boolean; private dragTop: boolean; private dragBottom: boolean; private start: number; private target: any; private dx: number; private dy: number; private mx: number; private my: number; private blank: any; private progress: number; constructor() { //ドラッグしているかどうか this.isDragging = false; //どの方向へドラッグしたを示す this.dragRight = false; this.dragLeft = false; this.dragTop = false; this.dragBottom = false; //ドラッグを開始した時刻 this.start = 0; //ドラッグしているピース this.target = null; //ピースを押したときのカーソル(指)の座標 this.dx = 0; this.dy = 0; //ピースをドラッグしているときのカーソル(指)の座標 this.mx = 0; this.my = 0; //空白 this.blank = null; //ドラッグからドロップまでの経過時間 this.progress = 0; const piece = puzzle.piece; //ピースをドラッグ&ドロップできるようにする処理 for(let i = 0; i < piece.elem.length; i++) { piece.elem[i].addEventListener("mousedown", e => this.down(e), false); piece.elem[i].addEventListener("touchstart", e => this.down(e), false); } document.body.addEventListener("mousemove", e => this.move(e), false); document.body.addEventListener("touchmove", e => this.move(e), false); document.body.addEventListener("mouseup", e => this.up(e), false); document.body.addEventListener("touchend", e => this.up(e), false); document.body.addEventListener("mouseleave", e => this.up(e), false); document.body.addEventListener("touchleave", e => this.up(e), false); } //ピースを押したときに実行される private down(e): void { //パズルがスタートしていれば実行 if(Puzzle.isPlaying){ const piece = puzzle.piece; //空欄のピースとその情報を取得する for(let i = 0; i < piece.elem.length; i++) { if(piece.elem[i].classList.contains('blank')){ const blank = piece.elem[i]; this.blank = PieceInfo.getName(blank, i);//シャッフル後のblankの位置情報を更新 break; } } e.preventDefault(); //ドラッグしたときの時刻 this.start = +new Date(); //マウス・タッチイベントの差異を吸収 let event; if(e.type === "mousedown"){ event = e; }else{ event = e.changedTouches[0]; } //押下したピースとその情報を取得 //targetのindexを取るためにforを使う for(let i = 0; i < piece.elem.length; i++){ if(piece.elem[i] == event.target){ const target = piece.elem[i]; this.target = PieceInfo.getName(target, i); console.log(this.target); //ターゲットが見つかったらループ処理を終了させる。 break; } } //ターゲットのcanvasに.targetクラスを追加 this.target.elem.classList.add('target'); //ドラッグできないピースであればドラッグ中止 if(this.noDrag()) { return; } //ドラッグした this.isDragging = true; //ピースを押したときのカーソル(指)の座標 this.dx = event.clientX this.dy = event.clientY } } //空白や枠外などドラッグできなければtrueを返すメソッド private noDrag(): boolean { const target = this.target; const blank = this.blank; //押したピースが空白であればドラッグ、ダメ、ゼッタイ if(target.elem == blank.elem){ blank.elem.classList.remove('target'); this.isDragging = false; return true; } const targetRect = target.elem.getBoundingClientRect(); const puzzleRect = puzzle.elem.getBoundingClientRect(); //右へスライドしたときにすぐ右が右外だったらスライドキャンセル if(Math.ceil(targetRect.right) >= Math.ceil(puzzleRect.right) && target.index !== blank.index + 1 && target.index !== blank.index + 3 && target.index !== blank.index - 3) { target.elem.classList.remove('target'); target.elem.style.left = ''; this.isDragging = false; return true; } //左へスライドしたときにすぐ左が左外だったらスライドキャンセル if(Math.ceil(targetRect.left) <= Math.ceil(puzzleRect.left) && target.index !== blank.index - 1 && target.index !== blank.index + 3 && target.index !== blank.index - 3) { target.elem.classList.remove('target'); target.elem.style.left = ''; this.isDragging = false; return true; } } //ピースの移動できる方向を返すメソッド private isDirection(): boolean { const target = this.target; const blank = this.blank; //ターゲットが空白の1つ前に位置していれば if(target.index == blank.index - 1){ return this.dragRight = true; //ターゲットが空白の1つ後に位置していれば }else if(target.index == blank.index + 1){ return this.dragLeft = true; //ターゲットが空白の真上に位置していれば }else if(target.index == blank.index - 3){ return this.dragBottom = true; //ターゲットが空白の真下に位置していれば }else if(target.index == blank.index + 3){ return this.dragTop = true; } } //ピースを押したまま動かしたときに実行されるメソッド private move(e): void { //マウス・タッチイベントの差異を吸収 let event; if(e.type === "mousemove"){ event = e; }else{ event = e.changedTouches[0]; } //ドラッグ中であれば実行 if(this.isDragging){ e.preventDefault(); //dx(dy)からの相対的な移動中の座標 this.mx = event.clientX - this.dx; this.my = event.clientY - this.dy; const mx = this.mx; const my = this.my; const target = this.target; const blank = this.blank; //ピースの移動できる方向によって移動範囲を制限する switch(this.isDirection()) { case this.dragRight: target.elem.style.left = `${Pointer.clampPos(mx, target.parentX, target.parentX + target.parentWidth)}px`; break; case this.dragLeft: target.elem.style.left = `${Pointer.clampNeg(mx,target.parentX, target.parentX - target.parentWidth)}px`; break; case this.dragBottom: target.elem.style.top = `${Pointer.clampPos(my,target.parentY, target.parentY + target.parentHeight)}px`; break; case this.dragTop: target.elem.style.top = `${Pointer.clampNeg(my,target.parentY, target.parentY - target.parentHeight)}px`; break; default: break; } console.log(`top: ${this.dragTop}, bottom: ${this.dragBottom}, left: ${this.dragLeft}, right: ${this.dragRight}`); } } //ピースのスライド後に空白とピースのcanvasを入れ替えるメソッド private change(): void { const mx = this.mx; const my = this.my; const target = this.target; const blank = this.blank; //→方向へドラックする場合 if(this.dragRight){ //移動量がピースの3分の1の値であれば if(mx >= (blank.width / 3)){ //空白とターゲットのインデックスを入れ替える blank.index = target.index; //↑でtargetのインデックスが代入されたため、1を足して代入される前の値に戻してから代入している target.index = blank.index + 1; //次に備えてリセット target.elem.style = ''; //空白とターゲットのピースを入れ替える blank.parent.appendChild(target.elem); target.parent.appendChild(blank.elem); //空白とターゲットの親要素を入れ替える blank.parent = target.parent; target.parent = blank.parent; //移動したのでリセット this.dragRight = false; //十分な移動量でなければ移動取り消し }else{ target.elem.style.left = ''; blank.elem.style.left = ''; this.dragRight = false; } //以下同様 }else if(this.dragLeft){ if(mx <= -(blank.width / 3)){ target.index = blank.index; blank.index = target.index + 1; target.elem.style = ''; blank.parent.appendChild(target.elem); target.parent.appendChild(blank.elem); blank.parent = target.parent; target.parent = blank.parent; this.dragLeft= false; }else{ target.elem.style.left = ''; blank.elem.style.left = ''; this.dragLeft = false; } }else if(this.dragBottom){ if(my >= (blank.height / 3)){ blank.index = target.index; target.index = blank.index + 3; target.elem.style = ''; blank.parent.appendChild(target.elem); target.parent.appendChild(blank.elem); blank.parent = target.parent; target.parent = blank.parent; this.dragBottom = false; }else{ target.elem.style.top = ''; blank.elem.style.top = ''; this.dragBottom = false; } }else if(this.dragTop){ if(my <= -(blank.height / 3)){ target.index = blank.index; blank.index = target.index + 3; target.elem.style = ''; blank.parent.appendChild(target.elem); target.parent.appendChild(blank.elem); blank.parent = target.parent; target.parent = blank.parent; this.dragTop = false; }else{ target.elem.style.top = ''; blank.elem.style.top = ''; this.dragTop = false; } }else { target.elem.style.top = ''; target.elem.style.left = ''; blank.elem.style.top = ''; blank.elem.style.left = ''; this.dragTop = false; this.dragBottom = false; this.dragLeft = false; this.dragRight = false; } } //ドラッグ中のピースをドロップしたときに実行されるメソッド private up(e): void { //ドラッグ中のみ実行 if(this.isDragging){ //ドロップしたときの時刻 const now = +new Date(); //ドラッグからドロップまでの差 this.progress = now - this.start; //ドラッグ終了 this.isDragging = false; //ターゲットからtargetクラスを削除 this.target.elem.classList.remove('target'); //フリックしたかどうか this.isFlick(); //ターゲットと空白を入れ替える this.change(); //クリア判定 this.isClear(); } } //フリックしたかどうかを判定するメソッド private isFlick(): void { const target = this.target; const blank = this.blank; //ドラッグの経過時間が短ければ(フリックであれば)キャンセル if(this.progress < 200){ //元に戻す this.dragRight = false; this.dragLeft = false; this.dragBottom = false; this.dragTop = false; target.elem.style.left = ''; blank.elem.style.left = ''; target.elem.style.top = ''; blank.elem.style.top = ''; } } //クリアしたかどうかを判定するメソッド private isClear():void { //upされる度にcountをリセット //countをそのままにしておくと全て揃わないうちにcountが8になりクリアとなってしまうため let count = 0; const piece = puzzle.piece; //ピースとその親要素のクラス名が一致していればインクリメント for(let i = 0; i < piece.elem.length - 1; i++){ if(piece.elem[i].className.match('num-'+(i + 1)) && piece.elem[i].parentNode.className.match('num-'+(i + 1))){ count++; } } //全て一致すればクリア if(count == piece.elem.length - 1){ //パズル終了 Puzzle.isPlaying = false; //アラートで表示 alert('Clear!'); //タイマー停止 timer.stop(); //カウントリセット count = 0; //シェアボタンとリプレイボタンを表示 tweetBtn.show(); replayBtn.show(); }else{ console.log('dame'); } } //ピースの移動範囲を制御するユーティリティメソッド private static clampPos(number, min, max): number { //numberをminからmaxまでの値で返す return Math.max(min, Math.min(number, max)); } private static clampNeg(number, max,min): number { //↑の逆 return Math.min(max,Math.max(number,min)); } } const pointer = new Pointer;

このクラスではピースをドラッグ&ドロップで移動できるようにしたり、クリア判定を行なっています。

ドラッグ&ドロップはdownメソッド・moveメソッド・upメソッドで行います。

downメソッドではピース.canvas-pieceを押下した座標dx(dy)や押したときの時刻startを取得します。 続けて、空白の情報blankと押下されたピース.canvas-pieceの情報targetを取得します。 またnoDragメソッドを実行して空白を押下した場合やピースを枠外へドラッグした場合はドラッグを中止します。

moveメソッドではdx(dy)からの指(カーソル)の移動量をmx(my)を取得します。そしてこの移動量をターゲットtarget.elemのleftプロパティの値にして指(カーソル)の動きに応じてターゲットを動かします。しかしこのままですとあらゆる方向へ移動できてしまうためisDirectionメソッドで上下左右隣りのどこかが空白である場合のみ移動を可能とします。また移動範囲はclampメソッドでターゲットの座標から空白の領域内までとします。

upメソッドではターゲットをドロップしたときにchangeメソッドで移動量からターゲットを移動先へ移動させるべきかを判断し、十分な移動量であればターゲットと空白の位置を入れ替え、そうでなければターゲットを元の位置に戻します。

更に、isFlickメソッドを実行してドロップの際にフリックが行われていたら移動をキャンセルします。現実世界の9パズルはピースを素早く動かす等乱暴に扱うと壊れてしまうためです。

最後にisClearメソッドを実行してピースの絵が揃う、つまり各ピースが初めに内包されてたコンテナ内に戻るとクリアとなります。この判定はコンテナとピースの.num-*クラスが一致したかどうかで判断します。

クリア後はタイマーを停止してシェアボタンとリプレイボタンを表示させます。

最後に

以上で、HTML+CSS+JavaScriptによる9パズルの作り方を終わります。今回のコードで特にJavaScriptは正しく動いてはいますが、まだまだ冗長な部分が多いため、その部分を少しずつ減らしていく予定です。

参考文献