四川省と上海を作ったので麻雀ネタで聴牌の待ちを調べる方法を考えます。今回の記事は麻雀のルールを知らない方には完全に意味不明なものとなります。
Contents
聴牌であるかどうかを調べる
一筒から九筒まで4枚ずつ合計36枚の牌から13枚の牌を選びます。これが聴牌であればこれを問題として出題します。聴牌でなければもう一度選び直す処理を繰り返します。
では聴牌であるかどうかはどうやれば判別できるでしょうか? 13枚の牌に一筒から九筒までの牌を1枚足して14枚にしてこれが和了形かどうかを調べます。もし和了形であれば3枚の牌は聴牌であり、追加した牌が待ち牌のひとつであったことになります。
和了形であるかどうかを調べる
では14枚の牌が和了形かどうかはどうやって調べればよいでしょうか? いろいろなやり方があると思うのですが、ここでは以下の方法で判定します。
七対子については別に考えます。
2枚以上同じ種類の牌があればそれは雀頭である可能性があります。そこで雀頭の候補を14枚の牌のなかから取り除きます。和了形であれば、残された12枚のなかには同じ種類の3つの牌で構成される刻子と連続する3つの牌で構成される順子があわせて4組存在するはずです。ただしどれが刻子で順子なのかはわかりません。
そこで残された12枚のなかに3枚以上同じ種類の牌があればそれは刻子かもしれないので、それらをチェックします。刻子の候補を取り除く場合と取り除かない場合で残された牌から順子を取り除き、すべての牌を取り除けるかを考えます。刻子の候補の数は0以上4以下なのでビット全探索をすればすぐに終わりそうです。
また順子を取り除く処理は、一番小さな数の牌とその次の牌とさらにその次の牌を取り除くという操作を繰り返すだけです。
七対子であるかどうかは異なる7つの対子が存在するかどうかを調べるだけです。
HTML部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <title>鳩でもわかる清一色の待ち</title> <link rel="stylesheet" href="./style.css"> </head> <body> <div id = "container"> <div id = "canvas-outer"> <button id = "start">開始</button> <button id = "answer">答えを表示</button> <button id = "next">次</button> </div> </div> <script src="./app.js"></script> </body> </html> |
style.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
body { background-color: #000; } #container { width: 360px; margin: 0 auto 0 auto; } #canvas-outer { position: relative; } #start, #answer, #next { position: absolute; width: 200px; height: 60px; top: 400px; left: 80px; font-size: large; z-index: 1; } #answer, #next { display: none; } |
グローバル変数と定数
グローバル変数と定数を示します。
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
const $canvasOuter = document.getElementById('canvas-outer'); const $canvas = document.createElement('canvas'); const ctx = $canvas.getContext('2d'); const $images = []; const TILE_WIDTH = 44; // 牌の表示サイズ const TILE_HEIGHT = 60; const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 480; let problem = []; // 聴牌になっている13枚の牌を表す整数 let ans = []; // 処理の結果判明した待ち牌を格納する配列 let showAnswer = false; // true なら解を表示する const $start = document.getElementById('start'); const $next = document.getElementById('next'); const $answer = document.getElementById('answer'); |
ページが読み込まれたときの処理
ページが読み込まれたら以下の処理をおこないます。
画像ファイルを読み込んで牌の描画につかうイメージを取得する
ボタンがクリックされたときのイベントリスナの追加
問題の生成
描画処理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
window.onload = () => { $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; $canvas.style.position = 'absolute'; $canvas.style.left = '0px'; $canvas.style.top = '0px'; $canvasOuter?.appendChild($canvas); ctx.fillStyle = '#000'; ctx?.fillRect(0, 0, $canvas.width, $canvas.height); for(let i = 1; i <= 9; i++){ const image = new Image(); image.src = `./images/${i}.png`; $images.push(image); } $start?.addEventListener('click', () => { createProblem(); $start.style.display = 'none'; $answer.style.display = 'block'; }); $answer?.addEventListener('click', () => { showAnswer = true; $answer.style.display = 'none'; $next.style.display = 'block'; }); $next?.addEventListener('click', () => { showAnswer = false; createProblem(); $next.style.display = 'none'; $answer.style.display = 'block'; }); createProblem(); setInterval(() => { draw(); }, 100); } |
描画処理
描画に関する処理を示します。配列 problemに格納されている整数から聴牌の牌を描画します。showAnswerフラグがtrueであれば配列ansに格納されている解も描画します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function draw(){ ctx.fillStyle = '#000'; ctx?.fillRect(0, 0, $canvas.width, $canvas.height); ctx.fillStyle = '#f0f'; ctx.textBaseline = 'top'; ctx.font = '24px Arial'; const text = '清一色聴牌 待ちは何?'; const x = (CANVAS_WIDTH - ctx.measureText(text).width) / 2; ctx.fillText(text, x, 20); for(let i = 0; i <= 6; i++) ctx.drawImage($images[problem[i] - 1], TILE_WIDTH * i + 20, 80, TILE_WIDTH, TILE_HEIGHT); for(let i = 7; i < 13; i++) ctx.drawImage($images[problem[i] - 1], TILE_WIDTH * (i - 6) + 2, 150, TILE_WIDTH, TILE_HEIGHT); if(showAnswer){ ctx.fillStyle = '#f0f'; ctx.textBaseline = 'top'; ctx.font = '24px Arial'; const text2 = '答え'; const x = (CANVAS_WIDTH - ctx.measureText(text2).width) / 2; ctx?.fillText(text2, x, 270); for(let i = 0; i < ans.length; i++){ const x = (CANVAS_WIDTH - TILE_WIDTH * ans.length) / 2; ctx.drawImage($images[ans[i] - 1], TILE_WIDTH * i + x, 310 , TILE_WIDTH, TILE_HEIGHT); } } } |
問題の生成
問題の生成を生成する処理を示します。9種類 × 4枚の牌のなかから13枚をランダムに選んで聴牌かどうかを調べます。後述するsolve関数を実行して聴牌なら同時に解も得られるのでこれを出題します。そうでない場合はもう一度選び直します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function createProblem(){ while(true){ // 牌と乱数のセットを配列に格納して乱数でソート。 // この状態で頭から13個とればランダムに13個選ぶことができる const arr = []; for(let num = 1; num <= 9; num++){ for(let i = 0; i < 4; i++) arr.push({value: num, random: Math.random()}); } arr.sort((a, b) => a.random - b.random); problem = []; for(let i = 0; i < 13; i++) problem.push(arr[i].value); ans = solve(problem); // 聴牌かどうか調べる if(ans.length > 0){ problem.sort((a, b) => a - b); break; } } } |
聴牌かどうか調べる
聴牌かどうか調べる処理を示します。
配列 counts を定義してどの数牌が何枚あるかわかるようにしておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
function solve(problem){ // どの数牌が何枚あるか数える const counts = [0,0,0,0,0,0,0,0,0,0]; problem.forEach(_ => { counts[_]++; }); // すでに4枚存在する牌があるか確認する const set4 = new Set(); for (let num = 1; num <= 9; num++){ if(counts[num] == 4) set4.add(num); } // すでに4枚存在する牌以外を1枚追加して和了形か調べる const set = new Set(); for (let num = 1; num <= 9; num++){ if (set4.has(num)) // すでに4枚存在する牌はスキップ continue; // numを追加してheadが雀頭と仮説を立てる for (let head = 1; head <= 9; head++) { const copiedCounts = []; for(let i=0; i<counts.length; i++) copiedCounts.push(counts[i]); copiedCounts[num]++; // 1枚追加 copiedCounts[head] -= 2; // 雀頭を抜く if(copiedCounts[head] < 0) continue; if (check(copiedCounts)) // 刻子と順子をすべて取り除けるか調べる(後述) set.add(num); } } // 七対子の確認 let pairCount = 0; let alone = 0; for (let num = 1; num <= 9; num++){ if(counts[num] == 2) pairCount++; if(counts[num] == 1) alone = num; } if(pairCount == 6) set.add(alone); // 解がsetに格納されているのでソートしたものを返す const ans = []; for(let v of set) ans.push(v); ans.sort((a, b) => a - b); return ans; } |
刻子と順子をすべて取り除けるか調べる処理を示します。どれが刻子なのかはわからないので3枚以上揃っている牌は刻子の候補とみなしてそれぞれが刻子の場合、そうでない場合を全探索します(ビット全探索)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
function check(counts){ const arr = []; for(let i=1; i<=9; i++){ if(counts[i] >= 3) arr.push(i); } // ビット全探索 for (let bit = 0; bit < 1 << arr.length; bit++){ let ng = false; const copiedCounts = []; for(let i = 0; i < counts.length; i++) copiedCounts.push(counts[i]); // 刻子の候補を取り除く for (let i = 0; i < arr.length; i++){ if (((bit >> i) & 1) == 1) { copiedCounts[arr[i]] -= 3; if(copiedCounts[arr[i]] < 0) console.log('おかしい'); } } // 残りは順子なのですべて取り除けるか調べる while (true){ // min, min + 1, min + 2 を取り除けるか? let min = 10000; for (let i = 1; i <= 9; i++){ if(copiedCounts[i] > 0){ min = i; break; } } if (min == 10000) // すべて取り除くことができた break; if (min + 2 >= 10){ ng = true; break; } copiedCounts[min]--; copiedCounts[min + 1]--; copiedCounts[min + 2]--; if (copiedCounts[min] < 0 || copiedCounts[min + 1] < 0 || copiedCounts[min + 2] < 0){ ng = true; // 順子を取り除けない break; } } if (!ng) return true; } return false; } |