TypeScriptのRecord型で麻雀の席ごとの点数を管理する

点数情報をどう持つか

麻雀のオーラス条件を判定するアプリを作る中で、4人分の点数をどのような形で管理するか考えることになった。
単純に考えると、プレイヤーを表す型を作り、その中に点数を持たせる方法が自然に見える。

type Player = {
  seat: string; //席
  score: number; //点数
};

ただ、今回最初に作っていたのは、麻雀の対局そのものではなく、
東家・南家・西家・北家の点数を入力し、現在順位や必要なアガリ点を計算するところまでだった。
そのため、まずはプレイヤー全体ではなく、点数計算に必要な情報だけをまとめることにした。

麻雀の席をユニオン型として定義する

最初に、麻雀の4つの席を表すユニオン型を作った。

export type Seat =
  | "east"
  | "south"
  | "west"
  | "north";

Seat型には、ここで指定した4つの文字列しか入れられない。

//これはOK
const seat: Seat = "east";

一方で、宣言時に存在しない席を指定するとTypeScriptがエラーにしてくれる。

//これはエラー
const seat: Seat = "dealer";

文字列をそのまま使うより、入力ミスや表記のズレを防ぎやすい。

ちなみにTypeScriptの変数宣言は

変数宣言のキーワード 変数名 : 型 = 初期値;

なお、変数宣言のキーワードには正式な名称はない模様。
しいて言うなら「キーワード」がそれらしいが、一般名詞すぎて伝わりづらいから、
変数宣言のキーワードといったほうが伝わりやすいとのこと。
var, let, constの3つで変数の扱い方を決めるもの。
いつか、こういう基礎的な部分をまとめられるといいな。

Record型で席と点数を対応させる

閑話休題。
次に4人分の点数は、Record型を使って管理している。

export type Scores = Record<Seat, number>;

Record<Seat, number>は、

  • キーがSeat
  • バリューがnumber

であるオブジェクトを表す。
実際のデータは次のような形になる。

const scores: Scores = {
  east: 32000,
  south: 24500,
  west: 23000,
  north: 20500,
};

これでSeatで定義した4つのキーが必須となるため、どれか一つでも抜けるとエラーになる。

//これはエラー
const scores: Scores = {
  east: 32000,
  south: 24500,
  west: 23000,
};

逆に、関係のないキーを追加することもできない。

//これもエラー
const scores: Scores = {
  east: 32000,
  south: 24500,
  west: 23000,
  north: 20500,
  dealer: 10000,
};

4人分の点数が必ずそろっていることを、TypeScript側で確認してくれるというわけだ。

点数を取得する

席が分かっていれば、対応する点数を簡単に取得できる。

//南家の点数を取得する
const seat: Seat = "south";
const score = scores[seat];

さっきのRecord型の定義のままであれば、結果は24500になる。
ロンやツモによる点数移動でも、同じ形でアクセスできる。

//nextScoresは点数移動後のScores型変数
nextScores[winner] += points;
nextScores[loser] -= points;

配列から対象のプレイヤーを検索する必要がなく、席をそのままキーとして使える。
点数計算だけを行う処理とは相性がいい。

元の点数を直接変更しない

Reactで状態を管理するときは、元のオブジェクトを直接変更せず、新しいオブジェクトを作る。

const nextScores: Scores = {
  ...scores,
};

...scoresによって、現在の点数をコピーしている。
その後、コピーした点数を変更する。

nextScores[loser] -= points;
nextScores[winner] += points;

最後に、新しい点数を返す。

return nextScores;

全体では、ロンは次のような処理になる。

function applyRon(
  scores: Scores, // 現在の点数
  winner: Seat, // 和了者
  loser: Seat, // 放銃者
  points: number, // 和了点
): Scores {
  // 現在の点数をコピー
  const nextScores: Scores = {
    ...scores,
  };

  // 加減点
  nextScores[loser] -= points;
  nextScores[winner] += points;

  // 計算後の点数を返す
  return nextScores;
}

元の点数を変更しないため、処理の前後を比較しやすい。
テストもしやすく、Reactの状態更新でも扱いやすい形になる。

点数から順位を計算する

順位を計算するときは、席の一覧を配列として用意している。

const seats: Seat[] = [
  "east",
  "south",
  "west",
  "north",
];

それぞれの席について、点数を取り出す。
この辺はまだ癖が強くて、自分の中でも理解しきれていない。

const rows = seats.map((seat) => ({
  seat,
  score: scores[seat],
}));

この時点では、次のような配列になる。

[
  { seat: "east", score: 32000 },
  { seat: "south", score: 24500 },
  { seat: "west", score: 23000 },
  { seat: "north", score: 20500 },
]

あとは点数が高い順に並べ替える。

rows.sort((a, b) => {
  return b.score - a.score;
});

並べ替えた後、配列の位置を使って順位を付ける。

const ranking = rows.map((row, index) => ({
  ...row,
  rank: index + 1,
}));

こうすることで、入力された点数から現在順位を計算できる。

同点の場合も考える

点数だけで並べると、同点だった場合の順番を決められない。
今回のアプリでは、同点の場合は次の順番で上位としている。

東家 → 南家 → 西家 → 北家

席の配列自体がこの順番になっているため、その位置を比較に利用できる。

function getSeatOrder(seat: Seat): number {
  return seats.indexOf(seat);
}

並べ替えでは、まず点数を比較する。

if (a.score !== b.score) {
  return b.score - a.score;
}

同点だった場合だけ、席順を比較する。

return getSeatOrder(a.seat)
  - getSeatOrder(b.seat);

これで、同じ点数でも毎回同じ順位になる。

Player型では駄目なのか

ゲームのデータとして考えると、プレイヤーの中に点数を持たせる方が自然にも見える。

type Player = {
  id: string; // ID
  seat: Seat; // 席
  score: number; // 点数
  controller: "human" | "cpu"; // プレイヤーかCPUか
};

実際に対局機能を作る場合は、こんな感じの型が必要になるはずだ。
プレイヤーには点数以外にも、

  • 人間かCPUか
  • 手牌
  • 河
  • リーチ状態
  • 鳴き
  • 名前

などの情報が紐づくためだ。
ただし、順位計算や点数移動の処理に必要なのは、基本的に席と点数だけになる。
そのため、点数計算の関数までPlayer全体を受け取る必要はない。

function calculateRanking(
  players: Player[],
) {
  // 点数計算には不要な情報も含まれる
}

今回のScoresは、プレイヤーを表す型ではない。
点数計算に使うための、席ごとの点数表として定義している。

type Scores = Record<Seat, number>;

今後、対局機能を本格的に作るなら、Player型とScores型の両方を使うことも考えられる。

type Player = {
  id: string;
  seat: Seat;
  controller: "human" | "cpu";
};

type Scores = Record<Seat, number>;

Playerは対局中のプレイヤー情報を表し、Scoresは順位や点数移動の計算に使う。
目的ごとにデータの形を分ける方が、処理の役割も分かりやすくなる。

まとめ

今回の点数管理では、次の型を使った。

type Seat =
  | "east"
  | "south"
  | "west"
  | "north";

type Scores = Record<Seat, number>;

この形にすることで、

  • 4席分の点数が必ずそろう
  • 存在しない席を指定できない
  • 席から点数を簡単に取得できる
  • 点数計算の関数を小さく保てる
  • 順位計算やテストを行いやすい

といったメリットがあった。

最初は単に4人分の数字をまとめただけだった。
ただ、型の役割を考えてみると、ゲーム全体のデータと、計算に必要なデータは分けた方が扱いやすい場合もあるらしい。
まだ設計の正解が完全に分かっているわけではないけれど、少なくとも今回のような点数計算には、Record<Seat, number>は使いやすい形だった。

今回は、一処理の大まかなつくりを記事にしたが、
上述の通り、細かいところまで理解が及んでいない。
しばらくは「こういう機能はこんな感じでできました」といった内容になるかもしれないが、
いずれはChatGPTに頼らずとも自力で書いたり、デバッグしたりするくらい理解を深めていき、
それを記事としてアウトプットできるようになりたいところ。

牛一。

コメント

タイトルとURLをコピーしました