点数情報をどう持つか
麻雀のオーラス条件を判定するアプリを作る中で、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に頼らずとも自力で書いたり、デバッグしたりするくらい理解を深めていき、
それを記事としてアウトプットできるようになりたいところ。
牛一。

コメント