29
13

【Rust】カードゲームにハマったので大富豪を作った話

Posted at

はじめに

最近、Slay the Spireドミニオン等のカードゲームにハマっています。
毎回手に入るカードが違うためプレイする毎に戦略が変わります。
空き時間にちょっと遊ぶつもりでも熱中してしまうくらい面白いです。

デジタルカードゲームはデッキのシャッフルやカードの配布を自動で行ってくれるので便利ですね。
遊んでいて自分でもカードゲームを作りたくなりました。
といっても上記の様なゲームは複雑すぎるので、トランプの定番である大富豪を作ることにしました。

プログラミング言語は個人的に好きなRustを使いました。🦀

成果物

ターミナル上で動作する大富豪を作りました。
場に出すカードの番号を入力します。
カードの番号(XX):XXには場に出されたカードが表示されます。
パスする場合は何も入力せずEnterキーを押します。

画面上には以下の情報が表示されます。
プレイヤー名 [手札の枚数]: 場に出したカード

demo.gif

ルール

大富豪はローカルルールを含め様々なルールがあります。
今回は以下のルールに絞って実装しました。

  • プレイヤーは4人で固定
  • 2、8、ジョーカーで上がってはいけない(反則上がり)
  • 8を出すと強制的に場が流れる(8切り)
  • 同じスートのカードが2回連続で出た場合は、そのスートのカードしか出せなくなる(縛り)

トランプ

スートと数字

スートと数字は列挙体で定義します。
比較やコピーができるように各種アトリビュートを実装します。
また、各値は弱い順に並んでいます。

#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Suit {
    Club,
    Diamond,
    Heart,
    Spade,
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Rank {
    Three,
    Four,
    Five,
    Six,
    Seven,
    Eight,
    Nine,
    Ten,
    Jack,
    Queen,
    King,
    Ace,
    Two,
}

カードの定義

カードも列挙体で定義します。
カードには以下の2種類があります。

  • 数字とスート持つ通常のカード
  • 数字とスートを持たないジョーカーのカード
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Card {
    Normal(Suit, Rank),
    Joker,
}

Rustの列挙体は値によって異なるデータ型を持たせることができます。

カードの比較関数

次に2つのカードの大きさを比較する関数を作ります。
Cardのベクタをソートしたり、カードを場に出せるかを判定する時に使います。
必要な比較関数は以下の通りです。

  • 数字、スート順で比較する関数
  • 数字のみで比較する関数
  • 数字のみで比較する関数(革命版)

比較関数は2つのCardの参照を受け取り、以下の条件でstd::cmp::Ordering列挙体を返します。

  • カード1 < カード2 ⇒ std::cmp::Ordering::Less
  • カード1 == カード2 ⇒ std::cmp::Ordering::Equal
  • カード1 > カード2 ⇒ std::cmp::Ordering::Greater

数字、スート順で比較

この関数は配られた手札をソートする際に使用します。

pub fn cmp_order(c1: &Card, c2: &Card) -> std::cmp::Ordering {
    match (c1, c2) {
        (Card::Normal(s1, r1), Card::Normal(s2, r2)) => r1.cmp(r2).then(s1.cmp(s2)),
        (_, _) => c1.cmp(c2),
    }
}

通常のカード同士の場合は数字、スート順で比較します。
少なくとも一方のカードがジョーカーの場合は、数字やスートを見ずにCardの値で比較します。

// 以下の様ににソートされる
// [♠7, Joker, ♣3, ♥7]
// ↓
// [♣3, ♥7, ♠7, Joker]
let mut cards = vec![Card::Normal(Suit::Spade, Rank::Seven)Card::Joker, Card::Normal(Suit::Club, Rank::Three), Card::Normal(Suit::Heart, Rank::Seven)];
cards.sort_by(cmp_order);
assert_eq!(cards, vec![Card::Normal(Suit::Club, Rank::Three), Card::Normal(Suit::Heart, Rank::Seven), Card::Normal(Suit::Spade, Rank::Seven), Card::Joker]);

数字のみ比較

上記の関数では同じ数字のカードを比較しても♣ < ♦ < ♥ < ♠の順で大小関係を判定します。
なので数字のみを比較する関数も定義します。
場に出ているカードと手札のカードの大小関係は主にこの関数で判定します。

pub fn cmp_rank(c1: &Card, c2: &Card) -> std::cmp::Ordering {
    match (c1, c2) {
        (Card::Normal(_, r1), Card::Normal(_, r2)) => r1.cmp(r2),
        (_, _) => c1.cmp(c2),
    }
}

数字のみでソートする関数(革命版)

大富豪では同じ数字のカードを4枚以上出すとジョーカー以外のカードの強さが逆転します。(革命)
この「ジョーカー以外の」というのが意外と曲者です。
この例外のせいでcmp_rank関数の使いまわしができません。

// これだとジョーカーが最弱になってしまう
pub fn cmp_rank_reversely(c1: &Card, c2: &Card) -> std::cmp::Ordering {
    cmp_rank(c2, c1)
}

そのため、逆転した際のロジックを書く必要があります。
通常のカード同士の場合は逆順で比較し、どちらかがジョーカーの場合はcmp_rankと同じように比較します。

pub fn cmp_rank_reversely(c1: &Card, c2: &Card) -> std::cmp::Ordering {
    match (c1, c2) {
        (Card::Normal(_, r1), Card::Normal(_, r2)) => r2.cmp(r1),
        (_, _) => c1.cmp(c2),
    }
}

組み合わせ

場に出せるカードの組み合わせは大きく分けて以下の3通りです。

  • カード1枚
  • 数字が同じ2枚以上のカード
  • 同じスートで数字が連続している3枚以上のカード(階段)

上記の組み合わせも列挙体で定義します。

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Comb {
    Single(Card), // カード1枚
    Multi(Vec<Card>), // 数字が同じ2枚以上のカード
    Seq(Vec<Card>), // 同じスートで数字が連続している3枚以上のカード
}

Card ⇒ Comb::Singleへの変換

CardからComb::Singleへの変換処理は簡単です。

// 1枚のカードからComb::Singleへ変換できるようにする ※例: ♥10
let cmb = Comb::Single(Card::Normal(Suit::Heart, Rank::Ten));

Vec<Card> ⇒ Comb::Multiへの変換

Comb::MultiComb::Seqの場合は、変換処理を実装します。
また、枚数が複数枚の場合は無効な組み合わせが渡されることを考慮する必要があります。
そのため、変換に成功したらOk、失敗したらErrを返すTryFromトレイトを実装します。

impl TryFrom<Vec<Card>> for Comb {
    type Error = ();

    fn try_from(mut cards: Vec<Card>) -> Result<Self, Self::Error> {
        let len = cards.len();
        if len <= 1 {
            return Err(());
        }
        if is_same_ranks(&cards) {
            return Ok(Comb::Multi(cards));
        }
        Err(())
    }
}

全てのカードが同じ数字か判定するis_same_ranks関数にカードのベクタの参照を渡してtrueならばOk(Comb::Multi)を返します。
カードが1枚の場合や判定がfalseの場合はErrを返します。

is_same_ranks関数

is_same_ranks関数の判定ロジックは以下の通りです。

  1. ジョーカーを削除する
  2. 各カードの数字を抽出する
  3. 隣り合う数字同士を比較して全て同じか調べる

例えばis_same_ranks関数に♠6、Joker、♥6、♦6を渡すと

  1. ♠6、♥6、♦6
  2. 6、6、6
  3. 6 == 6 == 6

となりtrueを返します。

コードは以下の様になります。
ジョーカーの削除と数字の抽出はfilter_map関数で一度に行っています。
tuple_windows関数で隣り合う要素のタプルを取得しています。
これはitertoolsという外部クレートの関数です。

// 全てのカードが同じ数字か判定する
fn is_same_ranks(cards: &[Card]) -> bool {
    cards
        .iter()
        .filter_map(|c| match c {
            Card::Normal(_, r) => Some(r),
            Card::Joker => None,
        })
        .tuple_windows()
        .all(|(r1, r2)| r1 == r2)
}
// 数字が同じ2枚以上のカードならComb::Multiを返す ※例[♦Q ♣Q]
let cards = vec![Card::Normal(Suit::Diamond, Rank::Queen), Card::Normal(Suit::Club, Rank::Queen)];
let cmb2 = Comb::try_from(cards);
assert_eq!(cmb2, Ok(Comb::Multi(...)));

// 無効な組み合わせならばエラーを返す ※例[♠3、♦5]
let cmb4 = Comb::try_from(vec![Card::Normal(Suit::Spade, Rank::Three), Card::Normal(Suit::Spade, Rank::Five)]);
assert_eq!(cmb3, Err());

Vec<Card> ⇒ Comb::Seqへの変換

Comb::Seqへの変換処理を上記のtry_from関数内に追加します。
以下の条件を全て満たした場合にOk(Comb::Seq(..))を返します。

  • カードの枚数が3枚以上
  • 全てのカードのスートが同じ(後述するis_same_suits関数で判定)
  • カードが連続して並んでいる(後述するis_seq関数で判定)
impl TryFrom<Vec<Card>> for Comb {
    type Error = ();

    fn try_from(mut cards: Vec<Card>) -> Result<Self, Self::Error> {
        ...
        if len >= 3 && is_same_suits(&cards) && is_seq(&cards) {
            return Ok(Comb::Seq(cards));
        }
        Err(())
    }
}

is_same_suits関数

is_same_suits関数の判定ロジックはis_same_ranksとほぼ同じです。
抽出する値が数字からスートに変わっただけです。

// 全てのカードが同じスートか判定する
fn is_same_suits(cards: &[Card]) -> bool {
    cards
        .iter()
        .filter_map(|c| match c {
            Card::Normal(s, _) => Some(s),
            Card::Joker => None,
        })
        .tuple_windows()
        .all(|(v1, v2)| v1 == v2)
}

is_seq関数

「カードが連続して並んでいるか」の判定は少し複雑です。
ジョーカーを含む場合と含まない場合でロジックが変わるので順番に説明します。

ジョーカーを含まない場合

まずは比較的簡単なジョーカーを含まない場合の判定ロジックです。

  1. カードの数値を抽出する
  2. 隣り合う数字同士の差分を計算する
  3. 全ての差分が1または-1か調べる

例えば♦3、♦4、♦5、♦6の場合

  1. 3、4、5、6
  2. 1、1、1
  3. 1 == 1 == 1

となりtrueを返します。

Rankは列挙体なので差分を求めることはできません。
なのでRankを整数型に変換するトレイトを実装します。

impl From<Rank> for i32 {
    fn from(r: Rank) -> Self {
        match r {
            Rank::Three => 0,
            Rank::Four => 1,
            ...
            Rank::Ace => 11,
            Rank::Two => 12,
        }
    }
}

Rustで上記のロジックを実装すると以下の様になります。
差分を重複を排除するHashSetに入れます。
その後、要素を取得しやすくするためベクタに変換します。
このベクタの要素が1でその値の絶対値が1の場合、カードが連続して並んでいることが分かります。

            let diffs = cards
                .iter()
                .filter_map(|c| match c {
                    // カードの数字をi32に変換
                    Card::Normal(_, r) => Some(i32::from(*r)),
                    _ => None,
                })
                .tuple_windows()
                .map(|(v1, v2)| v2 - v1) // 隣同士の数字の差分を計算する
                .collect::<HashSet<i32>>() // 差分の重複を排除する
                .into_iter()
                .collect::<Vec<i32>>();
            return (diffs.len() == 1) && (diffs[0].abs() == 1);
ジョーカーを含む場合

ジョーカーを含む場合、ジョーカーを適切な数字に変換する必要があります。
ジョーカーの位置によって見るべきカードが変わります。

先頭にある場合は、後続の2枚から適切な数字を判断します。
Joker、♣7、♣86、7、8
Joker、♣7、♣68、7、6
末尾にある場合は、その前の2枚から適切な数字を判断します。
♥4、♥5、Joker4、5、6
♥6、♥5、Joker6、5、4
それ以外の場合は、前後の2枚から適切な数字を判断します。
♠Q、Joker、♠AQ、K、A
♠A、Joker、♠QA、K、Q

Rustで上記のロジックを実装すると以下の様になります。
ジョーカーの置き換え処理が追加されている以外は、含まない場合の処理とほとんど同じです。

            let mut nums: Vec<Option<i32>> = cards
                .iter()
                .map(|c| match c {
                    // カードの数字をi32に変換
                    Card::Normal(_, r) => Some(i32::from(*r)),
                    Card::Joker => None,
                })
                .collect();
            // ジョーカーを数字に置き換える
            match idx {
                _ if idx == 0 => {
                    let x = *nums[idx + 1].as_ref().unwrap();
                    let y = *nums[idx + 2].as_ref().unwrap();
                    nums[idx] = Some(2 * x - y);
                }
                _ if idx == nums.len() - 1 => {
                    let x = *nums[idx - 2].as_ref().unwrap();
                    let y = *nums[idx - 1].as_ref().unwrap();
                    nums[idx] = Some(2 * y - x);
                }
                _ => {
                    let v1 = *nums[idx - 1].as_ref().unwrap();
                    let v2 = *nums[idx + 1].as_ref().unwrap();
                    nums[idx] = Some(((v1 + v2) / 2) as i32)
                }
            };
            let diffs = nums
                .into_iter()
                .filter_map(|v| v)
                .tuple_windows()
                .map(|(v1, v2)| v2 - v1) // 隣同士の数字の差分を計算する
                .collect::<HashSet<i32>>() // 差分の重複を排除する
                .into_iter()
                .collect::<Vec<i32>>();
            return diffs.len() == 1 && (diffs[0].abs() == 1);

以下にis_seq関数全体のコードを載せます。

is_seq関数
// カードの数字が連続しているか判定する
fn is_seq(cards: &[Card]) -> bool {
    if cards.len() <= 2 {
        return false;
    }
    let joker_idx = cards.iter().position(|c| matches!(*c, Card::Joker));
    match joker_idx {
        // ジョーカーを含む
        Some(idx) => {
            let mut nums: Vec<Option<i32>> = cards
                .iter()
                .map(|c| match c {
                    // カードの数字をi32に変換
                    Card::Normal(_, r) => Some(i32::from(*r)),
                    Card::Joker => None,
                })
                .collect();
            // ジョーカーを数字に置き換える
            match idx {
                _ if idx == 0 => {
                    let x = *nums[idx + 1].as_ref().unwrap();
                    let y = *nums[idx + 2].as_ref().unwrap();
                    nums[idx] = Some(2 * x - y);
                }
                _ if idx == nums.len() - 1 => {
                    let x = *nums[idx - 2].as_ref().unwrap();
                    let y = *nums[idx - 1].as_ref().unwrap();
                    nums[idx] = Some(2 * y - x);
                }
                _ => {
                    let v1 = *nums[idx - 1].as_ref().unwrap();
                    let v2 = *nums[idx + 1].as_ref().unwrap();
                    nums[idx] = Some(((v1 + v2) / 2) as i32)
                }
            };
            let diffs = nums
                .into_iter()
                .filter_map(|v| v)
                .tuple_windows()
                .map(|(v1, v2)| v1 - v2) // 隣同士の数値の差分を計算する
                .collect::<HashSet<i32>>() // 差分の重複を排除する
                .into_iter()
                .collect::<Vec<i32>>();
            return diffs.len() == 1 && (diffs[0].abs() == 1);
        }
        // ジョーカーなし
        None => {
            // カードから数字を抽出する
            let diffs = cards
                .iter()
                .filter_map(|c| match c {
                    // カードの数値をi32に変換
                    Card::Normal(_, r) => Some(i32::from(*r)),
                    Card::Joker => None,
                })
                .tuple_windows()
                .map(|(v1, v2)| v1 - v2) // 隣同士の数値の差分を計算する
                .collect::<HashSet<i32>>() // 差分の重複を排除する
                .into_iter()
                .collect::<Vec<i32>>();
            return diffs.len() == 1 && (diffs[0].abs() == 1);
        }
    }
}

Combの比較

Comb同士を比較するis_greater関数を実装します。
これは比較対象のCombより数字が強ければtrueを返す関数です。
引数に比較対象のCombの参照と比較関数を取ります。

この比較関数を通常時と革命時で変えることで、カードの強さが変わります。

// ♣5、♥5、♠5
let comb1 = Comb::try_from(vec![
    Card::Normal(Suit::Club, Rank::Five),
    Card::Normal(Suit::Heart, Rank::Five),
    Card::Normal(Suit::Spade, Rank::Five)
]).unwrap();
// ♣3、♥3、♠3
let comb2 = Comb::try_from(vec![
    Card::Normal(Suit::Club, Rank::Three),
    Card::Normal(Suit::Heart, Rank::Three),
    Card::Normal(Suit::Spade, Rank::Three)
]).unwrap();
// 通常時は5の方が強い
assert_eq!(comb1.is_greater(&comb2, cmp_rank), true);
// 革命時は3の方が強い
assert_eq!(comb1.is_greater(&comb2, cmp_rank_reversely), false);

is_greater関数内ではCombが持つCardを順番に比較して全て大きいかを調べています。
カードのどちらかがジョーカーの場合は比較をせずtrueを返しています。

impl Comb {
    pub fn is_greater<F>(&self, comb: &Comb, comparator: F) -> bool
    where
        F: Fn(&Card, &Card) -> Ordering,
    {
        match (self, comb) {
            (Comb::Single(card1), Comb::Single(card2)) => {
                comparator(card1, card2) == Ordering::Greater
            }
            (Comb::Multi(cards1), Comb::Multi(cards2)) | (Comb::Seq(cards1), Comb::Seq(cards2)) => {
                // カードの枚数が同じか
                if cards1.len() != cards2.len() {
                    return false;
                }
                // cards1の全てのカードがcards2のカードより大きいか
                cards1
                    .iter()
                    .zip(cards2.iter())
                    .all(|(c1, c2)| match (c1, c2) {
                        (Card::Normal(_, _), Card::Normal(_, _)) => {
                            comparator(c1, c2) == Ordering::Greater
                        }
                        // どちらかのカードがジョーカーならtrue
                        (_, _) => true,
                    })
            }
            (_, _) => false,
        }
    }
}

Validatorトレイト

「カードを場に出せるか」を判定する処理を実装します。
大富豪にはスート縛りや階段しばりといったルールがあるため、数字が強いだけで場に出せるとは限りません。
そういった制約を内部で管理して場に出せるかの判定をするのがValidatorトレイトです。

pub trait Validator {
    fn get_prev_comb(&self) -> Option<&Comb>;
    fn is_valid(&self, comb: &Comb) -> bool;
}

Validatorトレイには以下の2つの関数があります。

  • get_prev_cob
    直前に場に出た組み合わせを返す関数です。
  • is_valid
    組み合わせが場に出せるかを判定する関数です。

大富豪のプレーヤーは、Validatorトレイトで場に出せる組み合わせを見つけます。

フィールド

上記のValidatorトレイトを実装しているのが、場の状態を管理するField構造体です。
Fieldは主に以下の状態を管理しています。

  • 直前に場に出されたカード
  • パスの回数
  • プレイヤーの順番や順位
  • 縛り状況

Indexerはプレイヤーの順番と順位を管理し、SuitBinderは縛りの状態を管理する構造体です。
Fieldだけで全てを管理するのは責任過剰なので、別の構造体に機能を委譲しています。

pub struct Field {
    prev_comb: Option<Comb>,
    indexer: Indexer,
    binder: SuitBinder,
    pass_counter: usize,
    is_rev: bool,
}

put関数

カードを場に出す処理を担うのがput関数です。
引数に場に出す組み合わせと残りの手札枚数を渡します。
手札の枚数が0になったらそのプレイヤーは上がりとなります。
パスをする場合はNoneを渡します。

場に出されたカードよって様々なイベントが発生します。(革命、8切りなど)
返り値に発生したイベントのフラグを返します。

impl Field {
    ...
    pub fn put(&mut self, new_comb: Option<Comb>, hands_count: usize) -> Flags {
    }

革命

is_rev_comb関数は4枚のカードを持つComb::Multiか判定する関数です。
返り値のフラグにFlags::REVを付与します。

...
    pub fn put(&mut self, new_comb: Option<Comb>, hands_count: usize) -> Flags {
        let mut flags = Flags::empty();
        match new_comb {
            Some(comb) => {
                ...
                if is_rev_comb(&comb) {
                    // カードの強さが逆転する
                    self.is_rev = !self.is_rev;
                    flags.insert(Flags::REV);
                }

8切り

contains_eightは組み合わせに8のカードが含まれているか判定する関数です。
8のカードが含まれている場合は、次のプレイヤーにターンを進めず場を初期化します。
self.prev_combNoneを設定することで場が流れ、同じプレイヤーが続けて任意のカードを出せます。

    pub fn put(&mut self, new_comb: Option<Comb>, hands_count: usize) -> Flags {
        let mut flags = Flags::empty();
        match new_comb {
            Some(comb) => {
                ...
                let eight_flag = contains_eight(&comb);
                if hands_count > 0 {
                    if eight_flag {
                        // 8切り
                        flags.insert(Flags::EIGHT);
                        self.binder.clear();
                    } else {
                        // 次のプレイヤーのターンに移る
                        self.indexer.next();
                    }
                }
                ...
                // 8を含むなら場を流す
                self.prev_comb = if eight_flag { None } else { Some(comb) }

反則上がり

contains_especial_card関数は反則上がりになるカードが含まれているか判定する関数です。
反則上がりの場合、現在のプレイヤーを下位の順位に設定し、フラグにFlags::LOSEを付与します。

...
    pub fn put(&mut self, new_comb: Option<Comb>, hands_count: usize) -> Flags {
        ...
            Some(comb) => {
                ...
                } else if contains_especial_card(&comb, self.is_rev) {
                    // 禁止上がり
                    self.indexer.set_rank_back();
                    flags.insert(Flags::LOSE);
                }
                ...

Validatorトレイトの実装

ValidatorトレイトをFieldに実装します。

適切なスートでかつ場に出ているカードより強い場合にtrueを返します。
カードが場にない場合は常にtrueを返します。

impl Validator for Field {
    fn get_prev_comb(&self) -> Option<&Comb> {
        self.prev_comb.as_ref()
    }

    fn is_valid(&self, comb: &Comb) -> bool {
        match &self.prev_comb {
            Some(prev_comb) => {
                let comparator = match self.is_rev {
                    true => cmp_rank_reversely,
                    false => cmp_rank,
                };
                self.binder.is_valid(comb) && comb.is_greater(prev_comb, comparator)
            }
            None => true,
        }
    }
}

プレイヤー

大富豪のプレイヤーが実装するPlayerトレイトを定義します。

pub trait Player {
    fn init(&mut self, hands: Vec<Card>);
    fn count_hands(&self) -> usize;
    fn get_name(&self) -> &str;
    fn get_hands(&mut self) -> &mut Vec<Card>;
    fn play(&mut self, validator: &dyn Validator) -> Option<Comb>;
    fn get_needless_cards(&mut self, cards_count: usize) -> Vec<Card>;
}

Playerトレイトの関数は以下の通りです。

  • init
    手札を初期化する関数です。
    引数にカードのベクタを取ります。

  • count_hands
    現在の手札の枚数を返す関数です。

  • get_name
    プレイヤーの名前を返す関数です。

  • get_hands
    手札への可変参照を返す関数です。

  • play
    Validatorトレイトを実装したオブジェクトを受け取り、場に出す組み合わせを返す関数です。
    パスする場合はNoneを返します。

  • get_needless_cards
    カード交換時に不要なカードのベクタを返す関数です。
    引数に交換するカードの枚数を渡します。

大富豪に参加するプレイヤーはコンピュータの場合も人間の場合も
上記のPlayerトレイトを必ず実装します。

NPC

まずNPC(Non Playable Character)を実装します。
出せる組み合わせの中から最弱のものを場に出す単純なプレイヤーです。
最弱の組み合わせを見つける方法はCombの種類によって異なります。

Comb::Singleが場に出ている場合

手札のカードを弱い順に調べていき、場のカードより強いものがあればそれを返します。

impl Player for MinNpc {
    ...
    fn play(&mut self, validator: &dyn Validator) -> Option<Comb> {
        match validator.get_prev_comb() {
            Some(comb) => match comb {
                Comb::Single(_) => {
                    // 場に出せる最小のカードのインデックスを探す
                    (0..self.hands.len()).find_map(|i| {
                        let new_comb = Comb::Single(self.hands[i]);
                        validator.is_valid(&new_comb).then(|| {
                            self.hands.remove(i);
                            new_comb
                        })
                    })
                }
                ...

Comb::Multiが場に出ている場合

カードを数字ごとにグループ分けして、その中で場に出せる最弱の組み合わせを調べます。

例えば場に♦5 ♠5があり、手札が♥6 ♣7 ♥7 ♦8 ♥8 ♠8である場合
数字ごとにグループ分けすると[♥6] [♣7 ♥7] [♦8 ♥8 ♠8]となり
場に出せる最弱の組み合わせである♣7 ♥7が見つかります。

実際はget_indices_grouped_by_rank関数で数字ごとにグループ分けしたカードのインデックスを取得します。

impl Player for MinNpc {
    ...
    fn play(&mut self, validator: &dyn Validator) -> Option<Comb> {
        match validator.get_prev_comb() {
            Some(comb) => match comb {
                ...
                Comb::Multi(cards) => {
                    let len = cards.len();
                    get_indices_grouped_by_rank(&self.hands, len)
                        .into_iter()
                        .find_map(|indices| {
                            // 場に出せる最小のカードの組み合わせを探す
                            let cards = get_cards(&self.hands, &indices[0..len]);
                            let new_comb = Comb::try_from(cards).ok()?;
                            validator.is_valid(&new_comb).then(|| {
                                self.remove_hands(&indices[0..len]);
                                new_comb
                            })
                        })
                }
                ...

Comb::Seqが場に出ている場合

カードをスートごとにグループ分けして、その中で場に出せる最弱の組み合わせを調べます。

例えば場に♣4 ♣5 ♣6があり、手札が♥4 ♣7 ♥7 ♦8 ♥8 ♣8 ♥9である場合
数字ごとにグループ分けすると[♣7 ♣8] [♦8] [♥4 ♥7 ♥8 ♥9]となり
場に出せる最弱の組み合わせである♥7 ♥8 ♥9が見つかります。

実際はget_indices_grouped_by_suit関数でスートごとにグループ分けした
カードのインデックスを取得して、find_seq関数でグループ分けしたカードの中から
階段となっているカードの組み合わせを見つけます。

impl Player for MinNpc {
    ...
    fn play(&mut self, validator: &dyn Validator) -> Option<Comb> {
        match validator.get_prev_comb() {
            Some(comb) => match comb {
                ...
                Comb::Seq(cards) => {
                    let len = cards.len();
                    get_indices_grouped_by_suit(&self.hands, len)
                        .into_iter()
                        .find_map(|indices| {
                            // 場に出せる最小のカードの組み合わせを探す
                            let (new_comb, indices) = find_seq(&self.hands, &indices, len)?;
                            validator.is_valid(&new_comb).then(|| {
                                self.remove_hands(&indices[0..len]);
                                new_comb
                            })
                        })
                }
                ...

場が流れた場合

場が流れて新たにカード出す際は、複数枚→階段→1枚の順で出せる組み合わせを探します。
それぞれの組み合わせの見つけ方は上記の方法とほぼ同じです。

また、カード交換の際は最弱のカードを不要なカードとして出します。

impl Player for MinNpc {
    ...
    fn get_needless_cards(&mut self, cards_count: usize) -> Vec<Card> {
        (0..cards_count).map(|_| self.hands.remove(0)).collect()
    }
    ...

PC

次に人間のプレイヤーであるPC(Playable Character)を実装します。
キーボードで出すカードのインデックスを入力します。
なので、入力された文字列をインデックスに変換する処理が必要です。

fn parse_idx(input: &str) -> Result<Vec<usize>, ()> {
    let results: Vec<_> = input.split(' ').map(|s| s.parse::<usize>()).collect();
    match results.iter().all(|r| r.is_ok()) {
        true => Ok(results.into_iter().map(|r| r.unwrap()).sorted().collect()),
        false => Err(()),
    }
}

以下の条件を全て満たした場合に手札のカードを場に出せます。

  • 入力したインデックスが全て範囲内
  • 選択したカードの組み合わせが有効
  • カードの組み合わせが場に出せる
impl Player for Pc {
    ...
    fn play(&mut self, validator: &dyn Validator) -> Option<Comb> {
        ...
        println!("{}", get_cards_with_indices(&self.hands));
        loop {
            let input = get_input(format!("カードの番号{}: ", comb_str));
            if input.is_empty() && prev_comb.is_some() {
                return None;
            }
            let result = parse_idx(&input);
            if result.is_err() {
                println!("無効な入力");
                continue;
            }
            let indices = result.unwrap();
            let cards: Vec<Option<&Card>> =
                indices.iter().map(|idx| self.hands.get(*idx)).collect();
            if cards.iter().any(|card| card.is_none()) {
                println!("無効な入力");
                continue;
            }
            let cards: Vec<Card> = cards.iter().map(|card| *card.unwrap()).collect();
            match conver_to_comb(cards) {
                Ok(comb) if validator.is_valid(&comb) => {
                    // 手札からカードを除く
                    for i in indices.iter().rev() {
                        self.hands.remove(*i);
                    }
                    return Some(comb);
                }
                _ => {
                    println!("無効な組み合わせ");
                }
            }
        }
    }
    ...

以下の様に手札のカードの一覧が表示されるので次に出すカードの番号を入力します。
パスする場合は何も入力せずにEnterキーを押します。

 0:♣️6 
 1:♥6 
 2:♠️7 
 3:♣️8 
 4:♦︎8 
 5:♣️9 
 6:♥10
 7:♠️Q
 8:♦︎K
 9:♠️K
10:♦︎2
11:♠️2
カードの番号(♦︎A) : 10

カード交換の際も同様にカードのインデックスを入力します。

impl Player for Pc {
    ...
    fn get_needless_cards(&mut self, cards_count: usize) -> Vec<Card> {
        println!("{}", get_cards_with_indices(&self.hands));
        loop {
            let input = get_input(format!("不要なカードを{}枚選択: ", cards_count));
            let result = parse_idx(&input);
            if result.is_err() {
                continue;
            }
            let indices = result.unwrap();
            if indices.len() != cards_count {
                continue;
            }
            return indices
                .into_iter()
                .rev()
                .map(|idx| self.hands.remove(idx))
                .collect();
        }
    }
    ...

おわりに

大富豪もプログラムに落とし込むと結構複雑なルールだと感じました。
ジョーカーという例外があるため、カードを扱う処理が思ってた以上に難しかったです。

また、Rustを使ったことでイテレータやトレイトについての理解も深めることができました。

参考文献

29
13
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
13