1. はじめに
ブラウザ上で動作する麻雀ゲームをJavaScriptのみで実装しました。サーバーサイドの通信や外部麻雀ライブラリを一切使わず、クライアントサイドだけでレスポンス良く高速に動作する対局・役判定エンジンを構築しています。
4面子1雀頭のパターン全探索と受け入れ枚数に基づく手牌評価AIアルゴリズム、厳密なアガリ・役判定ロジック、そして初心者をサポートする星表示アシスト・リアルタイムヒント機能の実装について解説します。
2. 全体アーキテクチャと対局フロー
ゲーム全体は DOMContentLoaded 内でカプセル化されており、状態管理・レンダリング・思考エンジンが連携して動作します。
3. 麻雀思考AIと手牌評価アルゴリズム
① 4面子1雀頭の全探索と評価関数(evaluateHand)
麻雀のAI思考の肝となるのは、「どの牌を捨てると最も手牌が強くなるか(受け入れ枚数・向聴数が向上するか)」を数値化することです。
evaluateHand では、手牌から雀頭(ペア)の候補を抽出し、残りの牌から刻子(同じ牌3枚)や順子(連続する数字3枚)を再帰的に全探索(バックトラッキング)します。
function evaluateHand(hand) {
const tileValues = hand.map(t => (t.suit === 'm' ? 0 : t.suit === 'p' ? 10 : t.suit === 's' ? 20 : 30) + t.num).sort((a, b) => a - b);
const visibleCounts = getVisibleTilesCount(hand);
// 山札や河に見えていない残り牌数を取得
function getRem(v) {
if (v < 0 || (v % 10 === 0 && v < 30) || (v % 10 > 9 && v < 30)) return 0;
return Math.max(0, 4 - (visibleCounts[v] || 0));
}
let maxScore = 0;
const uniqueTiles = [...new Set(tileValues)];
const pairs = uniqueTiles.filter(v => tileValues.filter(t => t === v).length >= 2);
const scenarios = pairs.length > 0 ? pairs : [null];
scenarios.forEach(pairVal => {
const tilesToProcess = [...tileValues];
let currentBonus = 0;
if (pairVal !== null) {
tilesToProcess.splice(tilesToProcess.indexOf(pairVal), 1);
tilesToProcess.splice(tilesToProcess.indexOf(pairVal), 1);
currentBonus = 50; // 雀頭加点
}
function search(tiles, sets, leftovers) {
if (tiles.length === 0 || sets >= 4) {
let score = sets * 100 + currentBonus;
let expectation = 0;
const processed = new Set();
// 塔子(両面・カンチャン・ペンチャン)および対子の受け入れ期待値計算
for (let i = 0; i < leftovers.length; i++) {
if (processed.has(i)) continue;
const v1 = leftovers[i];
let bestWaitScore = 0;
let bestWaitIdx = -1;
for (let j = i + 1; j < leftovers.length; j++) {
if (processed.has(j)) continue;
const v2 = leftovers[j];
let currentWaitScore = 0;
if (v1 === v2) { // 対子(ポン受け)
currentWaitScore = getRem(v1) * 2.0;
} else if (Math.floor(v1/10) === Math.floor(v2/10) && Math.floor(v1/10) < 3) {
if (v2 - v1 === 1) { // 両面・ペンチャン待ち
const r1 = (v1 % 10 > 1) ? getRem(v1 - 1) : 0;
const r2 = (v2 % 10 < 9) ? getRem(v2 + 1) : 0;
currentWaitScore = (r1 + r2) * 1.5;
} else if (v2 - v1 === 2) { // カンチャン待ち
currentWaitScore = getRem(v1 + 1) * 1.2;
}
}
if (currentWaitScore > bestWaitScore) {
bestWaitScore = currentWaitScore;
bestWaitIdx = j;
}
}
if (bestWaitIdx !== -1) {
expectation += bestWaitScore;
processed.add(i);
processed.add(bestWaitIdx);
}
}
score += expectation;
if (score > maxScore) maxScore = score;
return;
}
// 刻子・順子の再帰探索
const first = tiles[0];
// 刻子
const sameIdx = [];
for (let i = 0; i < tiles.length; i++) if (tiles[i] === first) sameIdx.push(i);
if (sameIdx.length >= 3) {
const next = [...tiles];
next.splice(sameIdx[2], 1); next.splice(sameIdx[1], 1); next.splice(sameIdx[0], 1);
search(next, sets + 1, leftovers);
}
// 順子
if (Math.floor(first/10) < 3) {
const s2 = first + 1, s3 = first + 2;
const i2 = tiles.indexOf(s2), i3 = tiles.indexOf(s3);
if (i2 !== -1 && i3 !== -1 && Math.floor(s2/10) === Math.floor(first/10) && Math.floor(s3/10) === Math.floor(first/10)) {
const next = [...tiles];
next.splice(i3, 1); next.splice(i2, 1); next.splice(next.indexOf(first), 1);
search(next, sets + 1, leftovers);
}
}
// 使わない
search(tiles.slice(1), sets, [...leftovers, first]);
}
search(tilesToProcess, 0, []);
});
return maxScore;
}
② AIの打牌選択(selectOpponentDiscard)
AIは毎ターン、手牌の各牌を仮想的に1枚捨てた後の evaluateHand スコアを比較し、最も手牌の評価値(受け入れ期待値)が高くなる牌を選択して打牌します。
難易度(初級・中級・上級)に応じてノイズ(ランダムネス)を混入させることで、AIの強さを人間味のあるレベルに調整しています。
function selectHardDiscard(hand) {
let bestIndex = 0;
let bestScore = -1;
for (let i = 0; i < hand.length; i++) {
const tempHand = [...hand];
tempHand.splice(i, 1);
const score = evaluateHand(tempHand); // 1枚捨てた後の手牌評価
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
4. アガリ・役判定エンジン(checkWin / getYaku)
アガリ判定は二段階で行われます。
-
形の判定(
checkWin): 4面子1雀頭、七対子(チートイツ)、国士無双のいずれかの形が成立しているかを判定。 -
役の判定(
getYaku): 役満(大三元、字一色、国士無双、四暗刻)および一翻以上の役(リーチ、一発、ツモ、タンヤオ、役牌、ピンフ、イッツー、ホンイツ、チンイツ、トイトイ、チートイツ、ドラ)が存在するかを判定(一翻縛りの検証)。
function checkWin(hand, melds = [], isPlayer = true) {
const allTiles = [...hand, ...melds.flatMap(m => m.tiles)];
if (allTiles.length !== 14) return false;
const tileValues = handToValues(allTiles);
let hasCorrectShape = false;
// 1. 4面子1雀頭判定
const uniqueTiles = [...new Set(tileValues)];
for (let pairVal of uniqueTiles) {
if (tileValues.filter(v => v === pairVal).length >= 2) {
const remainingTiles = [...tileValues];
remainingTiles.splice(remainingTiles.indexOf(pairVal), 1);
remainingTiles.splice(remainingTiles.indexOf(pairVal), 1);
if (canFormSets(remainingTiles, 4)) {
hasCorrectShape = true;
break;
}
}
}
// 2. 七対子判定
if (!hasCorrectShape && melds.length === 0) {
const counts = {};
tileValues.forEach(v => counts[v] = (counts[v] || 0) + 1);
if (Object.values(counts).every(c => c === 2)) hasCorrectShape = true;
}
if (!hasCorrectShape) return false;
// 3. 役の判定(一翻縛りチェック)
const yaku = getYaku(allTiles, isPlayer);
return yaku.some(y => y.name !== 'ドラ' && y.name !== '役なし');
}
5. 初心者向けアシスト&UI/UX設計
麻雀のルールを知らない初心者でも直感的に楽しめるよう、以下のサポート機能を実装しています。
1. 手牌の「残すべき度」を星(★1〜★3)でビジュアル表示
getStarRatings 関数により、手牌各牌を捨てた時の評価値の下がり幅を計算し、不要な牌(★なし)と重要な牌(★3)を視覚的に識別できるようにUIレンダリングします。
function getStarRatings(hand) {
const scores = hand.map((_, i) => {
const tempHand = [...hand];
tempHand.splice(i, 1);
return evaluateHand(tempHand);
});
const maxScore = Math.max(...scores);
const minScore = Math.min(...scores);
const range = maxScore - minScore;
return scores.map((s, i) => {
const importance = (maxScore - s) / range;
if (importance > 0.85) return 3;
if (importance > 0.4) return 2;
if (importance > 0.1) return 1;
return 0;
});
}
2. リアルタイム・ヒントアドバイス(updateHint)
ツモ・ロン・リーチ・役(ホンイツ、タンヤオ、トイトイ、チートイツなど)の狙い目をリアルタイムにテキストでガイドします。
6. まとめ
外部ライブラリを一切使わず、JavaScriptのみで完結する本格的な二人麻雀エンジンを構築しました。
- 再帰的面子分解 + 有効牌受け入れ確率 による高度な手牌評価AI
- 厳密なアガリ形・役判定(一翻縛り) の実装
- AI評価値を応用した初心者向け星表示アシスト
複雑なドメインロジックを持つボードゲームやWebゲームをJavaScriptで開発する際の参考にしてみてください。
