【TypeScript】麻雀の点数計算をしてみた【簡易ver.】

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#punch-一緒に二次元業界を盛り上げていきませんか)まで。
:::

## 🀄 概要

突然ですが、麻雀はお好きですか？
僕はかなり好きなんですが、点数計算だけはほんとうに苦手で今でもつまずきがちです。。。

というのも、学生時代は計算できる友達に頼っていたし、最近は雀魂といったオンライン麻雀でしかほぼ打たないので点数計算を自分でする機会があまりなかったというのが理由です。

そこで、勉強がてらに今回は僕が麻雀よりも大好きな **TypeScript(+Vue.js)** で麻雀の点数計算ツールを作ってみようと思います。

~~ほんとは麻雀の方が好きです~~

## 🀄 実装したもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1835408/6553c47b-af84-af0f-264c-76370da5cbe7.png)

<details open><summary>実際のコード</summary>

```vuejs
<script setup lang="ts">
// 今回は連底30符固定
// const points = [20, 25, 30, 40, 50, 60, 70, 80, 90, 100, 110];

const hands = {
  立直: 1,
  断么九: 1,
  平和: 1,
  一盃口: 1,
  役牌: 1,
  嶺上開花: 1,
  槍槓: 1,
  海底撈月: 1,
  河底撈魚: 1,
  三色同順: 2,
  一気通貫: 2,
  混全帯么九: 2,
  七対子: 2,
  対々和: 2,
  三暗刻: 2,
  混老頭: 2,
  三色同刻: 2,
  三槓子: 2,
  小三元: 2,
  ダブル立直: 2,
  混一色: 3,
  純全帯么九: 3,
  二盃口: 3,
  清一色: 6,
  国士無双: 13,
  四暗刻: 13,
  大三元: 13,
  字一色: 13,
  小四喜: 13,
  大四喜: 13,
  緑一色: 13,
  清老頭: 13,
  四槓子: 13,
  九蓮宝燈: 13,
  天和: 13,
  地和: 13,
};
const selectedHands = ref([]);

type ScoreTable = {
  [key: number]: {
    三麻: {
      ツモ: number | { min: number; max: number };
      ロン: number;
    };
    四麻: {
      ツモ: number | { min: number; max: number };
      ロン: number;
    };
  };
};
const children: ScoreTable = {
  1: {
    三麻: {
      ツモ: {
        min: 500,
        max: 700,
      },
      ロン: 1000,
    },
    四麻: {
      ツモ: {
        min: 300,
        max: 500,
      },
      ロン: 1000,
    },
  },
  2: {
    三麻: {
      ツモ: {
        min: 800,
        max: 1300,
      },
      ロン: 2000,
    },
    四麻: {
      ツモ: {
        min: 500,
        max: 1000,
      },
      ロン: 2000,
    },
  },
  3: {
    三麻: {
      ツモ: {
        min: 1500,
        max: 2500,
      },
      ロン: 3900,
    },
    四麻: {
      ツモ: {
        min: 1000,
        max: 2000,
      },
      ロン: 3900,
    },
  },
  4: {
    三麻: {
      ツモ: {
        min: 3000,
        max: 5000,
      },
      ロン: 8000,
    },
    四麻: {
      ツモ: {
        min: 2000,
        max: 4000,
      },
      ロン: 8000,
    },
  },
  6: {
    三麻: {
      ツモ: {
        min: 4500,
        max: 7500,
      },
      ロン: 12000,
    },
    四麻: {
      ツモ: {
        min: 3000,
        max: 6000,
      },
      ロン: 12000,
    },
  },
  8: {
    三麻: {
      ツモ: {
        min: 6000,
        max: 10000,
      },
      ロン: 16000,
    },
    四麻: {
      ツモ: {
        min: 4000,
        max: 8000,
      },
      ロン: 16000,
    },
  },
  11: {
    三麻: {
      ツモ: {
        min: 9000,
        max: 15000,
      },
      ロン: 24000,
    },
    四麻: {
      ツモ: {
        min: 6000,
        max: 12000,
      },
      ロン: 2000,
    },
  },
  13: {
    三麻: {
      ツモ: {
        min: 12000,
        max: 20000,
      },
      ロン: 32000,
    },
    四麻: {
      ツモ: {
        min: 8000,
        max: 16000,
      },
      ロン: 32000,
    },
  },
};
const parent: ScoreTable = {
  1: {
    三麻: {
      ツモ: 800,
      ロン: 1500,
    },
    四麻: {
      ツモ: 500,
      ロン: 1500,
    },
  },
  2: {
    三麻: {
      ツモ: 1500,
      ロン: 2900,
    },
    四麻: {
      ツモ: 1000,
      ロン: 3000,
    },
  },
  3: {
    三麻: {
      ツモ: 3000,
      ロン: 5800,
    },
    四麻: {
      ツモ: 2000,
      ロン: 5800,
    },
  },
  4: {
    三麻: {
      ツモ: 6000,
      ロン: 12000,
    },
    四麻: {
      ツモ: 4000,
      ロン: 12000,
    },
  },
  6: {
    三麻: {
      ツモ: 9000,
      ロン: 18000,
    },
    四麻: {
      ツモ: 6000,
      ロン: 18000,
    },
  },
  8: {
    三麻: {
      ツモ: 12000,
      ロン: 24000,
    },
    四麻: {
      ツモ: 8000,
      ロン: 24000,
    },
  },
  11: {
    三麻: {
      ツモ: 18000,
      ロン: 36000,
    },
    四麻: {
      ツモ: 12000,
      ロン: 36000,
    },
  },
  13: {
    三麻: {
      ツモ: 24000,
      ロン: 48000,
    },
    四麻: {
      ツモ: 16000,
      ロン: 48000,
    },
  },
};

type Flag = {
  親: boolean;
  人数: "三麻" | "四麻";
  和了: "ツモ" | "ロン";
  本場: 0;
};
const flags = ref(<Flag>{});

// 点数計算
const double = ref(0);
const result = ref("");
const calculate = () => {
  // 翻数
  double.value = selectedHands.value.reduce((prev, current) => {
    prev += hands[current];
    return prev;
  }, 0);

  // 素点の計算
  const table = flags.value["親"] ? parent : children;
  const scoreIndex = Object.keys(table).reduce((prev, current) => {
    // 和了った翻数と点数表を比較し、該当する翻数を判定
    if (double.value >= Number(current)) {
      const abs = Math.abs(double.value - Number(current));
      const prevAbs = Math.abs(double.value - prev);
      prev = abs !== prevAbs ? (Math.min(abs, prevAbs) === abs ? Number(current) : prev) : prev;
    }
    return prev;
  }, 99);
  const scores = table[scoreIndex];
  const score = scores[flags.value["人数"]][flags.value["和了"]];

  // 本場の計算
  // ロン和了の場合、四麻: 1本場 = 300点, 三麻: 1本場 = 1000点
  // ツモ和了の場合、四麻: 1本場 = 100点, 三麻: 1本場 = 500点
  //（三麻のツモ和了はルールによってかなり違うので、今回は適当に500点としました）
  const isThird = flags.value["人数"] === "三麻";
  const isRon = flags.value["和了"] === "ロン";
  const roundBase = isRon ? (isThird ? 1000 : 300) : isThird ? 500 : 100;
  const roundPoint = flags.value["本場"] * roundBase;

  // 計算結果出力
  const all = isRon ? "" : " all";
  result.value =
    typeof score === "number" ? `${score + roundPoint}${all}` : `${score.min + roundPoint} / ${score.max + roundPoint}`;
};
</script>

<template>
  <div class="flex flex-col justify-center items-end w-fit h-screen gap-y-10 gap-x-8 mx-auto">
    <div class="flex items-center gap-center w-full gap-4">
      <details class="dropdown">
        <summary class="btn m-1">役を選択</summary>
        <ul class="dropdown-content menu z-[1] h-64 w-max rounded-2xl bg-base-100 p-2 shadow">
          <li v-for="(_, hand) in hands" :id="hand">
            <label class="label cursor-pointer shrink-0">
              <input :value="hand" v-model="selectedHands" type="checkbox" class="checkbox-accent checkbox" />
              <span class="label-text mx-5 text-nowrap">{{ hand }}</span>
            </label>
          </li>
        </ul>
      </details>
      <label class="label cursor-pointer shrink-0">
        <span class="label-text mx-5 text-nowrap">親</span>
        <input v-model="flags['親']" type="checkbox" class="checkbox-accent checkbox" />
      </label>
      <label class="label cursor-pointer shrink-0">
        <span class="label-text mr-5 text-nowrap">人数</span>
        <select v-model="flags['人数']" class="select select-bordered w-full max-w-xs">
          <option selected>三麻</option>
          <option>四麻</option>
        </select>
      </label>
      <label class="label cursor-pointer shrink-0">
        <span class="label-text mr-5 text-nowrap">和了</span>
        <select v-model="flags['和了']" class="select select-bordered w-full max-w-xs">
          <option selected>ツモ</option>
          <option>ロン</option>
        </select>
      </label>
      <label class="label cursor-pointer shrink-0">
        <span class="label-text mr-5 text-nowrap">本場</span>
        <input
          v-model="flags['本場']"
          type="text"
          placeholder="本場数を入力"
          class="input input-bordered w-full max-w-xs"
        />
      </label>
    </div>
    <div class="flex justify-end items-center w-full gap-10">
      <p v-if="result" class="text-3xl font-semibold">計算結果：{{ double }}翻, {{ result }}</p>
      <div class="flex items-center justify-end gap-10">
        <p class="font-semibold">{{ selectedHands.join(", ") || "役を選択してください" }}</p>
        <button class="btn btn-accent text-white" @click="calculate">計算</button>
      </div>
    </div>
  </div>
</template>
```

</details>

今回は時間があまり取れなかったため、少し雑多なコードになっています 😢

また、実装簡略化のため

- 役判定なし（選択式）
- 符計算なし（連底30符固定）
- ローカル役満や流し満貫はなし
- 副露および喰い下がりは考慮しない
- 以下のようなありえないパターン
    - 同時に成立しない役
    - 副露で成立しない役
    - ツモなのに河底撈魚
    などのバリデーションは未実装
- 役牌はひとまとめ（種類ごとに分けず）
    - これはただの怠慢

という感じで実装しました。

他にも考慮が漏れている部分があるかもしれませんが、ご容赦ください :pray:
それでは、それぞれの実装について解説していきます。

## 🀄 実装解説

### マスタデータ的なもの（手役一覧、点数表）

<details open><summary>コード</summary>

```ts
const hands = {
  立直: 1,
  断么九: 1,
  平和: 1,
  一盃口: 1,
  役牌: 1,
  嶺上開花: 1,
  槍槓: 1,
  海底撈月: 1,
  河底撈魚: 1,
  三色同順: 2,
  一気通貫: 2,
  混全帯么九: 2,
  七対子: 2,
  対々和: 2,
  三暗刻: 2,
  混老頭: 2,
  三色同刻: 2,
  三槓子: 2,
  小三元: 2,
  ダブル立直: 2,
  混一色: 3,
  純全帯么九: 3,
  二盃口: 3,
  清一色: 6,
  国士無双: 13,
  四暗刻: 13,
  大三元: 13,
  字一色: 13,
  小四喜: 13,
  大四喜: 13,
  緑一色: 13,
  清老頭: 13,
  四槓子: 13,
  九蓮宝燈: 13,
  天和: 13,
  地和: 13,
};
const selectedHands = ref([]);

type ScoreTable = {
  [key: number]: {
    三麻: {
      ツモ: number | { min: number; max: number };
      ロン: number;
    };
    四麻: {
      ツモ: number | { min: number; max: number };
      ロン: number;
    };
  };
};
const children: ScoreTable = {
  1: {
    三麻: {
      ツモ: {
        min: 500,
        max: 700,
      },
      ロン: 1000,
    },
    四麻: {
      ツモ: {
        min: 300,
        max: 500,
      },
      ロン: 1000,
    },
  },
  2: {
    三麻: {
      ツモ: {
        min: 800,
        max: 1300,
      },
      ロン: 2000,
    },
    四麻: {
      ツモ: {
        min: 500,
        max: 1000,
      },
      ロン: 2000,
    },
  },
  3: {
    三麻: {
      ツモ: {
        min: 1500,
        max: 2500,
      },
      ロン: 3900,
    },
    四麻: {
      ツモ: {
        min: 1000,
        max: 2000,
      },
      ロン: 3900,
    },
  },
  4: {
    三麻: {
      ツモ: {
        min: 3000,
        max: 5000,
      },
      ロン: 8000,
    },
    四麻: {
      ツモ: {
        min: 2000,
        max: 4000,
      },
      ロン: 8000,
    },
  },
  6: {
    三麻: {
      ツモ: {
        min: 4500,
        max: 7500,
      },
      ロン: 12000,
    },
    四麻: {
      ツモ: {
        min: 3000,
        max: 6000,
      },
      ロン: 12000,
    },
  },
  8: {
    三麻: {
      ツモ: {
        min: 6000,
        max: 10000,
      },
      ロン: 16000,
    },
    四麻: {
      ツモ: {
        min: 4000,
        max: 8000,
      },
      ロン: 16000,
    },
  },
  11: {
    三麻: {
      ツモ: {
        min: 9000,
        max: 15000,
      },
      ロン: 24000,
    },
    四麻: {
      ツモ: {
        min: 6000,
        max: 12000,
      },
      ロン: 2000,
    },
  },
  13: {
    三麻: {
      ツモ: {
        min: 12000,
        max: 20000,
      },
      ロン: 32000,
    },
    四麻: {
      ツモ: {
        min: 8000,
        max: 16000,
      },
      ロン: 32000,
    },
  },
};
const parent: ScoreTable = {
  1: {
    三麻: {
      ツモ: 800,
      ロン: 1500,
    },
    四麻: {
      ツモ: 500,
      ロン: 1500,
    },
  },
  2: {
    三麻: {
      ツモ: 1500,
      ロン: 2900,
    },
    四麻: {
      ツモ: 1000,
      ロン: 3000,
    },
  },
  3: {
    三麻: {
      ツモ: 3000,
      ロン: 5800,
    },
    四麻: {
      ツモ: 2000,
      ロン: 5800,
    },
  },
  4: {
    三麻: {
      ツモ: 6000,
      ロン: 12000,
    },
    四麻: {
      ツモ: 4000,
      ロン: 12000,
    },
  },
  6: {
    三麻: {
      ツモ: 9000,
      ロン: 18000,
    },
    四麻: {
      ツモ: 6000,
      ロン: 18000,
    },
  },
  8: {
    三麻: {
      ツモ: 12000,
      ロン: 24000,
    },
    四麻: {
      ツモ: 8000,
      ロン: 24000,
    },
  },
  11: {
    三麻: {
      ツモ: 18000,
      ロン: 36000,
    },
    四麻: {
      ツモ: 12000,
      ロン: 36000,
    },
  },
  13: {
    三麻: {
      ツモ: 24000,
      ロン: 48000,
    },
    四麻: {
      ツモ: 16000,
      ロン: 48000,
    },
  },
};

type Flag = {
  親: boolean;
  人数: "三麻" | "四麻";
  和了: "ツモ" | "ロン";
  本場: 0;
};
const flags = ref(<Flag>{});
```
</details>
    

今回役の判定を行わず選択式にしたので、`[key: string]: number` 形式のhandsというオブジェクトとして手役を管理しました。

ここらへんのキーが日本語になっているのは、~~各役の変数名を考えるのがめんどくさかった~~記事を読んでくれた方が分かりやすいようにするためです。

次に`children`と`parent`についてですが、これはいわゆる点数表を落とし込んだものになります。childrenが子が和了ったとき、parentが親が和了ったときの点数表ですね。

加えて、ツモ or ロン/三麻 or 四麻でも変わるので、そこもオブジェクトの構造として持たせています。

本来は20符 ~ 110符で点数が変わるのでもっと細かい定義になるのですが、今回は30符固定なのでこの程度で済んでます。

### 点数計算

<details open><summary>コード</summary>

```ts
// 点数計算
const double = ref(0);
const result = ref("");
const calculate = () => {
  // 翻数
  double.value = selectedHands.value.reduce((prev, current) => {
    prev += hands[current];
    return prev;
  }, 0);

  // 素点の計算
  const table = flags.value["親"] ? parent : children;
  const scoreIndex = Object.keys(table).reduce((prev, current) => {
    // 和了った翻数と点数表を比較し、該当する翻数を判定
    if (double.value >= Number(current)) {
      const abs = Math.abs(double.value - Number(current));
      const prevAbs = Math.abs(double.value - prev);
      prev = abs !== prevAbs ? (Math.min(abs, prevAbs) === abs ? Number(current) : prev) : prev;
    }
    return prev;
  }, 99);
  const scores = table[scoreIndex];
  const score = scores[flags.value["人数"]][flags.value["和了"]];

  // 本場の計算
  // ロン和了の場合、四麻: 1本場 = 300点, 三麻: 1本場 = 1000点
  // ツモ和了の場合、四麻: 1本場 = 100点, 三麻: 1本場 = 500点
  //（三麻の場数による点数はルールによってかなり違うので、今回は適当に1000点 or 500点としました）
  const isThird = flags.value["人数"] === "三麻";
  const isRon = flags.value["和了"] === "ロン";
  const roundBase = isRon ? (isThird ? 1000 : 300) : isThird ? 500 : 100;
  const roundPoint = flags.value["本場"] * roundBase;

  // 計算結果出力
  const all = isRon ? "" : " all";
  result.value =
    typeof score === "number" ? `${score + roundPoint}${all}` : `${score.min + roundPoint} / ${score.max + roundPoint}`;
};
```
</details>


今回の肝となる部分です。

といっても役の判定や符計算がない分かなり簡略化できています。

コードに書いてある通りですが、やっていることは

1. 翻数の計算
2. 素点の計算
3. 場数の計算
4. 結果出力

のみとなります。
この中だと一番ややこしいのが素点計算ですね。

reduceを使っているのでかなりすっきりしていますが、やっていることを言語化すると「**和了った翻数と点数表を比較して一番近い点数を当てはめる**」という処理になります。

もっと直感的に書くなら、比較した差の絶対値が一番小さい値を割り出すということをしています。

:bulb: [reduce](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce)

ただ、これだけだと例えば5翻で和了った場合、点数表の4翻と6翻どちらも差の絶対値が1なので6翻の点数をあてはめてしまいます。

それを避けるため、参照している点数表 > 和了った翻数の場合は判定をスキップしています。

:::note alert
※僕が怠慢でオブジェクトのキーを日本語にしたせいでここらへんのオブジェクトへのアクセスが大変なことになっています :rolling_eyes: 
フロントの表示に使う値でそのまま欲しい値にアクセスできるので実装する分にはかなり楽でしたが、もしこのコードをレビューしてくださいと言われたら発狂する自信がありますので、基本的にオブジェクトのキーには一般的なものを用いるようにしましょう
:::

### 表示部分

- コード
    
    ```html
    <template>
      <div class="flex flex-col justify-center items-end w-fit h-screen gap-y-10 gap-x-8 mx-auto">
        <div class="flex items-center gap-center w-full gap-4">
          <details class="dropdown">
            <summary class="btn m-1">役を選択</summary>
            <ul class="dropdown-content menu z-[1] h-64 w-max rounded-2xl bg-base-100 p-2 shadow">
              <li v-for="(_, hand) in hands" :id="hand">
                <label class="label cursor-pointer shrink-0">
                  <input :value="hand" v-model="selectedHands" type="checkbox" class="checkbox-accent checkbox" />
                  <span class="label-text mx-5 text-nowrap">{{ hand }}</span>
                </label>
              </li>
            </ul>
          </details>
          <label class="label cursor-pointer shrink-0">
            <span class="label-text mx-5 text-nowrap">親</span>
            <input v-model="flags['親']" type="checkbox" class="checkbox-accent checkbox" />
          </label>
          <label class="label cursor-pointer shrink-0">
            <span class="label-text mr-5 text-nowrap">人数</span>
            <select v-model="flags['人数']" class="select select-bordered w-full max-w-xs">
              <option selected>三麻</option>
              <option>四麻</option>
            </select>
          </label>
          <label class="label cursor-pointer shrink-0">
            <span class="label-text mr-5 text-nowrap">和了</span>
            <select v-model="flags['和了']" class="select select-bordered w-full max-w-xs">
              <option selected>ツモ</option>
              <option>ロン</option>
            </select>
          </label>
          <label class="label cursor-pointer shrink-0">
            <span class="label-text mr-5 text-nowrap">本場</span>
            <input
              v-model="flags['本場']"
              type="text"
              placeholder="本場数を入力"
              class="input input-bordered w-full max-w-xs"
            />
          </label>
        </div>
        <div class="flex justify-end items-center w-full gap-10">
          <p v-if="result" class="text-3xl font-semibold">計算結果：{{ double }}翻, {{ result }}</p>
          <div class="flex items-center justify-end gap-10">
            <p class="font-semibold">{{ selectedHands.join(", ") || "役を選択してください" }}</p>
            <button class="btn btn-accent text-white" @click="calculate">計算</button>
          </div>
        </div>
      </div>
    </template>
    ```
    

正直ここに関してはtailwindとdaisyUIに頼りきりなので特筆することはないです 🙈

強いて言うなら手役の複数選択部分は同期する値をhandsオブジェクトの各キーにしています。

オブジェクトの値を同期してしまうと、例えば平和を選択すると1翻の役がすべて選択されてしまうためですね。

## 🀄 まとめ

今回は麻雀の点数計算を簡略化して実装してみましたが、いかがでしたでしょうか？

個人的に時間が許せば役判定の部分から作ってみたいので、次の記事では役判定部分の実装について書きたいなと思っています。
（思っているだけで実行できるかはまだわかりませんが、がんばりたいという気持ちはあります）

それでは 👋

## :punch: 一緒に二次元業界を盛り上げていきませんか
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。