1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptだけで動く麻雀ゲーム!手牌評価アルゴリズムと初心者アシスト機能

1
Posted at

1. はじめに

ブラウザ上で動作する麻雀ゲームをJavaScriptのみで実装しました。サーバーサイドの通信や外部麻雀ライブラリを一切使わず、クライアントサイドだけでレスポンス良く高速に動作する対局・役判定エンジンを構築しています。

4面子1雀頭のパターン全探索と受け入れ枚数に基づく手牌評価AIアルゴリズム、厳密なアガリ・役判定ロジック、そして初心者をサポートする星表示アシスト・リアルタイムヒント機能の実装について解説します。

ninin-mahjong.png


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)

アガリ判定は二段階で行われます。

  1. 形の判定(checkWin): 4面子1雀頭、七対子(チートイツ)、国士無双のいずれかの形が成立しているかを判定。
  2. 役の判定(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で開発する際の参考にしてみてください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?