四川省(二角取り)は麻雀牌を使ったパズルゲームです。同じ牌のペアを線でつなげた時に、線が曲がる回数が2回以内であればその牌を消すことができます。また同じ牌が隣接している場合も消せます。すべての牌を消すことができればゲームクリアです。慣れないとなかなか消えるペアを見つけることができません。

HTML部分

style.css

画像は素材サイトで麻雀牌の素材を探してそれを使います。そして萬子は11~19、筒子は21~29、索子は31~39、字牌は41~47の番号をつけて保存しておきます。

グローバル変数と定数

グローバル変数と定数を示します。

麻雀牌はcanvasに描画しますが、幅と高さがそれぞれCELL_WIDTH, CELL_HEIGHT のセルをつくり、そのなかに描画します。一番端のセルには牌は配置しません。二角取りできるかどうかは牌が置かれていないセルをとおって2回以内の方向転換で同じ牌とつながっているかどうかで判定します。

牌は全部で数牌が27種類、字牌が7種類で34種類あります。また同じ牌は4個あります。なので牌は最大で136個になります(春夏秋冬牌はなし)。なので(ROW_COUNT – 2 )×(const COL_COUNT – 2)は偶数であり136以下でなければなりません。以下のコードでは 8 × 17 でちょうど136になっています。

セルと牌の値は2次元配列に格納します。

app.js

Cellクラスの定義

Cellクラスを定義します。また上から◯行目、左か◯列目のセルの左上部分の座標を計算する関数も定義します。

ページが読み込まれたときの処理

ページが読み込まれたときは以下の処理がおこなわれます。

画像ファイルから牌のイメージを生成しimageMapに格納する。
各セルの位置の計算(createField関数 – 後述)
問題の生成(createProblem関数 – 後述)
ボタンクリックに反応するようにイベントリスナの追加
レンジスライダーで音量調節ができるようにする(initVolume関数 – 後述)
描画処理(draw関数 – 後述)

createField関数

createField関数はセルの位置の計算と生成されたCellオブジェクトを2次元配列に格納する処理、描画用のcanvasを要素に追加する処理をおこないます。

また見えないdiv要素を生成して牌が描画される位置に配置します。これで牌がクリックされたときにクリックを検知することができるようになります。div要素と描画用のcanvasは position : absolute にします。

問題の生成

問題を生成する処理を示します。

初回実行のときだけ2次元配列value2x2を生成する処理をおこないます。そのあと問題を生成します。

問題を生成するときは牌の番号は11~47(ただし1の位が0は欠番)なので牌の番号と生成された乱数をセットにしたものを生成し作業用の配列に格納します。配列のサイズが途中で(ROW_COUNT – 2) * (COL_COUNT – 2)に達したら処理を終了します。そのあと乱数でソートします。こうすることで牌をシャッフルすることができます。

牌をシャッフルしたらその値を2次元配列value2x2に格納します。このとき端の要素には値を格納しない(0のままにする)ようにします。これで0の部分のみを通ってつながっている同じ牌を探すパズルの完成です。

ただし解けないパズルであってはいけません。なので最後にtest関数(後述)で解けるかどうかの確認をしています。解けない問題を生成してしまった場合は再度問題生成の処理をやりなおします。

音量調整の処理

音量調整を可能にする処理を示します。これは毎度毎度のお決まりの処理です。

問題を解くことはできるのか?

問題を解くことはできるのかを調べる処理を解説する前に取れる牌を調べる処理の解説します。

取れる牌を調べる処理

取れる牌を調べるには開始点を決め、開始点から牌が存在しないセルだけをとおって同じ種類の牌にたどり着けるかを調べればよいです。そのために幅優先探索の処理をおこないます。

ただ2つの牌を結ぶ線の曲がる回数は2回以内でなければならないというもうひとつ条件があります。これはどうすればよいでしょうか?

線の曲がる回数は「2回以内」であることから多くの解法が存在するかもしれませんが、ここは拡張ダイクストラ法で考えます。Qiitaの四川省の判定アルゴリズムと実装例記事では、「一般的な経路探索アルゴリズムを使って、出てきた経路が条件を満たしているか判定するというやり方は牛刀割鶏に過ぎる」とありますが、こちらにはこちらの考えがあるので牛刀割鶏なやり方で臨むことにします(おいおい)。

通常の幅優先探索やダイクストラ法では暫定的に取得された平面上の最短距離を2次元配列上に保存するのですが、ここでは二次元配列を4つ用意して三次元配列とします。そして以下のように遷移できるようにします。

dp[z1][y][x] → dp[z2][y][x ± 1]
z1 == z2 ならコスト1で移動可能、そうでないなら移動コストは10000

dp[z1][y][x] → dp[z2][y ± 1][x]
z1 == z2 ならコスト1で移動可能、そうでないなら移動コストは10000

※ 移動先である value2x2[ny][nx] が 0 または開始点の牌と同じ番号でないときは移動不可

あとは開始点と同じ種類の牌があるセルのdp[0][y][x] ~ dp[3][y][x]の値のなかで最小のものを調べ、値が30000未満であるかを調べることで、牌がない部分のみを通って、しかも2つの牌を結ぶ線の曲がる回数は2回以内になっているかどうかを知ることができます。

もし条件を満たすセルがある場合は開始点からそのセルにたどり着く道筋も取得しオブジェクトに格納して返すようにします。

以上の説明をコードにすると以下のようになります。

問題を解くことはできるのか?

上記の乱数を用いて生成された問題を解くことはできるのかを検証する処理を示します。

実際に取ることができる牌を取り続けてすべて取りきることができるかを調べているだけです。このときvalue2x2の値を変更してはならないので値を別の2次元配列 copy2x2 にコピーしてシミュレートしています。

createCopy2x2 は 2次元配列 value2x2 のコピーをつくる関数です。getRestCount は残っている牌の数を求める関数です。

ゲーム開始の処理

ゲームを開始する処理は以下のとおりです。

選択されている牌や組み合わせの情報が存在するならすべてクリアする
問題の生成
スタートボタンの非表示と「探す」ボタンの表示
効果音の再生
ゲーム開始時刻の取得と保存、プレイ秒数のリセット
isPlayingフラグのセットとgiveupedフラグのクリア

クリック時の処理

セルがクリックされたときの処理を示します。

プレイ開始前や牌を取り除く処理がおこなわれているときのクリックは無視します。

セルがクリックされたときにまだ1つ目の牌が選択されていないのであれば、対応するセルを選択状態にします。すでに選択されているときは新たにクリックされたセルに存在する牌とセットにして取り除くことができるかを調べます。

取り除くことができるなら取り除く処理をおこないます。できない場合は不正な選択なので警告音を鳴らします。いずれの場合も2つめの牌が選択された段階で1つめに選択されていた牌の情報はクリアされます。

取り除くことができる牌のペアが成立したらすぐに取り除かずに両者を結ぶ線を描画してしばらくしてから取り除く処理をおこないます。

ペア成立時はこれによってすべての牌が取り除かれてしまったかもしれません。この場合はゲームクリアの処理をおこないます。

以下の処理は効果音を鳴らしているだけです。

手詰まりのチェック

牌を消去したときこれ以上牌を取り除くことができない、いわゆる手詰まりになっている可能性もあります。手詰まりのときはゲームオーバーの処理をおこないます。

詰まりになっているかどうかは以下の方法で確認できます。まず残されたすべての牌でsearch関数を実行します。もしすべて戻り値の配列の長さが0なら次の手は存在しない=手詰まりと判断することができます。

「探す」コマンド

自分で取ることができる牌を見つけることができない場合は「探す」ボタンをクリックすることで候補が示されます。その処理を示します。

これは最初に発見した取れる牌の組み合わせを表示しているだけです。なので最適解とは限りません。またこの機能を使用したらギブアップしたものとして処理をします。

描画処理

描画処理を示します。

初期化の処理がおこなわれていない場合(value2x2の長さが0のとき)はなにもしません。

もし牌と牌をつなぐ道筋が存在する場合はそれを先に描画します。そのあと各セルがある位置に牌が存在する場合は対応するイメージを取得してそれを描画します。もし選択されている牌や提案されている牌がある場合はその位置に赤い枠も描画します。

またプレイ中でギブアップしていない場合は経過時間を取得(開始時刻と現在時刻の差から調べる)してこれを左上に表示します。ギブアップ時はその旨表示します。

これは牌と牌を結ぶ道筋を描画する処理です。