(更新: 2026.03.21)

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

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 OnTileDiscarded;
    public event Action OnPlayerWin;
    public event Action OnGameEnd;

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

    技術的な実装ポイント

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

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

    public class WinningChecker
    {
        // 和了判定のメインロジック
        public bool IsWinningHand(List 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 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 CalculateYaku(Hand hand, Tile winningTile)
        {
            var yakuList = new List();
            
            // 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 _pool = new Queue();
        
        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以上