Unityで作る本格3D麻雀ゲーム – 4段階のAI難易度実装まで

はじめに

麻雀ゲームアプリは数多く存在しますが、今回Unityを使ってiOS向けの3D麻雀ゲームを一から開発しました。

特にこだわったのはAIの難易度調整。初心者から上級者まで楽しめるよう、4段階の難易度(初心者・中級・上級・鬼畜)を実装しています。

この記事では、開発の過程で学んだことや技術的なポイントを紹介します。

プロジェクト概要

実装した機能

  • ✅ 日本式リーチ麻雀ルール完全対応
  • ✅ 東風戦・半荘戦
  • ✅ 全ての一般役 + 役満(国士無双、四暗刻、大三元など)
  • ✅ 赤ドラ(各色1枚)
  • ✅ ドラ・裏ドラ・槓ドラ
  • ✅ 鳴き機能(ポン・チー・カン)
  • ✅ リーチ・ダブルリーチ・一発
  • ✅ 流局判定(四風子連打、九種九牌、四開槓など)
  • ✅ 4段階のCPU AI
  • ✅ 戦績保存・設定保存

技術スタック

- Unity 2022.3 LTS
- C# (.NET Standard 2.1)
- iOS 13.0+
- TextMeshPro

アーキテクチャ設計

麻雀ゲームは複雑なロジックを持つため、以下のような階層構造で設計しました:

Assets/Scripts/
├── Core/              # 麻雀の基本ロジック
│   ├── Tile.cs              # 牌クラス
│   ├── Hand.cs              # 手牌管理
│   ├── WinningChecker.cs   # 和了判定
│   ├── Yaku.cs              # 役判定
│   └── ScoreCalculator.cs   # 点数計算
├── AI/                # CPU思考エンジン
│   └── AIPlayer.cs          # 4段階の難易度実装
├── Game/              # ゲーム進行管理
│   ├── GameManager.cs
│   └── GameController.cs
└── UI/                # ユーザーインターフェース
    ├── TileObject.cs        # 3D牌オブジェクト
    └── TableManager.cs      # 卓・牌配置

設計のポイント

1. 関心の分離(Separation of Concerns)

麻雀のロジック(Core)と表示(UI)、AI思考を完全に分離することで:
– 各モジュールの独立性が高い
– テストがしやすい
– 将来的な拡張(オンライン対戦など)が容易

2. イベント駆動アーキテクチャ

// ゲームの状態変化をイベントで通知
public event Action<Tile> OnTileDiscarded;
public event Action<Player> OnPlayerWin;
public event Action OnGameEnd;

これにより、UIとロジックの結合度を下げています。

技術的な実装ポイント

1. 和了判定アルゴリズム

麻雀の和了判定は、14枚の牌が「3枚組×4 + 雀頭2枚」の形になっているかを判定する必要があります。

public class WinningChecker
{
    // 和了判定のメインロジック
    public bool IsWinningHand(List<Tile> tiles)
    {
        // 1. 牌を種類ごとにソート
        var sorted = SortTiles(tiles);

        // 2. 雀頭候補を全て試す
        foreach (var pairCandidate in GetPairCandidates(sorted))
        {
            // 雀頭を除いた残りの牌
            var remaining = RemovePair(sorted, pairCandidate);

            // 3. 残り12枚が面子4組になるか再帰的に判定
            if (CanFormMelds(remaining, 0))
            {
                return true;  // 和了!
            }
        }

        return false;
    }

    // 再帰的に面子を判定
    private bool CanFormMelds(List<Tile> tiles, int index)
    {
        // ベースケース:全ての牌を使い切った
        if (index >= tiles.Count) return true;

        // 刻子(同じ牌3枚)を試す
        if (TryFormPung(tiles, index))
        {
            return CanFormMelds(RemovePung(tiles, index), index);
        }

        // 順子(連続する3枚)を試す
        if (TryFormChow(tiles, index))
        {
            return CanFormMelds(RemoveChow(tiles, index), index);
        }

        return false;
    }
}

2. 役判定システム

麻雀には30種類以上の役があり、それぞれ判定ロジックが異なります。

public class Yaku
{
    // 役の一覧
    public enum YakuType
    {
        // 1翻役
        Riichi,          // リーチ
        Tanyao,          // タンヤオ
        Pinfu,           // 平和
        Ippatsu,         // 一発

        // 2翻役
        Chiitoitsu,      // 七対子
        ToiToi,          // 対々和

        // 役満
        KokushiMusou,    // 国士無双
        Suuankou,        // 四暗刻
        Daisangen,       // 大三元
        // ... 他30種類以上
    }

    // 役判定のメインメソッド
    public List<YakuType> CalculateYaku(Hand hand, Tile winningTile)
    {
        var yakuList = new List<YakuType>();

        // 1. 役満の判定
        if (IsKokushiMusou(hand)) yakuList.Add(YakuType.KokushiMusou);
        if (IsSuuankou(hand)) yakuList.Add(YakuType.Suuankou);

        // 2. 一般役の判定(役満がない場合のみ)
        if (yakuList.Count == 0)
        {
            if (IsTanyao(hand)) yakuList.Add(YakuType.Tanyao);
            if (IsPinfu(hand)) yakuList.Add(YakuType.Pinfu);
            // ...
        }

        return yakuList;
    }

    // タンヤオ判定(2〜8の牌のみ)
    private bool IsTanyao(Hand hand)
    {
        return hand.AllTiles.All(tile =>
            tile.Number >= 2 && tile.Number <= 8 &&
            !tile.IsHonor  // 字牌を含まない
        );
    }
}

3. AIの難易度実装

4段階の難易度を実装したAIPlayerクラスが最も工夫したポイントです。

public class AIPlayer
{
    public enum Difficulty
    {
        Beginner,   // 初心者:ほぼランダム
        Medium,     // 中級:向聴数を考慮
        Advanced,   // 上級:効率と守備を考慮
        Expert      // 鬼畜:最適に近い判断
    }

    // 難易度に応じた打牌選択
    public Tile SelectDiscardTile(Hand hand, Difficulty difficulty)
    {
        switch (difficulty)
        {
            case Difficulty.Beginner:
                return SelectRandomTile(hand);

            case Difficulty.Medium:
                return SelectByShanten(hand);  // 向聴数最小

            case Difficulty.Advanced:
                return SelectByEfficiency(hand);  // 効率重視

            case Difficulty.Expert:
                return SelectOptimal(hand);  // 最適解
        }
    }

    // 向聴数計算(和了までの最小距離)
    private int CalculateShanten(Hand hand)
    {
        // 1. 通常手の向聴数
        int normalShanten = CalculateNormalShanten(hand);

        // 2. 七対子の向聴数
        int chitoisuShanten = CalculateChitoisuShanten(hand);

        // 3. 国士無双の向聴数
        int kokushiShanten = CalculateKokushiShanten(hand);

        // 最小値を返す
        return Math.Min(normalShanten,
               Math.Min(chitoisuShanten, kokushiShanten));
    }

    // 上級AIの思考:効率と守備のバランス
    private Tile SelectByEfficiency(Hand hand)
    {
        var candidates = new List<(Tile tile, float score)>();

        foreach (var tile in hand.Tiles)
        {
            // この牌を切った後の期待値を計算
            float score = 0;

            // 1. 攻撃面:向聴数の改善度
            score += CalculateShantenImprovement(hand, tile) * 10;

            // 2. 攻撃面:受け入れ枚数
            score += CalculateAcceptanceTiles(hand, tile) * 5;

            // 3. 守備面:危険牌度
            score -= CalculateDangerLevel(tile) * 8;

            candidates.Add((tile, score));
        }

        // スコアが最も高い牌を選択
        return candidates.OrderByDescending(c => c.score).First().tile;
    }
}

難易度ごとの特徴

難易度 思考内容 勝率
初心者 ランダム打牌、ほぼ鳴かない 15%
中級 向聴数最小の牌を切る 30%
上級 受け入れ枚数・守備を考慮 45%
鬼畜 期待値・放銃回避を完全計算 60%

3D表現とUIの工夫

1. 牌の3Dモデル

UnityのCubeを使って牌を表現:

public class TileObject : MonoBehaviour
{
    private Tile _data;  // 牌データ

    void Start()
    {
        // サイズ:実物の麻雀牌に近い比率
        transform.localScale = new Vector3(0.026f, 0.035f, 0.02f);

        // マテリアル適用
        ApplyTexture(_data.Type, _data.Number);
    }

    // タップ時のアニメーション
    public void OnTap()
    {
        // 0.2秒かけて少し浮き上がる
        LeanTween.moveY(gameObject,
                        transform.position.y + 0.01f,
                        0.2f)
                 .setEaseOutQuad();
    }
}

2. カメラアングル

// 斜め上から卓全体を見下ろす視点
Camera.main.transform.position = new Vector3(0, 3, -2);
Camera.main.transform.rotation = Quaternion.Euler(50, 0, 0);

3. アニメーション

牌の配牌・ツモ・捨て牌のアニメーションをLeanTweenで実装:

// ツモアニメーション
LeanTween.move(tileObject, playerHandPosition, 0.3f)
         .setEase(LeanTweenType.easeOutQuad)
         .setOnComplete(() => {
             SoundManager.Play("tsumo");
         });

iOSビルドの注意点

1. TextMeshPro の日本語フォント

標準フォントは日本語非対応のため、Noto Sans JPなどをインポート:

Window > TextMeshPro > Font Asset Creator
Font Source: NotoSansJP-Regular.ttf
Atlas Resolution: 4096×4096(漢字を含むため大きめ)
Character Set: Unicode Range (Japanese)

2. ビルドサイズの最適化

// PlayerSettings
- Strip Engine Code: ON
- Managed Stripping Level: Medium
- Script Call Optimization: Fast but no Exceptions

これにより、ビルドサイズを約200MBから80MBに削減できました。

苦労した点と解決策

1. 点数計算の複雑さ

麻雀の点数計算は非常に複雑(符計算、飜数計算、役満判定など)。

解決策
– 公式ルールブックを元にテストケースを作成
– 既存の麻雀ゲームと照合して正確性を担保

2. AIの強さ調整

「上級」と「鬼畜」の差が曖昧になりがち。

解決策
– 統計データを取得(100局×10回)
– 勝率が目標範囲(上級:40-50%、鬼畜:55-65%)に収まるよう調整

3. メモリ管理

牌オブジェクトを毎局生成→破棄すると、メモリリークの原因に。

解決策
– オブジェクトプールパターンを採用
– 牌を使い回すことで、メモリ使用量を1/3に削減

public class TilePool
{
    private Queue<TileObject> _pool = new Queue<TileObject>();

    public TileObject Get()
    {
        if (_pool.Count > 0)
        {
            var tile = _pool.Dequeue();
            tile.gameObject.SetActive(true);
            return tile;
        }

        return Instantiate(tilePrefab);
    }

    public void Return(TileObject tile)
    {
        tile.gameObject.SetActive(false);
        _pool.Enqueue(tile);
    }
}

今後の拡張計画

  • [ ] オンライン対戦機能(Photon Unity Networkingを検討)
  • [ ] リプレイ機能
  • [ ] 牌譜保存・共有
  • [ ] 牌効率の学習モード
  • [ ] 音声による役の読み上げ

まとめ

Unityで麻雀ゲームを開発して学んだこと:

複雑なロジックは段階的に実装
→ 和了判定→役判定→点数計算の順で実装

AIの難易度調整は統計データが重要
→ 主観ではなく、実際の勝率で評価

パフォーマンス最適化は必須
→ オブジェクトプール、LeanTweenの活用

麻雀という複雑なゲームを通じて、ゲーム開発の面白さと難しさを実感できました。特にAI実装は、アルゴリズムやデータ構造の理解が深まる良い経験となりました。


技術スタック: Unity, C#, iOS
開発期間: 約3ヶ月
コード行数: 約8,000行
対象: iOS 13.0以上