つばクロ
-TSUBASA CROSS-
プログラマな薬剤師の趣味を綴るブログ。
プログラマな薬剤師の趣味を綴るブログ。
どうも、つばさ♂です。
少し前の記事で JavaScript で作ってみたパズドラを紹介しましたが、今回はそのアルゴリズムについて少し書いてみたいと思います。
もちろん公式のパズドラとは仕様が違いますし、個人的に勝手に考えてみたアルゴリズムなので、実際のパズドラのパズルアルゴリズムとは全く無関係です。あくまでもパズドラっぽいパズルを実現するだけのアルゴリズムだと思ってください。
また、この企画自体が大学の講義の自由課題として取り扱ったものなので、その都合上、ここでは単なるパズルのアルゴリズムだけでなく HTML ファイルを作成するところから解説します。
まずは HTML ファイルを作成します。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> <title>JavaScript でパズドラ!</title> </head> <body> <!-- ここに本文を記述 --> <script> //ここに JavaScript を記述 </script> </body> </html>
本題とズレるのであまり詳しくは書きませんが、文字コードには気をつけてください。
Windows のメモ帳を利用する場合は 4 行目の
<meta charset="UTF-8">
の部分を
<meta charset="Shift_JIS">
と記述する必要があります。
また、5 行目の
<link rel="stylesheet" href="style.css">
で CSS ファイルを読み込んでいます。
HTML ファイルと同じディレクトリに以下のような CSS ファイルを作成しておきましょう。
@charset "UTF-8"; /* ここに CSS を記述 */
先ほどと同様に、文字コードが異なる場合はその指定に気をつけてください。
基本的に、CSS ファイル側で特に文字コードが指定されていない場合は HTML ファイルと同じ文字コードで CSS ファイルが読み込まれますので、文字コードは指定しなくても構いません。
HTML ファイルと CSS ファイルを作成したら、次はライブラリを読み込ませましょう。
今回使用するライブラリは以下の通りです。
(jQuery 本体のバージョンは WordPress に同梱されているもので動作しますが、一応開発環境では jQuery 1.7.2 を使用したのでバージョン 1.7.2 と表記しています。)
jQuery UI Touch Punch は単に iOS デバイスなどでアクセスした際でも動作するようにしてくれるだけのライブラリです。
要するに jQuery UI の draggable をタッチスクリーンに対応させる、というものですね。特別なコードを記述せずとも、このライブラリを読み込ませるだけでタッチスクリーンに対応してくれるので便利です。
<script src="http://code.jquery.com/jquery-1.7.2.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.8.23/jquery-ui.min.js"></script> <script src="jquery.ui.touch-punch.min.js"></script>
上のコードを HTML ファイルの <head> </head> タグ内に記述します。
jQuery と jQuery UI は Google でホスティングされているのでそれを利用しています。
jQuery UI Touch Punch についてはホスティングされていないようなので、本家サイトからダウンロードして HTML ファイルと同じディレクトリにでも置いておきましょう。
ここまでで HTML ファイルの準備が整いました。
次は、パズルそのものをどのように実現するかを考えていきましょう。
まずは、その仕様をはっきりと理解しておく必要があります。作りたいものがはっきりわからない状態では上手く作れません。
以上が主な流れでしょうか。
最初に実装すべきは、6 色のドロップをランダムに配置する機能でしょう。この機能を持った関数を makeDrop 関数とします。
次に、ドロップをドラッグすることで様々な処理を行わせる必要があります。この機能は jQuery UI の draggable メソッドを利用することで実装できそうです。
さらに、ドロップの移動が終了した際にコンボの判定を行わなければなりません。この判定を行う関数を checkDrop 関数とします。
そして、checkDrop 関数によってコンボの判定が行われた後で、コンボが成立したドロップを削除し、その上にあるドロップを落としてやる必要があります。コンボが成立したドロップの削除は checkDrop 関数内で処理してやることにし、その上にあるドロップを落としてやる関数を fallDrop 関数とします。
fallDrop 関数によるドロップの落下処理が終了したら、空白となった箇所に新たなドロップを補充しますが、この機能は先ほどの makeDrop 関数を再利用することにします。
関数を整理してやると、
となります。
また、コンボ処理中に別のドロップを動かされては困るので、処理中は draggable メソッドをロックし、コンボの成立がなくなったところで draggable メソッドのロックを解除する必要があります。
以上を踏まえて、各関数をコーディングしていくことになりますが、実はまだ大きな問題があります。ドロップの落下には jQuery の animate メソッドを利用しますが、animate メソッドは非同期処理として実行されます。つまり、アニメーションの処理中にも関数の処理が先に進んでしまうということです。
この問題の解決には時間がかかりました。
1 番最初に思いついたのは animate メソッドの終了時に呼ばれる callback 関数を利用することでしたが、この方法では少なくとも 3 つ以上のドロップに対して animate メソッドを実行する、すなわち少なくとも 3 回は callback 関数が呼ばれてしまうことになります。全てのドロップのアニメーションが終了してから次の処理に移らなければなりません。また、そこそこ複雑なアニメーション処理を行うので、callback 関数を多用する必要が生じ、読みづらいソースコードとなってしまいます。
また、animate メソッドを使用せずに独自にアニメーション機能を実装することも考えましたが、JavaScript では sleep() のような関数が用意されていないので、やはり非同期処理となってしまいます。
そこでこの問題を解決するのが jQuery.Defferd というものでした。
jQuery.Defferd とは、非同期処理をうまく扱うための標準モジュールで、jQuery バージョン 1.5 以降で利用できます。これを利用することで、全てのドロップのアニメーションが完了してから次の処理に移ることができます。
jQuery.Defferd の全貌を理解するのは非常に難しいですが、ここではその簡潔な利用方法のみを述べますので安心してください。
さて、長くなりましたがパズルのおおまかな流れは以上となるので、次は実際のコーディングに取りかかりたいと思います。
まずはドロップをデザインしましょう。
ドロップそのものは ● という文字を使用することにし、この 1 文字を <span> タグで囲んで css を適用します。また、この <span> タグに drop クラスを付与します。
<span class="drop">●</span>
ドロップ自体の HTML は上のようになりますが、ここではまだ HTML を記述しません。
詳しくは次章で述べますが、スタートボタンを押した後でドロップが作成されるようにするので、ドロップの HTML は JavaScript によって動的に記述されます。
ドロップのサイズは JavaScript で定数として定義してやることにし、その他のプロパティを CSS で指定します。
span.drop { display: block; margin: 0; padding: 0; text-shadow: 0 0 2px #000; position: absolute; }
2 行目の
display: block;
は <span> タグをブロックレベル要素として扱うためのものです。あまり意味が分からない場合は「おまじない」だと思ってください。
重要なのは 6 行目の
position: absolute;
です。
ドロップは様々な位置へと自在に動かせる必要があるので、座標を指定して管理してやる必要があります。そのために position プロパティに absolute を指定しました。これにより、このドロップを絶対座標で配置してやることが可能です。
次に、各属性のドロップをデザインしましょう。
パズドラでは火・水・木・光・闇の 6 属性に加えて回復ドロップが存在します。この 6 つのドロップは以下のようにクラスを指定することで実装します。
さらに、それぞれのクラスに CSS で文字色を指定してやることで属性を分けることにします。
span.drop-heal { color: #F9F; } span.drop-flame { color: #F36; } span.drop-aqua { color: #3CF; } span.drop-leaf { color: #3C3; } span.drop-shine { color: #FF9; } span.drop-dark { color: #96C; }
ここまでを適用すると、以下のように各属性のドロップを実装することができます。
ドロップのデザインが終わったので、次はスタートボタンを作成してみたいと思います。
本家パズドラにはスタートボタンはありませんが、ユーザーが攻略するダンジョンを選択することでパズル画面に移行します。
ここではページの読み込みが完了した時点(DOM の構築が完了した後)で jQuery が実行されるようにコーディングしますが、ページが読み込まれてすぐにドロップが表示されているのはやはり違和感があります。
スタートボタンを用意して、ユーザーがスタートボタンを押すことでドロップが作成される方が、ゲームとして自然です。
さて、そのコードですが、まずはドロップを配置するボックスを作成し、その中にスタートボタンを配置したいと思います。
そこで、以下のような HTML を用意してみました。
<div id="puzzle-main"> <a id="btn-start" href="javascript: void(0);">スタート!</a> </div>
puzzle-main という ID を付与した div がドロップを配置するためのボックスですね。
この中にある、btn-start という ID を付与した a 要素がスタートボタンとなります。
a 要素の href 属性が見慣れない形式で記述されているかもしれません。これは、そのリンクをクリックした際に JavaScript で何かを実行するという意味の記述です。
このスタートボタンを押した際の処理は後で JavaScript で記述しますが、このリンク自体が有効になっていては困るので、リンク先を javascript: void(0); と指定することで、このリンクを無効化しています。
なお、<a> タグを使用せずに <span> タグで囲めばこのような処理は不要となります。
さて、前章:ドロップをデザインする で述べたように、各ドロップは絶対座標で配置されます。
HTML では基本的に、上下の関係を操作するようには作られていないので、ドロップが自由に上下左右するためには絶対座標で管理してやるのが最適、というわけです。
ただしページ内で自由自在に動き回られては困るので、puzzle-main という ID を付与した div の中で動くようにします。これを実現するためには、CSS で以下のように記述します。
div#puzzle-main { margin: 50px auto; border: 1px dashed #CCC; border-radius: 5px; position: relative; overflow: hidden; }
5 行目の
position: relative;
がポイントです。
position プロパティに absolute を指定した要素の座標は、position プロパティに relative が指定された親要素を基準とします。親要素に position: relative; が指定されているものがない場合は、document すなわちページそのものを基準とします。
つまり、ドロップの position プロパティには absolute を、そのボックスの position プロパティには relative を指定することで、ボックスの左上を原点とする相対座標でドロップの座標を指定してやることが出来る、というわけです。
また、6 行目の
overflow: hidden;
では、ボックスからはみ出したドロップの表示方法を指定しています。
「はみ出した」という状況がどのような状況かイマイチ分からないかもしれませんが、これは後で重要になってきます。
overflow プロパティに hidden を表示することで、はみ出したドロップを非表示にしました。
次は、スタートボタンの CSS を記述します。
#btn-start { display: block; width: 100px; height: 30px; color: #FFF; line-height: 30px; text-shadow: none; background: #CCC; border-radius: 5px; position: absolute; }
縦:30px、横:100px のサイズのボタンにしてみました。
また、スタートボタンの position プロパティもドロップ同様に absolute を指定しました。
スタートボタンの位置はボックスの中央に表示されるようにしたいので、ドロップのサイズによってサイズが変化するボックス同様、JavaScript で位置を指定してやります。
それでは、いよいよ JavaScript を記述してみましょう。
まず、以下のようにコードを記述します。
$( function () { //ここに処理を記述 });
jQuery を使用する際はお約束のコードですね。ページの読み込みが完了してから、すなわち DOM 構築完了後に、中に書かれた処理を実行します。
それでは、この中に実際のコードを記述していきます。
const size = 50; //ドロップのサイズ $('#puzzle-main').css('width', size * 6 + 'px'); $('#puzzle-main').css('height', size * 5 + 'px'); $('#btn-start').css('top', ((size * 5 - 30) / 2)+ 'px'); $('#btn-start').css('left', ((size * 6 - 100) / 2)+ 'px');
まず、1 行目の
const size = 50; //ドロップのサイズ
でドロップのサイズを指定しています。この値を変更することで容易にボックスやドロップのサイズと位置を指定できるようになっています。
また、ここで size を定義したことによって、今後は size と書くことでドロップのサイズを取得することができます。
2・3 行目の
$('#puzzle-main').css('width', size * 6 + 'px'); $('#puzzle-main').css('height', size * 5 + 'px');
では、div#puzzle-main すなわちボックスの縦と横のサイズを指定しています。ボックスには横に 6 個、縦に 5 個のドロップが表示されるのでした。ドロップのサイズにそれぞれ 6 と 5 をかけたサイズがボックスのサイズとなります。
4・5 行目の
$('#btn-start').css('top', ((size * 5 - 30) / 2)+ 'px'); $('#btn-start').css('left', ((size * 6 - 100) / 2)+ 'px');
では、スタートボタンがボックスの中央に配置されるようにしています。
(ボックスのサイズ)−(スタートボタンのサイズ)÷ 2 を計算すれば、スタートボタンの相対座標が算出されます。
ここまででボックスとスタートボタンの配置が完了しました。
この章では、いよいよスタートボタンを押した際にドロップが作成されるようにしていきたいと思います。
まずは、縦 5 個 × 横 6 個の合計 30 個のドロップをどのように管理するかを考えていきましょう。
30 個のドロップにはそれぞれ以下のように drop-id-n (n は 1 〜 30 までの整数) という ID を割り当てて管理することにします。
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
そして、ドロップを動かした際に入れ替わったドロップと ID を入れ替えることにします。
例えば、drop-id-16 のドロップを → に 2 マス動かした場合を考えてみましょう。
まず drop-id-16 と drop-id-17 の場所が入れ替わり、次に drop-id-16 と drop-id-18 の場所が入れ替わりますよね。
動かした後のドロップの配置は次のようになります。
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 17 | 18 | 16 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
しかしこれではドロップを動かす度に ID の配置が滅茶苦茶になってしまいますので、ドロップが入れ替わる度に場所だけでなく ID も入れ替えてやります。
すなわち、drop-id-16 のドロップを → に 2 マス動かした場合では、まず drop-id-16 と drop-id-17 の場所が入れ替わり、同時に drop-id-16 と drop-id-17 の ID も入れ替わります。次に drop-id-17 (元 drop-id-16) と drop-id-18 の場所が入れ替わり、同時に drop-id-17 と drop-id-18 の ID が入れ替わります。結果として、drop-id-16 のドロップは drop-id-18 になり、drop-id-17 のドロップは drop-id-16 になり、drop-id-18 のドロップは drop-id-17 になります。
こうすることで、ドロップを動かしても各 ID のドロップは必ず同じ位置に存在することになります。
さて、ドロップの位置は、ボックスの左上を原点とする座標系で表すことが出来ます。
例えば drop-id-25 のドロップの座標は (0, 0) ですよね。
ドロップのサイズを 50 と仮定すると、drop-id-26 の座標は (50, 0)となり、drop-id-19 の座標は (0, 50) となります。drop-id-5 の座標は (200, 200) となります。
ID と座標は関連づけられているので、ID から座標を取得することが出来れば便利そうですね。
ということで、ID から座標を取得する方法を考えてみましょう。
まずは X 座標を考えてみます。
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
drop-id-1, drop-id-7, drop-id-13, drop-id-19, drop-id-25 では X 座標が 0 になり、ドロップは横 6 個であることを考えると、ID から 1 を引いて 6 で割ってみると上手くいきそうですね。
X 座標が 0 でない場合はドロップのサイズも考慮する必要があります。
ドロップのサイズを 50 と仮定すると、drop-id-10 の X 座標は 50 × 3 = 150 となります。ID: 10 から 1 を引いて 6 で割った余りは (10 – 1) % 6 = 3 ですね。
すなわち、ID から 1 を引いて 6 で割った余りにドロップのサイズをかけてやることで X 座標が取得できるわけです。
ドロップのサイズは
const size = 50; //ドロップのサイズ
のように先ほど定義したので、size でドロップのサイズを取得することが出来ます。
したがって、ドロップの ID を id、X 座標を positionX として、実際にドロップの ID から X 座標を取得するコードは以下のようになります。
positionX = ((id - 1) % 6) * size;
次は Y 座標を考えてみましょう。
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
drop-id-25, drop-id-26, drop-id-27, drop-id-28, drop-id-29, drop-id-30 では Y 座標が 0 になり、ドロップは縦 5 個ですが、X 座標のように ID から 1 を引いて 5 で割っても上手くいきません。
多少複雑にはなりますが、Y 座標では 30 から ID を引いた数を 6 で割った商を用います。例えば drop-id-27 では、(30 – 27) / 6 = 0 … 3 ですから、確かに商である 0 が Y 座標となっています。
もちろん X 座標同様、Y 座標でもドロップのサイズを考慮する必要があるので、30 から ID を引いた数を 6 で割った商にドロップのサイズをかけてやった数が Y 座標となります。
ではこれを実際に検算してみましょう。ドロップのサイズを 50 と仮定すると、drop-id-10 の Y 座標は 150 となります。
(30 – 10) / 6 = 3 … 2 ですから、商である 3 に 50 をかけると確かに 150 となります。
したがって、ドロップの ID を id、Y 座標を positionY として、実際にドロップの ID から Y 座標を取得するコードは以下のようになります。
positionY = Math.floor((30 - id) / 6) * size;
少し見慣れない関数を含んでいますね。
JavaScript では商を求める際は多少面倒な手順を踏まなければなりません。
例えば 5 ÷ 2 = 2 … 1 について考えてみましょう。JavaScript で 5 / 2 を計算すると、答えは当然 2.5 となってしまいます。商を整数で得たい場合には、小数点以下を切り捨てることで商が求められるわけです。
小数点以下を切り捨てる際には Math.floor() を利用しますから、Math.floor(5 / 2) とすることで、商である 2 を算出することが出来ます。
Y 座標の計算に戻ると、30 から ID を引いた数を 6 で割った商は Math.floor((30 – id) / 6) で表されるわけですね。
以上のようにして、ID から X 座標と Y 座標を取得することが出来ました。
しかし座標を求める度に毎回上のようなコードを書くのは非常に面倒ですよね。
そこで、上の計算を関数化することで、今後のコーディングで余計な計算を記述せずにコーディングすることができます。
コードは以下のようになります。
function positionX (id) { //X 座標を取得 return (size * ((parseInt(id) - 1) % 6)); } function positionY (id) { //Y 座標を取得 return (Math.floor((30 - parseInt(id)) / 6) * size); } function getDropId (left, top) { //X 座標と Y 座標からドロップ ID を逆算 return (Math.floor( (parseInt(left) + parseInt(size / 2)) / size ) + 1 + Math.floor( (parseInt(size * 9 / 2) - parseInt(top)) / size) * 6); }
positionX 関数と positionY 関数は先ほどの計算を関数化したものです。
positionX(7) や positionY(7) と記述することで、drop-id-7 の X 座標や Y 座標が返ってきます。
ついでに getDropId 関数も実装してみました。詳細の説明は省きますが、X 座標と Y 座標からドロップの ID を逆算してくれる関数です。getDropId(0, 150) と記述することで、対応するドロップの ID である 7 が返ってきます。
それでは、実際にドロップを配置してみましょう。
アルゴリズム概要 で述べたように、ドロップの作成は makeDrop 関数にて処理するのでした。
makeDrop 関数のコードは次のようになります。
function makeDrop (init) { //ドロップ作成 if (!$('#drop-id-1').is('*')) { //ドロップが存在しない場合はドロップを作成 for (var i = 1; i <= 30; i++) { $('#puzzle-main').append("<span class='drop' id='drop-id-" + i + "'>●</span>"); $('#drop-id-' + i).css('left', positionX(i) + 'px'); } $('.drop').css('width', size + 'px'); $('.drop').css('height', size + 'px'); $('.drop').css('font-size', size * 0.8 + 'px'); $('.drop').css('line-height', size + 'px'); } for (var i = 1; i <= 30; i++) { //ドロップにランダムで属性を付与 switch (Math.round (Math.random() * 5)) { case 0: $('#drop-id-' + i).addClass('drop-heal'); break; case 1: $('#drop-id-' + i).addClass('drop-flame'); break; case 2: $('#drop-id-' + i).addClass('drop-aqua'); break; case 3: $('#drop-id-' + i).addClass('drop-leaf'); break; case 4: $('#drop-id-' + i).addClass('drop-shine'); break; case 5: $('#drop-id-' + i).addClass('drop-dark'); break; default: break; } //座標をセット $('#drop-id-' + i).css('top', (positionY(i) - size * 5) + 'px'); //アニメーション $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow'); } }
詳しく見ていきましょう。
2 行目 〜 11 行目を見てください。
if (!$('#drop-id-1').is('*')) { //ドロップが存在しない場合はドロップを作成 for (var i = 1; i <= 30; i++) { $('#puzzle-main').append("<span class='drop' id='drop-id-" + i + "'>●</span>"); $('#drop-id-' + i).css('left', positionX(i) + 'px'); } $('.drop').css('width', size + 'px'); $('.drop').css('height', size + 'px'); $('.drop').css('font-size', size * 0.8 + 'px'); $('.drop').css('line-height', size + 'px'); }
まず if 文の条件では、drop-id-1 のドロップがあるかどうかを判断し、存在しなければドロップを作成しています。 ドロップをデザインする でドロップの HTML を記述しなかったのは、ここで動的に記述させるためでした。
<span class="drop" id="drop-id-1">●</span>
上のように、ドロップ ID を 1 〜 30 まで付与して記述してくれます。
ドロップには ID とクラスを設定したので、特定のドロップを参照したい場合は ID を用いて、全てのドロップをまとめて操作したい場合はクラスを用いて処理することができます。
ID を指定する場合には ID 名の前に # を、クラスを指定する場合にはクラス名の前に . をつけて記述します。
5 行目では、実際に ID を指定してドロップに X 座標をセットしています。
$('#drop-id-' + i).css('left', positionX(i) + 'px');
$(‘#drop-id-’ + i) という表現に戸惑うかもしれませんが、これは for 文の中での処理なので、i には 1 〜 30 までの整数が順に代入されます。
結果として $(‘#drop-id-1′), $(‘#drop-id-2′), $(‘#drop-id-3′), ……, $(‘#drop-id-30′) と順に繰り返しているに過ぎません。
for 文の後では $(‘.drop’) とクラスを指定することで、全てのドロップに対してまとめて CSS を適用しています。
ドロップのサイズは size 定数にて定義されているので、各プロパティに対して size を使用して値を適用しているわけですね。各プロパティの詳細は以下の通りです。
13 〜 34 行目では、作成したドロップ 1 つ 1 つに対してランダムで属性を付与しています。
switch (Math.round (Math.random() * 5)) { case 0: $('#drop-id-' + i).addClass('drop-heal'); break; case 1: $('#drop-id-' + i).addClass('drop-flame'); break; case 2: $('#drop-id-' + i).addClass('drop-aqua'); break; case 3: $('#drop-id-' + i).addClass('drop-leaf'); break; case 4: $('#drop-id-' + i).addClass('drop-shine'); break; case 5: $('#drop-id-' + i).addClass('drop-dark'); break; default: break; }
13 行目の Math.round (Math.random() * 5) では 0 〜 5 までのランダムな整数が返ってきますので、結果を switch 文で参照して対応する属性が付与されるわけですね。
0 であれば回復が、1 であれば火属性が、2 であれば水属性が、3 であれば木属性が、4 であれば光属性が、5 ではれば闇属性が付与されます。
35・36 行目では、Y 座標をボックス 1 つ分上にセットしています。
//座標をセット $('#drop-id-' + i).css('top', (positionY(i) - size * 5) + 'px');
ボックスにはドロップが縦 5 個 × 横 6 個のサイズなので、size に 5 をかけることでボックスの縦幅が算出できます。
なぜボックス 1 つ分上にセットするのかと言うと、本家パズドラでは初回のドロップ作成時には上からドロップが降ってくるからですね。各ドロップをボックス 1 つ分上にセットして、各ドロップの本来の座標へとアニメーションさせてやることで、この表現を擬似的に実現できます。 スタートボタンを作成する の章で、ボックスの overflow プロパティに hidden を指定しましたが、それがここで活きてきます。ボックス 1 つ分上に作成されたドロップはボックスの中にアニメーションするまで見えません。
37・38 行目ではボックス 1 つ分上にセットされた各ドロップをアニメーションさせています。
//アニメーション $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow');
animate というのがアニメーションのためのメソッドですね。
第一引数にはアニメーション先を配列で指定し、第二引数には速度を指定します。
なお、アニメーションは非同期で実行されます。
あとはスタートボタンを押した際に makeDrop 関数が呼ばれるようにコードを記述しましょう。
//初期化 $('#btn-start').on('click', function () { $('#puzzle-main').empty(); makeDrop(true); });
スタートボタンをクリックした際の処理ですね。
この処理が事実上の初期化処理となります。変数などを初期化する場合はここに記述してあげましょう。
スタートボタンを押すと、まず div#puzzle-main の中身を削除します。div#puzzle-main とは、ドロップが配置されるボックスです。
3 行目を見てください。
$('#puzzle-main').empty();
empty メソッドは対象の中身を空にするメソッドです。スタートボタンそのものが div#puzzle-main の中にあるので、ドロップを作成する際の邪魔になってしまいます。そこで、empty メソッドでスタートボタンを削除してやるというわけです。
div#puzzle-main の中身を削除した後は、makeDrop 関数を実行しています。
makeDrop 関数に true を渡していますが、これは後ほど重要になってくる処理です。今はまだ気にしなくても構いません。
ここまでで、スタートボタンを押すことでドロップが作成され、ボックスの中に配置されるところまで実装できました。
なお、このままでは初回配置時に既にコンボが成立した状態で配置されてしまう可能性があります。本家パズドラではコンボが成立した状態で初回のドロップが配置されることはないので、初回のドロップ配置時にコンボが成立しないようにしなければなりません。この処理については後で述べます。
次はドロップを動かす処理を考えてみたいと思います。
ドロップそのものをドラッグで動かせるようにするには、jQuery UI の draggable メソッドを使用します。draggable メソッドの仕様についてはこちらのページがわかりやすいです。
さて draggable メソッドをどこに記述するかですが、前章:ランダムでドロップを配置する の最後で述べたように、スタートボタンを押した際の処理が事実上の初期化処理となります。スタートボタンを押した後すなわちパズル開始後の処理における、main 関数のようなものだと判断していいでしょう。というわけで、スタートボタンを押した際の処理を以下のように書き換えてみました。
//初期化 $('#btn-start').on('click', function () { $('#puzzle-main').disableSelection().empty(); makeDrop(true); $('.drop').draggable( //ここに draggable の処理を記述 ); });
3 行目にはボックス内の要素を選択できなくする処理を加えています。
$('#puzzle-main').disableSelection().empty();
disableSelection メソッドは指定された要素内の要素を選択できないようにします。ドロップをドラッグする際に、ドロップそのものが要素として選択されてしまうことを回避することが出来ます。
5 行目からが draggable メソッドですね。
$('.drop').draggable( //ここに draggable の処理を記述 );
draggable メソッドでは、ドラッグ中の設定やドラッグ開始時・ドラッグ中・ドラッグ完了後のイベント処理などを詳細に設定することが出来ます。
ドラッグ開始時・ドラッグ中・ドラッグ完了後のイベント処理では、event と ui の 2 つの引数をとる callback 関数を記述します。ここでは ui.position.left でドラッグ中のドロップの X 座標を、ui.position.top でドラッグ中のドロップの Y 座標を取得することができるので、これらを利用してドロップを入れ替えたりすることが出来そうです。
そして実際の処理を記述したのが以下のコードとなります。
//初期化 $('#btn-start').on('click', function () { $('#puzzle-main').disableSelection().empty(); makeDrop(true); var dropId, currentDropId; $('.drop').draggable({ opacity: 0.5, //ドラッグ中のドロップの透明度を 50% に設定する containment: '#puzzle-main', //div#puzzle-main の中のみでドラッグ可能にする start: function (event, ui) { //ドラッグ開始時の処理 dropId = getDropId(ui.position.left, ui.position.top); }, drag: function (event, ui) { //ドラッグ中の処理 currentDropId = getDropId(ui.position.left, ui.position.top); if (dropId != currentDropId) { $('#drop-id-' + currentDropId).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px' }, 'fast'); $('#drop-id-' + currentDropId).removeAttr('id').attr('id', 'drop-id-' + dropId); $(this).removeAttr('id').attr('id', 'drop-id-' + currentDropId); } dropId = currentDropId; }, stop: function (event, ui) { //ドラッグ終了後の処理 $('.drop').draggable('disable'); $(this).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px'}, 'fast').promise().done(function () { checkDrop (false); }); } }); });
何やら急にコードの量が増えましたね。
コードを詳細に解説する前に、ドラッグ中のイベントについて、何を処理させるべきか考えておきましょう。
まず、ドロップをドラッグすると、ドラッグした先のドロップと随時入れ替わっていきますよね。
これを実現するためには、ドラッグ中のドロップの位置を取得して、その位置に対応するドロップ ID を取得します。そして取得したドロップ ID がそのドロップの以前のドロップ ID と異なっていれば、取得したドロップ ID を持つドロップを以前のドロップ ID の位置に動かします。
ちょっとわかりにくいですね。では drop-id-10 のドロップを → に 2 マス分ドラッグする際の処理を例に、もう少し具体的に説明してみましょう。
drop-id-10 のドロップをドラッグ開始した際に、まず ID: 10 を「以前のドロップ ID」として記憶します。
そして、ドラッグ中は常に drop-id-10 のドロップの座標を取得し、その座標に対応するドロップ ID を「現在のドロップ ID」とします。
「現在のドロップ ID」が「以前のドロップ ID」と異なる場合、「現在のドロップ ID」を持つドロップを「以前のドロップ ID」の位置に移動させます。また、前章:ランダムでドロップを配置する で述べたように、この時「現在のドロップ ID」を持つドロップの ID とドラッグ中のドロップの ID を入れ替えます。すなわち drop-id-10 のドロップが drop-id-11 の位置までドラッグされた時、「現在のドロップ ID」は 11 となり、「以前のドロップ ID」は 10 となりますので、drop-id-11 のドロップを drop-id-10 の位置に移動させ、drop-id-11 のドロップを drop-id-10 とし、ドラッグ中の drop-id-10 のドロップを drop-id-11 とするわけです。
入れ替えが完了したら、「以前のドロップ ID」に「現在のドロップ ID」を代入して、再びドラッグ中のドロップの座標に対応する ID を「現在のドロップ ID」として取得します。
そしてドラッグ中の drop-id-11 のドロップ(元 drop-id-10 のドロップ)が drop-id-12 の位置にドラッグされた時、「現在のドロップ ID」は 12 となり、「以前のドロップ ID」は 11 となりますので、drop-id-12 のドロップを drop-id-11 の位置に移動させ、drop-id-12 のドロップを drop-id-11 とし、ドラッグ中の drop-id-11 のドロップ(元 drop-id-10 のドロップ)を drop-id-12 とします。
ここでドラッグ中のドロップはドラッグを終了します。
ドラッグ終了時は、ドラッグしたドロップが正しい位置にありません。ドラッグ終了時のそのままの位置で止まってしまいます。
そこで、ドラッグ完了時の座標を取得して、その座標に対応するドロップ ID の正しい座標にドラッグしたドロップを移動させてやるとドロップがきれいに並びます。
では実際のコードを解説したいと思います。
5 行目では変数を 2 つ用意しています。
var dropId, currentDropId;
この dropId 変数は「以前のドロップ ID」を記憶するための変数で、currentDropId 変数は「現在のドロップ ID」を記憶するための変数となります。
そして draggable の処理ですが、まずドラッグ開始時にそのドロップの ID を「以前のドロップ ID」として記憶するのでした。
start: function (event, ui) { //ドラッグ開始時の処理 dropId = getDropId(ui.position.left, ui.position.top); },
そしてドラッグ中の処理は 12 〜 20 行目ですね。
drag: function (event, ui) { //ドラッグ中の処理 currentDropId = getDropId(ui.position.left, ui.position.top); if (dropId != currentDropId) { $('#drop-id-' + currentDropId).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px' }, 'fast'); $('#drop-id-' + currentDropId).removeAttr('id').attr('id', 'drop-id-' + dropId); $(this).removeAttr('id').attr('id', 'drop-id-' + currentDropId); } dropId = currentDropId; },
難しそうに見えますが、やっていることは先ほどの説明そのままです。
currentDropId = getDropId(ui.position.left, ui.position.top);
このようにまず現在のドロップ ID を取得し、
if (dropId != currentDropId) {
現在のドロップ ID と以前のドロップ ID、すなわち currentDropId と dropId の値が異なっていれば、
$('#drop-id-' + currentDropId).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px' }, 'fast');
のようにして「現在のドロップ ID」を持つドロップを「以前のドロップ ID」の位置に animate メソッドを用いて移動させます。
$('#drop-id-' + currentDropId).removeAttr('id').attr('id', 'drop-id-' + dropId); $(this).removeAttr('id').attr('id', 'drop-id-' + currentDropId);
移動させたら「現在のドロップ ID」を持つドロップの ID とドラッグ中のドロップの ID を入れ替えます。
dropId = currentDropId;
入れ替えが完了したら「以前のドロップ ID」に「現在のドロップ ID」を代入するのでした。
ドラッグ中は常にこの処理が行われますので、ドラッグしたドロップは常に以前の ID の位置に入れ替わっていきます。
そして、ドラッグが完了した際の処理が 21 〜 24 行目ですね。
stop: function (event, ui) { //ドラッグ終了後の処理 $('.drop').draggable('disable'); $(this).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px'}, 'fast').promise().done(function () { checkDrop (false); }); }
23 行目の
$(this).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px'}, 'fast').promise().done(function () { checkDrop (false); });
でドラッグしたドロップを正しい座標に移動させています。
ドラッグが完了したらコンボが成立していないか判定しなければなりません。また、仮にコンボが成立していた場合はコンボが成立したドロップを削除し、新たなドロップを作成する必要があります。そのための処理が 22・23 行目で、
$('.drop').draggable('disable'); //一時的にドロップをドラッグ不可にしておく $(this).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px'}, 'fast').promise().done(function () { checkDrop (false); });
コンボ成立時のアニメーション中にドロップを操作されては困るので、まず判定前に一度 draggable を無効化しておきます。
draggable メソッドは disable を渡すことで無効化でき、enable を渡すと有効化されます。この後ドロップ判定のために checkDrop 関数を呼び出しますが、checkDrop 関数でコンボがなければ draggable メソッドに enable を渡して有効化することにします。
draggable を無効化した状態でアニメーションを実行し、アニメーションが完了したら、コンボの判定を行う関数である checkDrop 関数を呼び出しています。
さて、ここで普通に「アニメーションが終了したら」と書きましたが、ここで使用されているのが アルゴリズム概要 で述べた jQuery.Defferd というものです。
animate メソッドの後にメソッドチェーンで promise().done(function () { checkDrop (false); }); と記述していますが、これが jQuery.Defferd のメソッドですね。
jQuery.Defferd は非常に難解なので、ここでは詳しく解説しません。(私も理解するまでに少し時間がかかりました。)
jQuery.Defferd について詳しく知りたい方はこちらのサイト様での解説がわかりやすいかと思います。
ここでは、animate メソッドのような非同期処理の後で promise().done() と記述することで、非同期処理が完了してから done() 内に記述した関数を実行することが出来るということだけ知っていてください。
それでは、次の章でコンボの判定を行う checkDrop 関数を記述していきましょう。
この章では、コンボを判定する checkDrop 関数と、コンボ成立によって削除されたドロップの上にあるドロップを落とす fallDrop 関数を書いていきます。
まずは checkDrop 関数ですが、コンボをどのようにして判定するでしょうか。
おそらく、コンボ判定のアルゴリズムが最も重い処理となることは確かです。すなわち、ゲームの処理を高速化しようと思ったらこのアルゴリズムを見直し、より効率的で高速に動作するコードを書けばいいわけですね。
残念ながら、今回はあまり効率的とは言えないアルゴリズムを使用します。
とりあえず動くことを目標としているので、そこは目を瞑っていただければ幸いです。
(単にもっと効率的なアルゴリズムを思いつかなかっただけです……。)
さて肝心のアルゴリズムですが、各ドロップの属性はクラスによって定義されているのでした。
したがってクラスを調べ、同じクラスを持つドロップが 3 つ以上隣接して並んでいればコンボ成立となります。
しかし厄介なのはこの「同じクラスを持つドロップが 3 つ以上隣接して並んでいれば」という条件で、例えば次のような場合も 1 つのコンボとしてみなされます。
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
幸いにも今回はコンボ数をカウントしない仕様となっているので、上のような場合でもコンボ数を無視して簡潔にコンボを判定することが可能です。
すなわち、全ドロップに対して右 2 マスのドロップが同じ属性かどうかを調べ、さらに全ドロップに対して上 2 マスのドロップが同じ属性かどうかを調べます。
こうすることで、やや強引ではありますが、とりあえずコンボが成立しているかどうかだけを判定することが出来ます。
実際には、右 2 マスまたは上 2 マスにドロップが存在しない場合もあるので、
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
上の図の範囲のドロップに対して右 2 マスを調べ、
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
さらに上の図の範囲のドロップに対して上 2 マスを調べればいいということになります。
このようにしてドロップの判定を行う際、コンボが成立していればそれら 3 つのドロップに checked クラスを付与することにします。
ドロップ判定が終わった後、コンボが成立しているドロップが存在すれば、すなわち checked クラスを持つドロップが存在すれば、checked クラスを持つドロップを削除し、fallDrop 関数を呼び出します。
存在しなければ、draggable に enable を渡してドロップの移動を有効化して checkDrop 関数を終了します。
以上を踏まえた上で、checkDrop 関数の実際のコードは次のようになります。
function checkDrop (init) { //コンボ判定 var attr =""; for (var i = 1; i <= 28; i++) { //現在のドロップの属性を取得 if ($('#drop-id-' + i).hasClass('drop-heal')) attr = "drop-heal"; if ($('#drop-id-' + i).hasClass('drop-flame')) attr = "drop-flame"; if ($('#drop-id-' + i).hasClass('drop-aqua')) attr = "drop-aqua"; if ($('#drop-id-' + i).hasClass('drop-leaf')) attr = "drop-leaf"; if ($('#drop-id-' + i).hasClass('drop-shine')) attr = "drop-shine"; if ($('#drop-id-' + i).hasClass('drop-dark')) attr = "drop-dark"; //右 2 マスのドロップを参照し、2 つとも属性が同じであれば checked クラスを付与 if ( !(i % 6 == 0 || i % 6 == 5) && $('#drop-id-' + (i + 1)).hasClass(attr) && $('#drop-id-' + (i + 2)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 1)).addClass('checked'); $('#drop-id-' + (i + 2)).addClass('checked'); } //上 2 マスのドロップを参照し、2 つとも属性が同じであれば checked クラスを付与 if (i <= 18 && $('#drop-id-' + (i + 6)).hasClass(attr) && $('#drop-id-' + (i + 12)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 6)).addClass('checked'); $('#drop-id-' + (i + 12)).addClass('checked'); } } if ( $('.checked').is('*') ) { //コンボが成立していた場合 $('.checked').fadeTo('fast', 0).promise().done( function () { $('.checked').css('top', '-' + size + 'px'); fallDrop(init); }); } else { //コンボが成立していなかった場合 $('.drop').draggable('enable'); } }
ではコードを見ていきましょう。
4 〜 10 行目では、for 文で処理している最中の「現在のドロップ」の属性を取得しています。
//現在のドロップの属性を取得 if ($('#drop-id-' + i).hasClass('drop-heal')) attr = "drop-heal"; if ($('#drop-id-' + i).hasClass('drop-flame')) attr = "drop-flame"; if ($('#drop-id-' + i).hasClass('drop-aqua')) attr = "drop-aqua"; if ($('#drop-id-' + i).hasClass('drop-leaf')) attr = "drop-leaf"; if ($('#drop-id-' + i).hasClass('drop-shine')) attr = "drop-shine"; if ($('#drop-id-' + i).hasClass('drop-dark')) attr = "drop-dark";
属性はクラスによって定義されているので、現在のドロップがどのクラスを持っているかを調べてやればいいわけですね。
hasClass メソッドは指定したクラスを持つかどうかを調べ、持っていれば true を、持っていなければ false を返します。回復・火・水・木・光・闇の 6 つの属性全てを調べ、true が返って来たら attr 変数にその属性のクラス名を代入しています。
次に、12 〜 17 行目で右 2 マスのドロップの属性を調べています。
//右 2 マスのドロップを参照し、2 つとも属性が同じであれば checked クラスを付与 if ( !(i % 6 == 0 || i % 6 == 5) && $('#drop-id-' + (i + 1)).hasClass(attr) && $('#drop-id-' + (i + 2)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 1)).addClass('checked'); $('#drop-id-' + (i + 2)).addClass('checked'); }
if 分の条件が何やら複雑になっていますが、「現在のドロップが右端 2 マスに位置しておらず、かつ 1 つ右隣のドロップが attr 属性を持ち、かつ 2 つ右隣のドロップが attr 属性を持つ」という条件を調べています。
JavaScript では、複数の条件を扱う際は && や || を用います。&& は「かつ」を表し、|| は「または」を表します。
そして全ての条件を満たしていれば、現在のドロップとその 1 つ右隣のドロップと 2 つ右隣のドロップの合計 3 つのドロップに checked クラスを付与します。
19 〜 24 行目では上 2 マスのドロップの属性を調べています。
//上 2 マスのドロップを参照し、2 つとも属性が同じであれば checked クラスを付与 if (i <= 18 && $('#drop-id-' + (i + 6)).hasClass(attr) && $('#drop-id-' + (i + 12)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 6)).addClass('checked'); $('#drop-id-' + (i + 12)).addClass('checked'); }
右 2 マスの処理と同様、条件を満たしていれば 3 つのドロップに checked クラスを付与します。
if 文の条件は「現在のドロップが上端 2 マスに位置しておらず、かつ 1 つ上のドロップが attr 属性を持ち、かつ 2 つ上のドロップが attr 属性を持つ」となっています。
以上を for 文でループさせ、全ドロップに対して上の処理を実行すると、コンボの成立している全ドロップに checked クラスが付与されていることになります。
そこで、26 〜 30 行目でコンボ成立時の処理を、31 〜 33 行目でコンボが存在しなかった場合の処理を記述しています。
まず 26 〜 30 行目のコンボ成立時の処理ですが、
if ( $('.checked').is('*') ) { //コンボが成立していた場合 $('.checked').fadeTo('fast', 0).promise().done( function () { $('.checked').css('top', '-' + size + 'px'); fallDrop(init); });
if 文の条件は「checked クラスを持つドロップが存在していれば」となっています。
checked クラスを持つドロップが存在していた場合、すなわちコンボが成立していた場合は、コンボが成立したドロップを削除します。
しかし実際に削除するわけではなく、ユーザーの目に見えなくするだけで大丈夫です。つまり、checked クラスを持つドロップ全ての透明度を 0 にしてあげればいいわけですね。
27 行目で使用されている fadeTo メソッドは透明度(opacity)をアニメーション的に変更するためのメソッドです。animate メソッドで opacity をアニメーションさせても同じ結果を得ることが出来ますが、単に透明度のみを変更する場合は専用のメソッドである fadeTo メソッドを利用しましょう。
fadeTo メソッドも非同期で処理されるので、jQuery.Defferd を用いてアニメーション終了後の処理を記述しています。
$('.checked').css('top', '-' + size + 'px'); fallDrop(init);
上はアニメーション終了後に実行される処理ですが、アニメーションが完了したら checked クラスを持つドロップをボックスの外に出し、fallDrop 関数を呼び出しています。
なぜボックスの外に出したのかというと、このドロップを再利用するためです。
コンボが成立して消えたドロップの上にあるドロップが落ちて来た後、空白になった箇所に新たなドロップが落ちてきますよね。当然、この新たなドロップの個数はコンボが成立して消えたドロップの個数と同じですから、再利用が可能です。
再利用の処理は次の章で述べることにして、コンボが成立していなかった場合の処理です。
} else { //コンボが成立していなかった場合 $('.drop').draggable('enable'); }
非常に簡単な処理ですね。
draggable に enable を渡しているだけです。
コンボ判定前に draggable を無効化したので、コンボが成立していなければ draggable を有効化してから checkDrop 関数を抜ける必要があるのでした。
それでは、次は fallDrop 関数を実装していきましょう。
fallDrop 関数では、コンボ成立によって削除されたドロップの上にあるドロップを落とす処理を行います。
さてこの「コンボ成立によって削除されたドロップの上にあるドロップを落とす」処理ですが、実は結構複雑な処理です。
実際、このアルゴリズムを考えるのに少し手間取りました。
先にアルゴリズムを解説しましょう。
まず下から順にドロップを参照してコンボに含まれているかどうかを調べます。(コンボが成立したドロップには checked クラスが付与されているので、コンボに含まれているかどうかを調べるには checked クラスを持つかどうかを見ればいいのでした。)
そしてコンボに含まれているドロップが見つかった場合、その上にあるドロップを、そのコンボに含まれているドロップの位置に移動させ、ドロップ ID を入れ替えます。ただし上にあるドロップもまたコンボに含まれているドロップであった場合は、さらにその上にあるドロップを参照し、そのドロップもまたコンボに含まれているドロップであった場合はさらにそのまた上のドロップを参照……という具合に、なかなか面倒な処理を行う必要があります。
以上のアルゴリズムを実装したコードは以下のようになります。
function fallDrop (init) { //ドロップ落下 for (var i = 1; i <= 6; i++) { //縦 5 マスを 1 列とした 6 列をループ(ループA) for (var j = 0; j < 5; j++) { //列ごとに、最下段のドロップから順に上に向かってループ(ループB) if ( $('#drop-id-' + (i + j * 6)).hasClass('checked') ) { //処理中のドロップがコンボに含まれている場合 for (var k = 1; $('#drop-id-' + (i + j * 6 + k * 6)).is('*'); k++) { //処理中のドロップの上のドロップを 1 つずつループ(ループC) if ( $('#drop-id-' + (i + j * 6 + k * 6)).hasClass('checked') ) continue; //コンボに含まれていないドロップが見つかるまでループCを回す //コンボに含まれていないドロップが見つかったらそのドロップに change クラスを付与 $('#drop-id-' + (i + j * 6 + k * 6)).addClass('change'); //change クラスを付与したドロップを処理中のドロップの位置へ移動 if (init) { $('#drop-id-' + (i + j * 6 + k * 6)).css('top', (positionY(i + j * 6) - size * 5) + 'px'); } else { $('#drop-id-' + (i + j * 6 + k * 6)).animate({top: positionY(i + j * 6) + 'px'}, 'normal'); } //ドロップ IDの入れ替え $('#drop-id-' + (i + j * 6)).removeAttr('id').attr('id', 'drop-id-' + (i + j * 6 + k * 6)); $('.change').removeAttr('id').attr('id', 'drop-id-' + (i + j * 6)).removeClass('change'); break; //ループCを抜ける } } } } //アニメーションが完了したら、新たなドロップを作成 $('.drop').promise().done(function () { makeDrop(init); }); }
おそらく、このパズルの中で最も難解なのがこのコードです。
for 文を多用する必要があったため、インデントが多くなってしまいました。
しかし、アルゴリズムさえ理解していればこのコードはそう難しくはありません。コード中に書き込まれたコメントを読めばほとんどわかるかと思います。
ループ中でループを使用し、全部で 3 つのループを使用しています。
まずループAとループBで全ドロップを参照しています。
今回は縦の列の中での入れ替えを行うので、以下の順にドロップを参照すればいいわけですね。
4 | 8 | 12 | 16 | 20 | 24 |
3 | 7 | 11 | 15 | 19 | 23 |
2 | 6 | 10 | 14 | 18 | 22 |
1 | 5 | 9 | 13 | 17 | 21 |
「処理中のドロップがコンボに含まれていれば、その上のドロップを処理中のドロップの位置に移動させる(落とす)」処理なので、最上段のドロップは参照する必要がありません。(最上段のドロップではその上にドロップが存在しないため。)
このような順にドロップを参照するには、以下のようにループAとループBを組み合わせて用いるわけです。
for (var i = 1; i <= 6; i++) { //縦 5 マスを 1 列とした 6 列をループ(ループA) for (var j = 0; j < 5; j++) { //列ごとに、最下段のドロップから順に上に向かってループ(ループB)
処理中のドロップの ID は i + j * 6 で表されます。
例えば i = 3, j = 3 の時、処理中のドロップ ID は 3 + 3 * 6 = 21 となりますね。
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
ドロップ ID の表を見ると、i = 3, j = 3 すなわち左から 3 列目の下から 4 番目のドロップの ID は確かに 21 となります。
( j は 0 から始まるので、j = 3 は下から 4 番目を参照します。)
このようにループAとループBを組み合わせてドロップを次々に参照し、そのドロップがコンボに含まれていた場合はループCを用いてその上にあるドロップを参照していきます。
for (var k = 1; $('#drop-id-' + (i + j * 6 + k * 6)).is('*'); k++) { //処理中のドロップの上のドロップを 1 つずつループ(ループC) if ( $('#drop-id-' + (i + j * 6 + k * 6)).hasClass('checked') ) continue; //コンボに含まれていないドロップが見つかるまでループCを回す
ループCで処理しているドロップの ID は i + j * 6 + k * 6 で表されます。
以上のようにして、コンボに含まれているドロップ(ドロップ ID:i + j * 6)と、その上にあるドロップでコンボに含まれていないドロップ(ドロップ ID:i + j * 6 + k * 6)を取得しているわけです。
ID さえ取得してしまえば、あとは animate メソッドによって「落ちる」アニメーションを実行し、ドロップ ID を入れ替えてやるだけですね。
その処理が 8 〜 22 行目となります。
//コンボに含まれていないドロップが見つかったらそのドロップに change クラスを付与 $('#drop-id-' + (i + j * 6 + k * 6)).addClass('change'); //change クラスを付与したドロップを処理中のドロップの位置へ移動 if (init) { $('#drop-id-' + (i + j * 6 + k * 6)).css('top', (positionY(i + j * 6) - size * 5) + 'px'); } else { $('#drop-id-' + (i + j * 6 + k * 6)).animate({top: positionY(i + j * 6) + 'px'}, 'normal'); } //ドロップ IDの入れ替え $('#drop-id-' + (i + j * 6)).removeAttr('id').attr('id', 'drop-id-' + (i + j * 6 + k * 6)); $('.change').removeAttr('id').attr('id', 'drop-id-' + (i + j * 6)).removeClass('change'); break; //ループCを抜ける
ドロップ ID を入れ替えた際に一時的に同じ ID を持つドロップが 2 つ存在してしまうため、ドロップ ID をセレクタとして指定しても上手く ID を入れ替えることが出来ません。その対策としてドロップ ID を入れ替える前に change クラスを付与し、同じドロップ ID が 2 つ存在している状態になったら change クラスをセレクタとして指定することでドロップ ID の入れ替えを完了させています。
(なお、draggable メソッド中でドロップのドラッグ時にもドロップ ID の入れ替えを行いましたが、draggable メソッド中では this でドラッグ中のドロップを捕捉できるので、このような処理は必要ありませんでした。)
おおまかな説明は以上です。
全てのアニメーションが終了したら、以下のように makeDrop 関数を呼び出しています。
//アニメーションが完了したら、新たなドロップを作成 $('.drop').promise().done(function () { makeDrop(init); });
makeDrop 関数は最初のドロップを作成するための関数でしたが、コンボ成立によって消えたドロップ補充にも再利用できると書きました。
実際に再利用するには makeDrop 関数をもう少し書き換えてやる必要がありますので、次の章では makeDrop 関数を再利用可能なように書き換えていきましょう。
前章:コンボを判定する では checkDrop 関数でコンボを判定し、コンボが成立していれば fallDrop 関数を呼び出すようにコーディングしました。そして、fallDrop 関数の処理の最後で makeDrop 関数を呼び出しました。
makeDrop 関数の機能は、ドロップが存在していなければドロップを作成し、作成したドロップにランダムな属性を与えてやるというものでしたね。
コンボによって消えたドロップの補充では、ドロップに新たにランダムな属性を与えてやればいいわけですから、makeDrop 関数を再利用することが出来るわけです。
そして makeDrop 関数を再利用するためには、再利用可能なように makeDrop 関数を書き換えなければなりません。
実際に makeDrop 関数を再利用可能なように書き換えたのが以下のコードです。
function makeDrop (init) { //ドロップ作成・補充 if (!$('#drop-id-1').is('*')) { //ドロップが存在しない場合はドロップを作成 for (var i = 1; i <= 30; i++) { $('#puzzle-main').append("<span class='drop checked' id='drop-id-" + i + "'>●</span>"); $('#drop-id-' + i).css('left', positionX(i) + 'px'); } $('.drop').css('width', size + 'px'); $('.drop').css('height', size + 'px'); $('.drop').css('font-size', size * 0.8 + 'px'); $('.drop').css('line-height', size + 'px'); } for (var i = 1; i <= 30; i++) { //ドロップにランダムで属性を付与 if ( !$('#drop-id-' + i).hasClass('checked') ) continue; $('#drop-id-' + i).removeClass('drop-heal').removeClass('drop-flame').removeClass('drop-aqua').removeClass('drop-leaf').removeClass('drop-shine').removeClass('drop-dark'); switch (Math.round (Math.random() * 5)) { case 0: $('#drop-id-' + i).addClass('drop-heal'); break; case 1: $('#drop-id-' + i).addClass('drop-flame'); break; case 2: $('#drop-id-' + i).addClass('drop-aqua'); break; case 3: $('#drop-id-' + i).addClass('drop-leaf'); break; case 4: $('#drop-id-' + i).addClass('drop-shine'); break; case 5: $('#drop-id-' + i).addClass('drop-dark'); break; default: break; } //座標をセット $('#drop-id-' + i).css('top', (positionY(i) - size * 5) + 'px').css('opacity', '1.0'); //初回のドロップ作成時でなければアニメーション if (!init) $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow'); } //アニメーション完了後、checkDrop 関数を呼び出す $('.drop').promise().done(function () { $(this).removeClass('checked'); checkDrop(init); }); }
ハイライトされているのが書き換えた箇所になります。
1 つずつ見ていきましょう。
if ( !$('#drop-id-' + i).hasClass('checked') ) continue;
まず 13 行目ですが、ドロップに属性を付与するために for 文で全ドロップを回しましたよね。
この 13 行目を加えることによって、ドロップが checked クラスを持っていた場合にのみ属性を付与するようにしています。
これに伴い、初回のドロップ作成時には checked クラスを付与した状態でドロップを作成する必要があります。
$('#puzzle-main').append("<span class='drop checked' id='drop-id-" + i + "'>●</span>");
このように 4 行目を書き換えることで、初回のドロップ作成時に checked クラスを付与した状態でドロップを作成するようにしました。
14 行目では、ドロップに付与された属性を削除しています。
$('#drop-id-' + i).removeClass('drop-heal').removeClass('drop-flame').removeClass('drop-aqua').removeClass('drop-leaf').removeClass('drop-shine').removeClass('drop-dark');
属性を付与する前に既に付与されている属性を解除しておかなければ、ドロップが複数の属性を持つことになってしまいますよね。
そして 39・40 行目です。
//初回のドロップ作成時でなければアニメーション if (!init) $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow');
ここで init とは何かという疑問が生じているかと思います。
この説明はこの後すぐ説明します。
43 〜 46 行目では、アニメーションが完了した後で全てのドロップの checked クラスを削除し、checkDrop 関数を呼び出しています。
//アニメーション完了後、checkDrop 関数を呼び出す $('.drop').promise().done(function () { $(this).removeClass('checked'); checkDrop(init); });
したがって、各関数の関係は次のようになっています。
また、初回ドロップ作成時には以下のようなプロセスを踏みます。
これにより、初回ドロップ作成時には必ずコンボが 1 つも成立していない状態でドロップを作成することが出来ます。
さて、初回ドロップ作成時なのかそうでないのかを各関数はどのように判断するのでしょうか。
その答えが init です。
各関数は全て引数をとり、引数は init 変数に格納されるようになっています。
function makeDrop (init) { //ドロップ作成・補充 }
function checkDrop (init) { //コンボ判定 }
function fallDrop (init) { //ドロップ落下 }
ここでもう一度スタートボタンを押した際の処理を見てみましょう。
//初期化 $('#btn-start').on('click', function () { $('#puzzle-main').disableSelection().empty(); makeDrop(true); //初回ドロップ作成時、makeDrop 関数に true を渡している var dropId, currentDropId; $('.drop').draggable({ opacity: 0.5, containment: '#puzzle-main', start: function (event, ui) { dropId = getDropId(ui.position.left, ui.position.top); }, drag: function (event, ui) { currentDropId = getDropId(ui.position.left, ui.position.top); if (dropId != currentDropId) { $('#drop-id-' + currentDropId).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px' }, 'fast'); $('#drop-id-' + currentDropId).removeAttr('id').attr('id', 'drop-id-' + dropId); $(this).removeAttr('id').attr('id', 'drop-id-' + currentDropId); } dropId = currentDropId; }, stop: function (event, ui) { $('.drop').draggable('disable'); $(this).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px'}, 'fast').promise().done(function () { checkDrop (false); //ドラッグ終了後、checkDrop 関数に false を渡している }); } }); });
初回ドロップ作成時には引数に true を、ドラッグ後は引数に false を指定しています。
また、各関数内においても別の関数を呼び出す際は引数に init を指定して呼び出していました。
以下は makeDrop 関数から checkDrop 関数を呼び出す処理ですね。
//アニメーション完了後、checkDrop 関数を呼び出す $('.drop').promise().done(function () { $(this).removeClass('checked'); checkDrop(init); });
init は引数を格納する変数ですから、この init を引数に指定して別の関数を呼び出すことで、常に初回ドロップ作成時なのかどうかを各関数は判断することができるのでした。
ここでいったん makeDrop 関数の説明に戻りましょう。
先ほど保留した 39・40 行目です。
//初回ドロップ作成時でなければアニメーション if (!init) $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow');
init の値を調べ、初回ドロップ作成時でなければアニメーションを実行しています。
初回ドロップ作成時には init に true が格納されているので、!init と記述することで初回ドロップ作成時でなければ true が返ってきます。
結果として、初回ドロップ作成時であれば属性を付与したドロップをボックス内へとアニメーションさせず、初回ドロップ作成時でなければ新たに属性を付与したドロップをボックス内へとアニメーションさせてやることが出来ます。
さて、ここでもう一度初回ドロップ作成時の処理手順を見返してみましょう。
最後に実行される関数は checkDrop 関数となっています。
直前に述べたように makeDrop 関数では、初回ドロップ作成時であれば属性を付与したドロップをボックス内へとアニメーションさせませんから、この checkDrop 関数でコンボが成立していないことを確認後にボックス内へとアニメーションさせてやらなければなりません。
そのためには checkDrop 関数の末尾に以下のコードを追加してやる必要があります。
function checkDrop (init) { //コンボ判定 var attr =""; for (var i = 1; i <= 28; i++) { if ($('#drop-id-' + i).hasClass('drop-heal')) attr = "drop-heal"; if ($('#drop-id-' + i).hasClass('drop-flame')) attr = "drop-flame"; if ($('#drop-id-' + i).hasClass('drop-aqua')) attr = "drop-aqua"; if ($('#drop-id-' + i).hasClass('drop-leaf')) attr = "drop-leaf"; if ($('#drop-id-' + i).hasClass('drop-shine')) attr = "drop-shine"; if ($('#drop-id-' + i).hasClass('drop-dark')) attr = "drop-dark"; if ( !(i % 6 == 0 || i % 6 == 5) && $('#drop-id-' + (i + 1)).hasClass(attr) && $('#drop-id-' + (i + 2)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 1)).addClass('checked'); $('#drop-id-' + (i + 2)).addClass('checked'); } if (i <= 18 && $('#drop-id-' + (i + 6)).hasClass(attr) && $('#drop-id-' + (i + 12)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 6)).addClass('checked'); $('#drop-id-' + (i + 12)).addClass('checked'); } } if ( $('.checked').is('*') ) { $('.checked').fadeTo('fast', 0).promise().done( function () { $('.checked').css('top', '-' + size + 'px'); fallDrop(init); }); } else { if (init) for (var i = 1; i <= 30; i++) $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow'); $('.drop').draggable('enable'); } }
コンボが成立していない場合かつ初回ドロップ作成時であればドロップをボックス内へとアニメーションさせる、というのがこの 1 行の処理内容ですね。
以上で、makeDrop 関数を再利用してコンボの判定をうまく処理することができるようになりました。
これで、全てのコードの記述が終了しました。
実際にコードを実行してみるとパズドラ風パズルが動作してくれると思います。
以上のすべてを反映させたソースコードは以下のようになります。
index.html
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> <script src="http://code.jquery.com/jquery-1.7.2.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.8.23/jquery-ui.min.js"></script> <script src="jquery.ui.touch-punch.min.js"></script> <title>JavaScript でパズドラ!</title> </head> <body> <h1>JavaScript でパズドラ!</h1> <div id="puzzle-main"> <a id="btn-start" href="javascript: void(0);">スタート!</a> </div> </body> </html>
style.css
@charset "UTF-8"; div#puzzle-main { margin: 30px auto; border: 1px dashed #CCC; border-radius: 5px; position: relative; overflow: hidden; } #btn-start { display: block; width: 100px; height: 30px; color: #FFF; line-height: 30px; text-shadow: none; background: #CCC; border-radius: 5px; position: absolute; } span.drop { display: block; margin: 0; padding: 0; text-shadow: 0 0 2px #000; position: absolute; } span.drop-heal { color: #F9F; } span.drop-flame { color: #F36; } span.drop-aqua { color: #3CF; } span.drop-leaf { color: #3C3; } span.drop-shine { color: #FF9; } span.drop-dark { color: #96C; }
JavaScript
jQuery( function ($) { const size = 50; //ドロップのサイズ $('#puzzle-main').css('width', size * 6 + 'px').css('height', size * 5 + 'px'); $('#btn-start').css('top', ((size * 5 - 30) / 2)+ 'px').css('left', ((size * 6 - 100) / 2)+ 'px'); function positionX (id) { return (size * ((parseInt(id) - 1) % 6)); } function positionY (id) { return (Math.floor((30 - parseInt(id)) / 6) * size); } function getDropId (left, top) { return (Math.floor( (parseInt(left) + parseInt(size / 2)) / size ) + 1 + Math.floor( (parseInt(size * 9 / 2) - parseInt(top)) / size) * 6); } function makeDrop (init) { //ドロップ作成・補充 if (!$('#drop-id-1').is('*')) { for (var i = 1; i <= 30; i++) { $('#puzzle-main').append("<span class='drop checked' id='drop-id-" + i + "'>●</span>"); $('#drop-id-' + i).css('left', positionX(i) + 'px'); } $('.drop').css('width', size + 'px').css('height', size + 'px').css('font-size', size * 0.8 + 'px').css('line-height', size + 'px'); } for (var i = 1; i <= 30; i++) { if ( !$('#drop-id-' + i).hasClass('checked') ) continue; $('#drop-id-' + i).removeClass('drop-heal').removeClass('drop-flame').removeClass('drop-aqua').removeClass('drop-leaf').removeClass('drop-shine').removeClass('drop-dark'); switch (Math.round (Math.random() * 5)) { case 0: $('#drop-id-' + i).addClass('drop-heal'); break; case 1: $('#drop-id-' + i).addClass('drop-flame'); break; case 2: $('#drop-id-' + i).addClass('drop-aqua'); break; case 3: $('#drop-id-' + i).addClass('drop-leaf'); break; case 4: $('#drop-id-' + i).addClass('drop-shine'); break; case 5: $('#drop-id-' + i).addClass('drop-dark'); break; default: break; } $('#drop-id-' + i).css('top', (positionY(i) - size * 5) + 'px').css('opacity', '1.0'); if (!init) $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow'); } $('.drop').promise().done(function () { $(this).removeClass('checked'); checkDrop(init); }); } function fallDrop (init) { //ドロップ落下 for (var i = 1; i <= 6; i++) { for (var j = 0; j < 5; j++) { if ( $('#drop-id-' + (i + j * 6)).hasClass('checked') ) { for (var k = 1; $('#drop-id-' + (i + j * 6 + k * 6)).is('*'); k++) { if ( $('#drop-id-' + (i + j * 6 + k * 6)).hasClass('checked') ) continue; $('#drop-id-' + (i + j * 6 + k * 6)).addClass('change'); if (init) { $('#drop-id-' + (i + j * 6 + k * 6)).css('top', (positionY(i + j * 6) - size * 5) + 'px'); } else { $('#drop-id-' + (i + j * 6 + k * 6)).animate({top: positionY(i + j * 6) + 'px'}, 'normal'); } $('#drop-id-' + (i + j * 6)).removeAttr('id').attr('id', 'drop-id-' + (i + j * 6 + k * 6)); $('.change').removeAttr('id').attr('id', 'drop-id-' + (i + j * 6)).removeClass('change'); break; } } } } $('.drop').promise().done(function () { makeDrop(init); }); } function checkDrop (init) { //コンボ判定 var attr =""; for (var i = 1; i <= 28; i++) { if ($('#drop-id-' + i).hasClass('drop-heal')) attr = "drop-heal"; if ($('#drop-id-' + i).hasClass('drop-flame')) attr = "drop-flame"; if ($('#drop-id-' + i).hasClass('drop-aqua')) attr = "drop-aqua"; if ($('#drop-id-' + i).hasClass('drop-leaf')) attr = "drop-leaf"; if ($('#drop-id-' + i).hasClass('drop-shine')) attr = "drop-shine"; if ($('#drop-id-' + i).hasClass('drop-dark')) attr = "drop-dark"; if ( !(i % 6 == 0 || i % 6 == 5) && $('#drop-id-' + (i + 1)).hasClass(attr) && $('#drop-id-' + (i + 2)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 1)).addClass('checked'); $('#drop-id-' + (i + 2)).addClass('checked'); } if (i <= 18 && $('#drop-id-' + (i + 6)).hasClass(attr) && $('#drop-id-' + (i + 12)).hasClass(attr)) { $('#drop-id-' + i).addClass('checked'); $('#drop-id-' + (i + 6)).addClass('checked'); $('#drop-id-' + (i + 12)).addClass('checked'); } } if ( $('.checked').is('*') ) { $('.checked').fadeTo('fast', 0).promise().done( function () { $('.checked').css('top', '-' + size + 'px'); fallDrop(init); }); } else { if (init) for (var i = 1; i <= 30; i++) $('#drop-id-' + i).animate({top: positionY(i) + 'px'}, 'slow'); $('.drop').draggable('enable'); } } //初期化 $('#btn-start').on('click', function () { $('#puzzle-main').disableSelection().empty(); makeDrop(true); var dropId, currentDropId; $('.drop').draggable({ opacity: 0.5, containment: '#puzzle-main', start: function (event, ui) { dropId = getDropId(ui.position.left, ui.position.top); }, drag: function (event, ui) { currentDropId = getDropId(ui.position.left, ui.position.top); if (dropId != currentDropId) { $('#drop-id-' + currentDropId).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px' }, 'fast'); $('#drop-id-' + currentDropId).removeAttr('id').attr('id', 'drop-id-' + dropId); $(this).removeAttr('id').attr('id', 'drop-id-' + currentDropId); } dropId = currentDropId; }, stop: function (event, ui) { $('.drop').draggable('disable'); $(this).animate({ left: positionX(dropId) + 'px', top: positionY(dropId) + 'px'}, 'fast').promise().done(function () { checkDrop (false); }); } }); }); });
なお、このパズルをプレイしてみるとわかりますが、本家パズドラと比べて若干コンボがつながりにくいように感じます。
本家パズドラではおそらく、新たなドロップが作成される際に属性を完全にランダムで付与するわけではなさそうです。
新たなドロップが落ちる地点でコンボが成立しやすいように多少は属性が操作されているかと。
参考までに、コンボ数をカウントしたい場合の処理について、案を出しておきましょう。
25 | 26 | 27 | 28 | 29 | 30 |
19 | 20 | 21 | 22 | 23 | 24 |
13 | 14 | 15 | 16 | 17 | 18 |
7 | 8 | 9 | 10 | 11 | 12 |
1 | 2 | 3 | 4 | 5 | 6 |
例えば drop-id-20, drop-id-21, drop-id-22 の 3 つのドロップがコンボ成立していた場合、上の図のようにその隣接するドロップを参照し、「同じ属性を持ち、かつ checked クラスを持つドロップ」を探します。そしてそのようなドロップが見つからなければコンボ数を +1 し、見つかればコンボ数には変化を加えません。こうすることで、コンボ数をカウントさせることが可能となります。
非常に長くなってしまいましたが、以上で(やっと)全ての説明は終わりです。
(章ごとに別々の記事にすれば良かったと、今更ながらに反省しているところです><)
ではまた。