TypeScriptで麻雀を作ってみた
こんにちは、YAMAです。
今回はTypeScriptで麻雀を作ってみた話を書いていこうと思います。
これを作成した経緯としては、TypeScriptの勉強を兼ねて学園祭の出し物として何か作ろうと思い、麻雀を作ることにしました。
麻雀はルールが複雑で、実装するのも大変そうだと思いましたが、やってみると意外と楽しくて、TypeScriptの勉強にもなりました。
今回は、麻雀を作るにあたってどのようなことを考えたのか、どのように実装したのか、そしてどのようなことを学んだのかを書いていこうと思います。
麻雀を作るにあたって考えたこと
麻雀を作るにあたって、まずはどのような機能を実装するのかを考えました。
麻雀はルールが複雑で、実装するのも大変そうだと思いましたが、まずは基本的な機能を実装することにしました。
基本的な機能としては、以下のようなものを考えました。
- 牌の生成とシャッフル(136枚 / 三人麻雀なら108枚)
- 配牌・ツモ・打牌の一連の流れ
- 鳴き(チー・ポン・カン)とロン・ツモの判定
- 和了形(4面子1雀頭・七対子・国士無双など)の判定
- 役の判定と点数計算
- 複数人でリアルタイムに対局できる通信機能
最初から全部を完璧に作ろうとすると挫折しそうだったので、「まず牌が配れて捨てられる」→「鳴きと和了ができる」→「点数がつく」→「オンラインで対戦できる」という順で、動くものを少しずつ育てていく方針にしました。
全体の構成
今回は フロントエンド(画面)とサーバー(ゲーム進行)を分ける 構成にしました。どちらも TypeScript で書いています。
- フロント: React + Vite。対局画面・ロビー・待機室などの UI を担当
- サーバー: Node.js + Socket.IO。ゲームのルールと進行を管理
- デプロイ: フロントは GitHub Pages、サーバーは Railway
なぜ分けたかというと、ゲームのロジックをクライアントに置くとカンタンにズルができてしまう からです。たとえば「自分の手牌をこっそり書き換える」「相手の手牌を覗く」といったことが、ブラウザの開発者ツールから簡単にできてしまいます。
そこで、
- 正しい状態(牌山・全員の手牌・点数)はすべてサーバーが持つ
- クライアントには「その人が見ていい情報だけ」を送る
- クライアントは「捨てる」「鳴く」などの操作を送るだけで、判定はサーバーがやる
という役割分担にしました。これは TypeScript の型がとても効いた部分で、サーバー側の「全部入りの状態」と、クライアントに送る「公開してよい状態」を別の型として定義しています。
// サーバー内部だけが持つ秘密情報つきの状態
interface PlayerState {
hand: Tile[]; // 手牌の中身(本人にしか見せない)
discards: Tile[];
score: number;
// ...
}
// クライアントへ送る「公開してよい」状態
interface PlayerView {
handCount: number; // 手牌は「枚数」だけ。中身は送らない
discards: Tile[];
score: number;
// ...
}PlayerView には hand(手牌の中身)というプロパティがそもそも存在しないので、「うっかり相手の手牌を全員に送ってしまう」ようなミスを、型のレベルで防げます。これは「型があると、できないことが明確になる」という TypeScript のうれしさを実感した瞬間でした。
牌をどう表現するか
麻雀の牌は、型で表すと驚くほどスッキリ書けました。
type Suit = 'man' | 'pin' | 'sou' | 'honor'; // 萬子・筒子・索子・字牌
interface Tile {
id: string; // 同じ「5萬」でも個別に区別するためのID
suit: Suit; // 種類
value: number; // 数字(字牌は1〜7で東南西北白發中)
red?: boolean; // 赤ドラ(赤5)かどうか
}Suit のように 「取りうる値を | で並べた型(ユニオン型)」 を使うと、'man' や 'pin' 以外の文字列を入れた時点でエラーになります。スペルミスで 'mann' と書いてしまっても、その場で気づけるわけです。麻雀のように「種類が決まっているもの」が多いゲームと、ユニオン型はとても相性がよかったです。
和了判定は「再帰」で解く
一番頭を使ったのが 和了形の判定 です。手牌が「4つの面子(3枚組)+1つの雀頭(2枚組)」になっているかを調べる必要があります。
ここは「一番小さい牌から順番に、刻子(同じ牌3枚)か順子(連続する3枚)として取り除けるか試し、残りで同じことを繰り返す」という 再帰 で実装しました。
function canFormMelds(tiles: Tile[]): boolean {
if (tiles.length === 0) return true; // 全部きれいに分けられた
if (tiles.length % 3 !== 0) return false; // 3で割れない=面子にならない
// 先頭の牌を「刻子の一部」または「順子の一部」とみなして、
// 取り除いた残りでもう一度 canFormMelds を呼ぶ(=自分自身を呼ぶ)
// ...
}「分からなくなったら、一回り小さい同じ問題に分解する」という再帰の考え方が、麻雀の手牌分解とぴったりハマって、書いていて気持ちよかったところです。
点数計算という沼
正直に言うと、一番大変だったのは 点数計算 でした。麻雀の点数は「役の飜数」と「符」で決まりますが、
- 同じ手牌でも、面子の分け方によって役が変わる(たとえば一盃口になったり、別の形になったり)
- 一番点数が高くなる解釈を採用する必要がある(高点法)
という事情があり、「すべての分け方を列挙して、それぞれ役と符を計算し、一番高いものを選ぶ」という総当たりで実装しました。役満・タンヤオ・平和・三色・一気通貫…と役を一つずつ足していくたびに、麻雀ってこんなにルールがあったのかと改めて驚きました。
リアルタイム通信は Socket.IO
オンライン対戦には Socket.IO を使いました。「捨てる」「鳴く」といったイベントをクライアントから送り、サーバーが処理して、結果を全員に配信する、という流れです。
ここでも型が活躍しました。やり取りするイベントの名前と引数を、あらかじめ型として定義しておけるのです。
interface ClientToServerEvents {
'discard-tile': (tileId: string) => void;
'declare-riichi': (tileId: string) => void;
'claim': (claim: ClaimRequest) => void;
// ...
}これを Socket.IO に渡しておくと、存在しないイベント名を emit したり、引数の型を間違えたりすると、コンパイル時点でエラーになります。「文字列でイベント名を投げる」ようなゆるい部分に型のレールが敷けるのは、想像以上に安心感がありました。
つまずいたバグたち
作っている途中では、いろいろなバグにも出会いました。いくつか紹介します。
- アガったのに和了牌が違う牌で表示される: ツモ和了のとき「手牌の中で和了形を成立させる牌」を先頭から探していたため、実際にツモった牌ではなく別の牌が選ばれていました。「ツモ和了の和了牌は、直前にツモった牌そのもの」と直して解決。
- テンパイなのにリーチボタンが出ない: テンパイ判定を「ツモ後の14枚」にかけていたのが原因でした。テンパイは本来「あと1枚で上がれる13枚」の概念なので、「1枚切ってテンパイになる打牌があるか」で判定するよう修正しました。
- リロードすると対局に戻れない: ブラウザを更新すると接続が切れて最初の画面に戻ってしまう問題。
localStorageに「自分が誰か」を表すトークンを保存しておき、再接続時にサーバーへ伝えて元の席に戻す仕組みを入れました。
特にリロード復帰では、Reactのレンダリング中に状態を更新してしまって「無限ループ」になるバグ(有名な React error #301)も踏みました。「画面の状態は、持っているデータから計算して決める」という形に直したら、すっきり直りました。
学んだこと
麻雀という題材を通して、TypeScript について次のようなことを学べました。
- ユニオン型と判別で、「取りうる値」を型で縛れること
- 型を分けることで、やってはいけないことを構造的に防げること(手牌を漏らさない設計など)
- クライアントとサーバーで責務を分ける設計の大切さ(ロジックはサーバー、表示はクライアント)
- 通信のような「ゆるくなりがちな部分」にも型を効かせられること
最初は「ルールが複雑すぎて無理かも」と思っていましたが、機能を小さく区切って一つずつ動かしていくと、気づけば三人麻雀・リーチ・鳴き・赤ドラ・北抜き・対局中チャット・再接続まで動くものになっていました。複雑なお題ほど、型でガードしながら少しずつ育てる進め方が効いてくる、というのが今回の一番の学びです。
ディレクトリ構成とコードの分け方
機能が増えてくると、「どこに何を書いたか分からない」状態になりがちです。今回は 「役割ごとにファイルを分ける」 ことを意識しました。
.
├─ src/ # フロントエンド(React)
│ ├─ components/ # 画面パーツ
│ │ ├─ GameBoard.tsx # 対局画面(牌・河・スコア・操作)
│ │ ├─ Lobby.tsx # ロビー(部屋一覧・作成)
│ │ ├─ WaitingRoom.tsx # 待機室
│ │ ├─ RoundResult.tsx # 局終了の結果モーダル
│ │ ├─ TileComponent.tsx # 牌1枚の描画
│ │ └─ Chat.tsx # 対局中チャット
│ ├─ hooks/
│ │ └─ useSocket.ts # サーバーとの通信をまとめたカスタムフック
│ └─ types/
│ └─ mahjong.ts # クライアント側の型・補助関数
│
└─ server/src/ # サーバー(Node + Socket.IO)
├─ index.ts # 通信の入口(イベントの受け口)
├─ roomManager.ts # 部屋の管理(作成・参加・再接続)
├─ types.ts # サーバー側の型・通信イベントの型
└─ game/ # ★ゲームのルールはここに集約
├─ MahjongGame.ts # 局の進行(配牌→ツモ→打牌→和了…)
├─ scoring.ts # 役判定・点数計算
├─ winCheck.ts # 和了形・待ちの判定
└─ tiles.ts # 牌の生成・シャッフル・ソート特に意識したのが、「ゲームのルール(game/)」と「通信(index.ts / roomManager.ts)」を混ぜない ことです。
たとえば index.ts は「クライアントから来たイベントを受け取って、対応する関数に渡すだけ」に徹しています。
socket.on('discard-tile', tileId => {
handleDiscard(socket.id, tileId); // 中身は知らない。渡すだけ
});そして実際のルール(捨てられるか?次は誰の番か?)は MahjongGame が持ちます。MahjongGame の側は 「通信のことを一切知らない」 のがポイントです。状態が変わったときに何をするかは、外から関数(コールバック)として受け取るようにしました。
new MahjongGame(
playerCount,
players,
() => broadcastGameUpdate(room), // 状態が変わったら全員に配信
(result) => emitRoundEnd(result), // 局が終わったら結果を配信
(seat, ...) => emitClaimWindow(...), // 鳴ける人に選択肢を送る
);こうしておくと、MahjongGame は「Socket.IO を使っているのか」「テストのためにダミーを使っているのか」を気にしなくてよくなります。ロジックと通信を切り離す だけで、コードがぐっと読みやすく・直しやすくなりました。この「責務を分ける」感覚が、今回の制作で一番身についたことかもしれません。
スマホ対応で苦労したUIの話
学園祭では「自分のスマホで気軽に打てる」状態にしたかったので、レスポンシブ対応にも取り組みました。ところが、PC向けに作った対局画面をスマホで開くと、なかなか悲惨なことになっていました。
河(捨て牌)が縦に立っていて読めない
最初の実装では、左右のプレイヤー(上家・下家)の河を、牌をまっすぐ立てたまま縦に積む形にしていました。これが「細長い列に牌がびっしり」で、とても読みづらかったのです。
本物の麻雀卓では、各家の捨て牌は自分に向かって横倒しになっています。そこで、
- 上家(左)と下家(右)の牌は 横倒し(90°回転)、しかも左右で逆向きに
- 対面の牌は 180°回転(上下反転)
として、卓を囲んでいるように見せました。
ここでハマったのが、CSSの transform: rotate() は見た目を回すだけで、レイアウト上の大きさは変わらない という点です。牌を90°回しても、まわりは「回す前の縦長の箱」のままだと思っているので、牌どうしが重なってしまいます。
これは、牌を 「外側の枠(span)」で包んで、枠の方を横長サイズにする ことで解決しました。
<span className="river-tile"> {/* 枠はレイアウト用。横倒し後のサイズで場所を確保 */}
<TileComponent tile={t} small /> {/* 中の牌だけを回転させる */}
</span>パネルが重なる・河が手牌に被る
スマホを横向きにすると、画面の高さがとても低くなります。PC向けの固定サイズのままだと、
- 自分のパネルが上家のパネルと重なる
- 中央の河が、下の手牌や操作ボタンに被る
といったことが起きていました。
ここはメディアクエリ(画面サイズによってCSSを切り替える仕組み)で、画面が低いときは牌・パネル・スコアボードを一回り小さくし、パネルの位置も詰め直す ことで対応しました。
@media (max-height: 600px) {
.tile { width: 34px; height: 48px; } /* 牌を小さく */
.pos-self { bottom: 104px; } /* 自分のパネルを下げて重なり回避 */
/* ...スコアボードや河も縮小... */
}スマホならではの細かい対応
- モバイルブラウザはアドレスバーのぶん
100vhがはみ出すので、対応端末では100dvhを使うようにしました。 - ノッチ(画面のへこみ)に隠れないよう、
env(safe-area-inset-*)で余白を確保。 - 横幅が足りないときは、手牌を横スクロールできるようにして、段折れでボタンが押し出されないようにしました。
「PCで動く」と「スマホで気持ちよく遊べる」の間には、思っていた以上に距離がありました。でも、ちゃんと卓を囲んでいるように見えたときは素直に嬉しかったです。
今後やりたいこと
ひとまず人同士で対局できるところまで作れましたが、まだまだやりたいことがたくさんあります。
- CPU(AI)対戦: 今は人が4人揃わないと始められません。簡単でいいので「とりあえず打ってくれる相手」がいると、一人でも遊べて練習にもなります。手番がしばらく進まないときに自動で打牌する仕組み(タイムアウト)とも相性がよさそうです。
- 観戦機能: 対局を「見るだけ」で参加できると、学園祭でも横から眺めて盛り上がれます。手牌を隠して配信するだけなので、今の「公開してよい情報だけ送る」設計の延長で作れそうです。
- 符計算の精緻化と検証: 点数まわりは簡略化している部分があるので、いろいろな手牌で「本当に正しい点数か」をテストで確かめたいです。ここは自動テストを書く練習にもちょうどよさそうです。
- 演出・効果音: ツモやロン、リーチ宣言のときに音やアニメーションがあると、一気にゲームらしくなります。
- 戦績の記録: 対局結果を保存して、勝率や順位の推移を見られるようにしたいです。
麻雀という複雑なお題でしたが、TypeScript の型に支えられながら一歩ずつ進めると、思った以上に形になりました。「複雑なものほど、型でガードしながら小さく育てる」——この感覚を持ち帰れたのが、今回の一番の収穫です。
ここまで読んでいただき、ありがとうございました。次は CPU 対戦あたりに挑戦してみようと思います。
おまけ
実際に作った麻雀ゲームはこちら
以上、YAMAでした。