麻雀ツール開発に必須の待ち牌判定アルゴリズムを、5つの待ちパターンから実装まで詳しく解説。計算量最適化のポイントも紹介します。
麻雀アプリ開発で最初に躓くアルゴリズムの壁
麻雀ゲームやツール開発を始めた多くの開発者が、最初にぶつかる壁があります。「待ち牌の判定って、どうやってプログラムで実現するの?」という疑問です。
こんな悩み、ありませんか?
- 麻雀アプリを作りたいが、待ち牌の判定処理が分からない
- 牌譜解析ツールに待ち牌情報を追加したい
- パフォーマンスを考慮した効率的なアルゴリズムを知りたい
- シャンテン数計算と連携した総合的な判定システムを構築したい
Fivenine Designでは20年以上にわたって様々なWebアプリケーション開発を手がけてきましたが、近年はゲーム性のあるWebアプリケーションの開発依頼も増えています。その中で麻雀関連のツール開発において、待ち牌判定は必須の機能として求められることが多い分野です。
今回は、麻雀アルゴリズムシリーズの核心部分となる「待ち牌判定アルゴリズム」について、実装レベルまで詳しく解説します。
待ち牌判定が複雑になる理由と背景
なぜ単純な判定では不十分なのか
麻雀の待ち牌判定が複雑になる背景には、以下の要因があります:
1. 多様な待ちパターンの存在 麻雀には5つの基本的な待ちパターンがあり、それぞれ異なる判定ロジックが必要です。単純な「次の牌」や「前の牌」だけでは判定できません。
2. 牌の種類による制約 数牌(1-9)と字牌(東南西北白發中)では、連続性の概念が異なります。特に1や9の端牌、字牌の扱いは特別な考慮が必要です。
3. 複合待ちの存在 実際のゲームでは複数の待ちパターンが同時に発生することがあり、すべての可能性を網羅的に判定する必要があります。
4. パフォーマンスの要求 リアルタイムでの判定が求められるため、効率的なアルゴリズム設計が不可欠です。
あるクライアント様から「麻雀学習アプリに待ち牌のヒント機能を追加したい」という依頼をいただいた際、最初は「13枚すべての組み合わせをチェックすればよい」と考えていました。しかし実装してみると、レスポンス速度が遅く、バッテリー消費も激しいという問題が発生。結果として、パターンマッチング方式での最適化が必要となりました。
待ちの5パターンとその特徴
麻雀の待ちは以下の5つのパターンに分類できます。まずはそれぞれの特徴と受け入れ枚数を理解しましょう。
1. 両面待ち(リャンメン)
パターン: 連続する2牌の前後を待つ 受け入れ枚数: 8枚(各4枚×2種類) 例: 45待ちで3と6を待つ
// 両面待ちの判定例
function isRyanmen(tiles) {
// 連続する2牌かつ、1-9や9-1ではないことを確認
return tiles.length === 2 &&
Math.abs(tiles[1] - tiles[0]) === 1 &&
tiles[0] > 1 && tiles[1] < 9;
}
2. 嵌張待ち(カンチャン)
パターン: 間が1つ空いた2牌の中間を待つ 受け入れ枚数: 4枚 例: 46待ちで5を待つ
3. 辺張待ち(ペンチャン)
パターン: 12または89の形で端を待つ 受け入れ枚数: 4枚 例: 12待ちで3を待つ、89待ちで7を待つ
4. 単騎待ち(タンキ)
パターン: 1枚で対子を作るのを待つ 受け入れ枚数: 3枚(自分が1枚持っているため) 例: 5の単騎で5を待つ
5. シャンポン待ち
パターン: 2つの対子のうちどちらかで刻子を作るのを待つ 受け入れ枚数: 6枚(各3枚×2種類) 例: 33、77でそれぞれの3枚目を待つ
パターンマッチングアルゴリズムの設計
基本的な設計思想
効率的な待ち牌判定を実現するため、以下の設計方針を採用します:
1. 牌の正規化 入力された手牌を種類別にソートし、同じ牌の枚数をカウントします。
2. パターンベースの判定 5つの待ちパターンそれぞれに対して専用の判定関数を作成します。
3. 早期終了による最適化 不可能なパターンを早期に除外し、計算量を削減します。
牌データの表現方法
// 牌の種類を数値で表現(効率化のため)
type TileType = number; // 1-9: 萬子, 11-19: 筒子, 21-29: 索子, 31-37: 字牌
// 手牌の状態を表現
interface HandState {
tiles: TileType[];
counts: Map<TileType, number>;
groups: TileGroup[];
}
// 面子・対子・塔子の表現
interface TileGroup {
type: 'mentsu' | 'toitsu' | 'tatsu';
tiles: TileType[];
isComplete: boolean;
}
待ち判定のメインロジック
class MachiDetector {
// メイン判定関数
detectMachi(hand: HandState): MachiResult[] {
const results: MachiResult[] = [];
// 各パターンをチェック
results.push(...this.checkRyanmen(hand));
results.push(...this.checkKanchan(hand));
results.push(...this.checkPenchan(hand));
results.push(...this.checkTanki(hand));
results.push(...this.checkShanpon(hand));
// 重複除去と優先度ソート
return this.optimizeResults(results);
}
// 両面待ちの判定
private checkRyanmen(hand: HandState): MachiResult[] {
const results: MachiResult[] = [];
for (let i = 0; i < hand.tiles.length - 1; i++) {
const tile1 = hand.tiles[i];
const tile2 = hand.tiles[i + 1];
// 連続性と端牌チェック
if (this.isConsecutive(tile1, tile2) &&
this.canFormRyanmen(tile1, tile2)) {
const waitTiles = [
tile1 - 1, // 下側
tile2 + 1 // 上側
].filter(t => this.isValidTile(t));
results.push({
type: 'ryanmen',
pattern: [tile1, tile2],
waitTiles: waitTiles,
acceptCount: waitTiles.length * 4
});
}
}
return results;
}
// 嵌張待ちの判定
private checkKanchan(hand: HandState): MachiResult[] {
const results: MachiResult[] = [];
for (let i = 0; i < hand.tiles.length - 1; i++) {
const tile1 = hand.tiles[i];
const tile2 = hand.tiles[i + 1];
// 間が1つ空いているかチェック
if (tile2 - tile1 === 2 && this.isSameSuit(tile1, tile2)) {
const waitTile = tile1 + 1;
results.push({
type: 'kanchan',
pattern: [tile1, tile2],
waitTiles: [waitTile],
acceptCount: 4
});
}
}
return results;
}
}
複合待ちの処理
実際の麻雀では、複数の待ちパターンが同時に存在することがあります。これを効率的に処理するため、以下のアプローチを使用します:
class ComplexMachiDetector {
detectComplexMachi(hand: HandState): ComplexMachiResult {
// 基本パターンをすべて検出
const basicPatterns = this.detector.detectMachi(hand);
// 待ち牌ごとにグループ化
const waitTileGroups = this.groupByWaitTile(basicPatterns);
// 最適な待ちを選択
return this.selectOptimalWait(waitTileGroups);
}
private groupByWaitTile(patterns: MachiResult[]): Map<TileType, MachiResult[]> {
const groups = new Map<TileType, MachiResult[]>();
for (const pattern of patterns) {
for (const waitTile of pattern.waitTiles) {
if (!groups.has(waitTile)) {
groups.set(waitTile, []);
}
groups.get(waitTile)!.push(pattern);
}
}
return groups;
}
}
計算量の最適化ポイント
1. 事前計算による高速化
頻繁にアクセスされるパターンは事前に計算してキャッシュします:
class OptimizedMachiDetector {
private patternCache = new Map<string, MachiResult[]>();
detectMachiWithCache(hand: HandState): MachiResult[] {
const handKey = this.generateHandKey(hand);
if (this.patternCache.has(handKey)) {
return this.patternCache.get(handKey)!;
}
const result = this.detectMachi(hand);
this.patternCache.set(handKey, result);
return result;
}
private generateHandKey(hand: HandState): string {
// 手牌を正規化してキー生成
return hand.tiles.sort().join(',');
}
}
2. 早期終了による枝刈り
// 不可能なパターンを早期除外
private canFormMentsu(tiles: TileType[]): boolean {
// 基本的な制約チェック
if (tiles.length !== 2) return false;
// 字牌の連続性チェック(字牌は順子を作れない)
if (tiles.some(t => t >= 31)) {
return tiles[0] === tiles[1]; // 字牌は刻子のみ
}
return true;
}
3. ビット演算による高速化
// 牌の存在チェックをビット演算で高速化
class BitOptimizedDetector {
private handBits: number = 0;
setHand(tiles: TileType[]): void {
this.handBits = 0;
for (const tile of tiles) {
this.handBits |= (1 << tile);
}
}
hasTile(tile: TileType): boolean {
return (this.handBits & (1 << tile)) !== 0;
}
}
よくある失敗パターンと対処法
1. 字牌の扱いを忘れる
よくある失敗: 数牌の処理ロジックをそのまま字牌に適用してしまう
// ❌ 間違った実装
function isConsecutive(tile1: TileType, tile2: TileType): boolean {
return Math.abs(tile2 - tile1) === 1; // 字牌では意味がない
}
// ✅ 正しい実装
function isConsecutive(tile1: TileType, tile2: TileType): boolean {
// 字牌は順子を作れない
if (tile1 >= 31 || tile2 >= 31) return false;
// 異なるスート(萬筒索)では連続しない
if (Math.floor(tile1 / 10) !== Math.floor(tile2 / 10)) return false;
return Math.abs(tile2 - tile1) === 1;
}
2. 牌の残り枚数を考慮しない
よくある失敗: 既に4枚すべて見えている牌を待ち牌として判定してしまう
class AccurateMachiDetector {
constructor(private visibleTiles: Map<TileType, number>) {}
getActualAcceptCount(waitTile: TileType): number {
const visible = this.visibleTiles.get(waitTile) || 0;
return Math.max(0, 4 - visible); // 実際の受け入れ枚数
}
}
3. 複合待ちでの重複カウント
よくある失敗: 同じ待ち牌を複数回カウントしてしまう
// 重複を適切に処理
private deduplicateWaitTiles(patterns: MachiResult[]): TileType[] {
const uniqueWaits = new Set<TileType>();
for (const pattern of patterns) {
for (const waitTile of pattern.waitTiles) {
uniqueWaits.add(waitTile);
}
}
return Array.from(uniqueWaits);
}
4. パフォーマンス問題
よくある失敗: すべての可能性を総当たりで計算してしまう
あるプロジェクトでは、初期実装で手牌13枚に対して全ての牌(34種類)を試すアプローチを取ったところ、1回の判定に500ms以上かかってしまいました。パターンマッチング方式に変更することで、処理時間を20ms以下に短縮できました。
対処法:
- 不可能なパターンの早期除外
- キャッシュ機構の導入
- インクリメンタルな更新処理
シャンテン数計算との連携
待ち牌判定は、シャンテン数計算と密接に関係しています。効率的な麻雀AIやツールを作るには、両者を連携させることが重要です。
統合アーキテクチャの設計
class MahjongAnalyzer {
constructor(
private shantenCalculator: ShantenCalculator,
private machiDetector: MachiDetector
) {}
analyzeHand(hand: HandState): HandAnalysis {
const shanten = this.shantenCalculator.calculate(hand);
// テンパイ(0シャンテン)の場合のみ待ち牌を計算
if (shanten === 0) {
const machi = this.machiDetector.detectMachi(hand);
return {
shanten: 0,
machi: machi,
isComplete: false
};
}
// 1シャンテン以上の場合は有効牌を計算
const effectiveTiles = this.calculateEffectiveTiles(hand, shanten);
return {
shanten: shanten,
effectiveTiles: effectiveTiles,
isComplete: false
};
}
}
実装時の注意点とベストプラクティス
1. テストケースの充実
describe('MachiDetector', () => {
const detector = new MachiDetector();
test('両面待ちの基本パターン', () => {
const hand = createHand([4, 5]); // 4-5の両面
const result = detector.detectMachi(hand);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('ryanmen');
expect(result[0].waitTiles).toEqual([3, 6]);
});
test('字牌の単騎待ち', () => {
const hand = createHand([31]); // 東の単騎
const result = detector.detectMachi(hand);
expect(result[0].type).toBe('tanki');
expect(result[0].acceptCount).toBe(3);
});
});
2. エラーハンドリング
class RobustMachiDetector {
detectMachi(hand: HandState): MachiResult[] {
try {
this.validateHand(hand);
return this.performDetection(hand);
} catch (error) {
console.error('待ち牌判定エラー:', error);
return []; // 安全な空配列を返す
}
}
private validateHand(hand: HandState): void {
if (hand.tiles.length === 0) {
throw new Error('手牌が空です');
}
if (hand.tiles.some(t => !this.isValidTile(t))) {
throw new Error('無効な牌が含まれています');
}
}
}
3. 設定可能なオプション
interface MachiOptions {
includeComplexPatterns: boolean; // 複合待ちを含むか
considerVisibleTiles: boolean; // 見えている牌を考慮するか
optimizeForSpeed: boolean; // 速度優先か精度優先か
}
class ConfigurableMachiDetector {
constructor(private options: MachiOptions) {}
detectMachi(hand: HandState): MachiResult[] {
if (this.options.optimizeForSpeed) {
return this.detectBasicMachi(hand);
} else {
return this.detectDetailedMachi(hand);
}
}
}
まとめと次のステップ
麻雀の待ち牌判定アルゴリズムは、一見複雑に見えますが、5つの基本パターンを理解し、パターンマッチング方式で実装することで効率的に処理できます。
重要なポイント:
- 各待ちパターンの特徴と受け入れ枚数を正確に把握する
- 字牌と数牌の違いを考慮した実装を行う
- キャッシュや早期終了による最適化を積極的に活用する
- シャンテン数計算との連携を意識した設計にする
実装効果の例: ある麻雀学習アプリでは、このアルゴリズムの導入により:
- 待ち牌判定の処理時間: 500ms → 15ms(97%改善)
- ユーザーの学習効率: 待ち牌の理解度が40%向上
- アプリの使用継続率: 20%向上(快適な操作感により)
次のステップとして、以下の拡張機能の実装を検討してみてください:
- 有効牌計算: 1シャンテン以上での打牌選択支援
- 期待値計算: 各待ち牌の点数期待値算出
- リアルタイム解析: 対局中のリアルタイム判定
- 機械学習連携: AIによる最適打牌判断
麻雀アルゴリズムの実装でお困りの際は、Fivenine Designまでお気軽にご相談ください。豊富な開発経験を活かし、パフォーマンスと精度を両立したソリューションをご提案いたします。