複雑な麻雀の有効牌計算ロジックをアルゴリズム化し、フローチャートやグラフで可視化することで、プログラミング初心者でも理解しやすい形で実装手法を解説します。
麻雀アプリ開発でこんな悩みありませんか?
麻雀アプリやゲーム開発に携わるエンジニアの皆さん、こんな課題に直面していませんか?
- 麻雀の有効牌(テンパイ時に上がれる牌)の計算が複雑すぎて実装に困っている
- 既存のライブラリはあるが、内部処理が見えずカスタマイズできない
- チーム内で麻雀ルールを知らないメンバーがいて、仕様共有に苦労している
- パフォーマンスの最適化ポイントが分からない
私たちFivenine Designでも、あるクライアントから「麻雀アプリのコア機能を開発したい」という依頼を受けた際に、同じような課題に直面しました。麻雀は日本の伝統的なゲームですが、そのルールをプログラムで正確に表現するのは想像以上に複雑です。
特に有効牌の計算は、単純な組み合わせ判定だけでなく、面子(メンツ)の構成パターンや特殊役まで考慮する必要があり、経験豊富なエンジニアでも実装に時間がかかってしまいます。
なぜ麻雀の有効牌計算は複雑なのか?
麻雀の有効牌計算が難しい理由を整理してみましょう。まず、麻雀の基本的な上がり形は「4つの面子(メンツ)+ 1つの雀頭(アタマ)」で構成されます。しかし、この面子には「順子(シュンツ)」「刻子(コーツ)」「槓子(カンツ)」の3種類があり、それぞれ異なる組み合わせパターンを持ちます。
さらに複雑なのは、同じ手牌でも複数の面子構成パターンが存在する場合があることです。例えば、「2,3,4,4,5,6」という数牌の組み合わせは、「23+445+6」と「234+45+6」の2通りの解釈が可能で、それぞれ異なる有効牌を生み出します。
flowchart TD
A[手牌入力] --> B{面子構成パターン解析}
B --> C[パターン1: 順子重視]
B --> D[パターン2: 刻子重視]
C --> E[有効牌計算1]
D --> F[有効牌計算2]
E --> G[結果統合]
F --> G
G --> H[最終有効牌リスト]実際のプロジェクトでは、この複雑さを理解せずに開発を始めたため、初期実装で大幅な設計変更を余儀なくされました。「シンプルな組み合わせ計算だろう」と思っていたクライアントも、実際の仕様を見て驚かれていました。
麻雀特有の「七対子」「国士無双」といった特殊役も考慮すると、単一のアルゴリズムでは対応できず、役ごとに異なる判定ロジックを実装する必要があります。これらの複雑さを事前に理解し、適切な設計を行うことが成功の鍵となります。
可視化されたアルゴリズムで解決する具体的手順
ステップ1: 基本データ構造の定義
まず、麻雀牌を扱うための基本的なデータ構造を定義します。牌の種類(萬子、筒子、索子、字牌)と数値を組み合わせた表現を使用します。
class MahjongTile {
constructor(suit, number) {
this.suit = suit; // 'm'(萬子), 'p'(筒子), 's'(索子), 'z'(字牌)
this.number = number; // 1-9 (字牌は1-7)
}
toString() {
return `${this.number}${this.suit}`;
}
equals(other) {
return this.suit === other.suit && this.number === other.number;
}
}
class Hand {
constructor(tiles) {
this.tiles = tiles;
this.tileCount = this.countTiles();
}
countTiles() {
const count = {};
this.tiles.forEach(tile => {
const key = tile.toString();
count[key] = (count[key] || 0) + 1;
});
return count;
}
}
ステップ2: 面子構成パターンの解析アルゴリズム
有効牌計算の核心となる面子構成パターンの解析を実装します。再帰的なアプローチを使用して、すべての可能な組み合わせを探索します。
class EffectiveTileCalculator {
calculateEffectiveTiles(hand) {
const allPatterns = this.findAllMentsuPatterns(hand.tileCount);
const effectiveTiles = new Set();
// 各パターンについて有効牌を計算
allPatterns.forEach(pattern => {
const tiles = this.calculateForPattern(pattern, hand);
tiles.forEach(tile => effectiveTiles.add(tile.toString()));
});
// 特殊役(七対子、国士無双)の有効牌も計算
const specialTiles = this.calculateSpecialYaku(hand);
specialTiles.forEach(tile => effectiveTiles.add(tile.toString()));
return Array.from(effectiveTiles).map(str => this.parseStringToTile(str));
}
findAllMentsuPatterns(tileCount, mentsuCount = 0, jantou = false) {
// 4面子+1雀頭の組み合わせを再帰的に探索
if (mentsuCount === 4 && jantou) {
return [{}]; // 完成形
}
const patterns = [];
// 順子パターンの探索
for (let suit of ['m', 'p', 's']) {
for (let num = 1; num <= 7; num++) {
if (this.canFormShuntsu(tileCount, suit, num)) {
const newCount = this.removeShuntsu(tileCount, suit, num);
const subPatterns = this.findAllMentsuPatterns(newCount, mentsuCount + 1, jantou);
patterns.push(...subPatterns);
}
}
}
// 刻子・槓子パターンの探索
Object.keys(tileCount).forEach(key => {
if (tileCount[key] >= 3) {
const newCount = {...tileCount};
newCount[key] -= 3;
const subPatterns = this.findAllMentsuPatterns(newCount, mentsuCount + 1, jantou);
patterns.push(...subPatterns);
}
});
return patterns;
}
}
ステップ3: 計算結果の可視化
有効牌計算の処理フローと結果を可視化することで、チーム内での理解を深めます。
実装したアルゴリズムでは、面子パターンごとに異なる計算時間を要することが分かりました。特に順子系の組み合わせは複雑な分岐が多いため、キャッシュ機能の実装が効果的です。
ステップ4: パフォーマンス最適化
大量のゲームセッションを処理するため、計算結果のキャッシュ機能を実装します。
class OptimizedCalculator {
constructor() {
this.cache = new Map();
this.cacheHitRate = 0;
this.totalRequests = 0;
}
calculateWithCache(hand) {
this.totalRequests++;
const handKey = this.generateHandKey(hand);
if (this.cache.has(handKey)) {
this.cacheHitRate = (this.cacheHitRate * (this.totalRequests - 1) + 1) / this.totalRequests;
return this.cache.get(handKey);
}
const result = this.calculateEffectiveTiles(hand);
this.cache.set(handKey, result);
return result;
}
generateHandKey(hand) {
// 手牌を正規化してユニークなキーを生成
const sorted = Object.keys(hand.tileCount)
.sort()
.map(key => `${key}:${hand.tileCount[key]}`)
.join('|');
return sorted;
}
}
よくある失敗パターンと対処法
失敗パターン1: 面子構成の見落とし
最も頻繁に発生する問題は、複数の面子構成パターンを考慮せずに実装してしまうことです。例えば「1,2,3,3,4,5」という手牌で、「123 + 345」というパターンしか考慮せず、「123 + 33 + 45(テンパイ)」のパターンを見落としてしまうケースがあります。
対処法: 全パターン探索を基本とし、各パターンで独立して有効牌を計算する設計にする
// 悪い例: 単一パターンのみ考慮
function badCalculation(tiles) {
const mentsu = findFirstPattern(tiles); // 最初に見つかったパターンのみ
return calculateEffective(mentsu);
}
// 良い例: 全パターンを考慮
function goodCalculation(tiles) {
const allPatterns = findAllPatterns(tiles); // すべてのパターンを探索
const allEffectives = allPatterns.map(pattern => calculateEffective(pattern));
return mergeEffectives(allEffectives);
}
失敗パターン2: パフォーマンスの軽視
リアルタイム対戦では1秒間に数十回の有効牌計算が発生するため、最適化を怠ると著しいパフォーマンス低下を招きます。私たちのプロジェクトでも、初期実装では1回の計算に50ms以上かかり、ユーザー体験を大きく損なっていました。
対処法: 早期からプロファイリングを実施し、段階的に最適化する
失敗パターン3: 特殊役の後付け実装
「基本的な役だけ実装して、あとから特殊役を追加すればいい」という考えで開始すると、設計の根本的な変更が必要になります。特に七対子や国士無双は通常の4面子1雀頭とは全く異なる判定ロジックが必要です。
対処法: 要件定義の段階で対応する役をすべて洗い出し、統一的な設計を行う
| 項目 | 初期実装 | 改善後実装 |
|---|---|---|
| 計算時間 | 50ms | 5ms以下 |
| 対応役数 | 基本役のみ | 全役対応 |
| キャッシュ | ||
| テストカバレッジ | 60% | 95%以上 |
失敗パターン4: テストケースの不足
麻雀の組み合わせは膨大で、人の手による検証では限界があります。特にエッジケースでの動作不良は、本番環境で予期しない問題を引き起こします。
対処法: 既存の麻雀エンジンとの結果比較テストや、プロの麻雀解説との照合を自動化する
実装効果の測定と改善
実際のクライアントプロジェクトでは、可視化されたアルゴリズムの導入により以下の改善を実現しました:
特に開発チーム内での仕様共有が劇的に改善され、麻雀を知らないエンジニアでもアルゴリズムの理解と実装が可能になりました。また、視覚的なフローチャートにより、コードレビューの効率も大幅に向上しています。
導入前の課題:
- 仕様理解に1人あたり2週間必要
- バグ修正に平均3日必要
- 新機能追加時の影響範囲が不明
導入後の改善:
- 仕様理解が3日で完了
- バグ修正が平均1日で完了
- 変更の影響範囲が事前に把握可能
クライアントからも「チームの開発効率が目に見えて向上した」「新しいメンバーのオンボーディングがスムーズになった」という評価をいただいています。
まとめと次のステップ
麻雀の有効牌計算アルゴリズムの可視化により、複雑な処理ロジックを直感的に理解できるようになりました。フローチャートやパフォーマンス指標の可視化は、技術的な実装だけでなく、チーム開発の効率化にも大きく貢献します。
今回紹介した手法は、麻雀に限らず複雑なビジネスロジックを持つシステム開発全般に応用可能です。特に以下のような分野での活用が期待できます:
- カードゲームやボードゲームのデジタル化
- 金融商品の自動判定システム
- 在庫管理の最適化アルゴリズム
- 医療診断支援システム
次に取り組むべきアクション:
もし複雑なビジネスロジックの実装や、アルゴリズムの可視化についてお困りのことがございましたら、Fivenine Designまでお気軽にご相談ください。20年以上の開発実績を活かし、プロジェクトの成功をサポートいたします。