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-container
はpadding-top
とwidth
の値を等しくします。また.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()
メソッドが実行され、パズルがスタートするようにしています。
インスタンス化の際は引数isShow
にtrue
を代入します。
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
メソッドが実行され、ツイート画面が開きタイムをシェアできるようにしています。
シェアボタンはパズルをクリアしたときに表示させたいので、インスタンス化の際はメンバ変数isShow
にfalse
を代入して初期状態では非表示にします。
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パズルが初期状態に戻るようにしています。
このボタンもシェアボタンと同じくクリア後に表示させたいのでインスタンス化の際はメンバ変数isShow
にfalse
を代入します。
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-piece
9個のコンテキスト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重ループによる座標の取得は以下の順で行われます。
- (0 * height, 0 * width)の座標→(0 * height, 1 * width)の座標→(0 * height, 2 * width)の座標
- (1 * height, 0 * width)の座標→(1 * height, 1 * width)の座標→(1 * height, 2 * width)の座標
- (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
メソッドでシャッフルをアニメーションで表現しています。
シャッフルアニメーションは以下のようにして行われています。
requestAnimationFrame
メソッドでshuffle
メソッドを繰り返し実行する。またアニメーションの停止に必要となるため、requestAnimationFrame
をrequestId
に代入しておく。Math.random
メソッドにピースの枚数pieceLen
を掛けて0~8までのうちのランダムな値r
を取得する。- ここからがwhile文によるシャッフルアルゴリズムである。
pieces
内の--pieceLen(8)番地つまり最後尾の要素をgetCurrentVal
として取得する。ここで--pieceLenでカウントダウンしたのでpieceLen
は8となる。 - 1.で取得したランダムな値rの番地にある
pieces
内の要素をpieces
内のpieceLen(8)番地に代入する。 - 3.の逆で
getCurrentVal
をpiece
内のr番地に代入する。 - 2.~4.を
pieceLen
が0(false)になるまで行う。ここまでがシャッフルアルゴリズムである。 - コンテナ内のピースをすべて削除する。
- シャッフルしたピースをコンテナに追加していく。これでコンテナ内のピースはシャッフル後の並びとなる。
count
をインクリメントする。count
がmax
(20)に達していたらisPlaying
をtrue
に切り替えてピースのドラッグ操作を許可し、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は正しく動いてはいますが、まだまだ冗長な部分が多いため、その部分を少しずつ減らしていく予定です。