Unityで作る本格3D麻雀ゲーム – 4段階のAI難易度実装まで
Unityで作る本格3D麻雀ゲーム – 4段階のAI難易度実装まで
はじめに
麻雀ゲームアプリは数多く存在しますが、今回Unityを使ってiOS向けの3D麻雀ゲームを一から開発しました。
特にこだわったのはAIの難易度調整。初心者から上級者まで楽しめるよう、4段階の難易度(初心者・中級・上級・鬼畜)を実装しています。
この記事では、開発の過程で学んだことや技術的なポイントを紹介します。
プロジェクト概要
実装した機能
技術スタック
- 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の強さ調整
「上級」と「鬼畜」の差が曖昧になりがち。
解決策:
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);
}
}
今後の拡張計画
まとめ
Unityで麻雀ゲームを開発して学んだこと:
→ 和了判定→役判定→点数計算の順で実装
→ 主観ではなく、実際の勝率で評価
→ オブジェクトプール、LeanTweenの活用
麻雀という複雑なゲームを通じて、ゲーム開発の面白さと難しさを実感できました。特にAI実装は、アルゴリズムやデータ構造の理解が深まる良い経験となりました。
---
技術スタック: Unity, C#, iOS
開発期間: 約3ヶ月
コード行数: 約8,000行
対象: iOS 13.0以上