よしたかの日常

日常の事やイラスト投稿、開発している時の備忘録を書いています。

Rust でババ抜き — シャッフルと CPU 難易度設計

おはこんばんにちは!

前回 に続いて、Rustで作ったババ抜きについて書いて行こうかなと。

前回 は、引かれた位置の履歴を使ってジョーカーを隠す CPU の話を書きました。
今回は 山札の切り方CPU 難易度の設計 について。

Rust の勉強がてら作っている old_maid では、 randshuffle 一発ではなく、実際のトランプの切り方に近い処理 を組み合わせています。
さらに CPU ごとに「どの切り方を何回やるか」を変えて、難易度の個性を出しています。

▲ 配札前の Deck setup and CPU 1 a shuffle... スピナー。起家プレイヤー名が入っている状態。


なぜ “shuffle()” だけにしなかったか

Rust なら cards.shuffle(&mut rng) で十分ランダムにはできます。
でもこのプロジェクトの目的は 「ターミナルで遊べるババ抜き」 です。

  • 切っている 時間 が見える
  • CPU によって 切り方の上手さ が変わる
  • ラウンドごとに起家が変わり、誰が切るか も変わる

つまり乱数の品質より、プレイ体験としてのシャッフル を優先しました。


4 種類の切り方

シャッフル処理は src/logic/shuffle/ に分けてあります。

関数 イメージ 概要
hindu_shuffle ヒンズーシャッフル 山の上から束を落として混ぜる
riffle_shuffle リフルシャッフル 山を半分に割り、交互に落とす
deal_shuffle ディール 複数の山に配り、山の順番をシャッフル
double_cut ダブルカット だいたい 1/3 と 2/3 で切り、束の順を入れ替え

どれも「数学的に完全ランダム」より 手元で切っている動き に寄せた実装です。


ヒンズーシャッフル

山の 上(末尾) からランダムな枚数を抜き、落とした束を前に積み直します。

pub fn hindu_shuffle(cards: &mut Vec<Card>, params: &HinduParams) {
    for _ in 0..params.iterations.max(1) {
        hindu_shuffle_once(cards, params.min_chunk, params.max_chunk);
        thread::sleep(Duration::from_millis(100));
    }
}

1 回の処理では、落とす枚数に jitter(ブレ) を加えています。
毎回同じサイズの束が落ちないようにするためです。

パラメータは HinduParams で指定します。

強さ 回数 1 回の枚数(min〜max)
Beginner 5 10〜20
Medium 10 10〜15
Veteran 15 5〜10
Gambler 10 5〜20

Beginner は回数が少なく、1 回に落とす枚数も大きめ。
Veteran は回数が多く、小さな束を何度も落とす — 丁寧に混ぜる イメージです。


リフルシャッフル

山を だいたい中央 で 2 分割し、左右の山から交互(確率的)にカードを落とします。

let cut = get_center_position(cards.len());
let mut right = cards.split_off(cut);
// ...
while !left.is_empty() || !right.is_empty() {
    // 残り枚数に比例した確率で left / right を選ぶ
    let take_n = rng.gen_range(1..=chunk_cap);
    mixed.extend(pile.drain(start..));
}

get_center_positioncards_len / 2 を基準に、±10% 程度の jitter を付けた位置を返します。
真ん中ぴったりではなく、人間が切るときのブレ を再現しています。

強さ 回数 1 回に落とす上限
Beginner 2 5 枚
Medium 4 2 枚
Veteran 8 2 枚
Gambler 8 2 枚

Medium 以降は max_chunk: 2 で、1 枚〜2 枚ずつ交互に落とす 本格リフル 寄りになります。


ディールシャッフル

カードを複数の山に 順番に配り、山の並び順をシャッフルしてから連結します。

for (i, card) in left.drain(..).rev().enumerate() {
    piles[i % pile_count].push(card);
}
piles.shuffle(&mut rand::thread_rng());
強さ 回数 山の数
Beginner 1 3
Medium 2 4
Veteran 5 6
Gambler 4 5

Veteran は 6 山 × 5 回と、最も手間のかかる切り方です。


ダブルカット

パラメータはなく、1 回だけ実行するシンプルな処理です。

// だいたい N/3 と 2N/3 の位置で切る
let cut1 = (n / 3 + jitter) ...
let cut2 = (2 * n / 3 + jitter) ...
// 中央・下・上の順に積み直し
mixed.extend(pile_m);
mixed.extend(pile_r);
mixed.extend(pile_l);

リフルとは別の「位置ブレ」として、束全体の順序を入れ替えます。


切り方の組み合わせ — CPU ごとの「レパートリー」

各 CPU 戦略(CpuStrategy)の deck_shuffle で、上記 4 種を 組み合わせ ています。

戦略 ヒンズー リフル ディール ダブルカット 備考
Beginner ヒンズーのみ
Medium
Veteran 全部
Gambler ディールなし
Random shuffle() 一発
None デフォルト値
Human default()
// Veteran の例
fn deck_shuffle(&self, cards: &mut Vec<Card>) {
    hindu_shuffle(cards, &HinduParams::veteran());
    riffle_shuffle(cards, &RiffleParams::veteran());
    deal_shuffle(cards, &DealParams::veteran());
    double_cut(cards);
}

切る回数・種類・パラメータ の3点セットで、CPU の「上手さ」を表現しています。


“thread::sleep” — 早すぎる問題

シャッフル処理の各ステップに thread::sleep を入れています。

thread::sleep(Duration::from_millis(100));  // 1 ラウンド完了ごと
thread::sleep(Duration::from_millis(20));   // 1 枚落とすごと

CPU の処理は一瞬で終わるので、そのままだとスピナーが見えません。
意図的に 100ms〜20ms 待つ ことで、「今、切っています」という演出にしています。

ゲームロジックとして必須ではなく、CLI ゲームとしてのテンポ調整 です。
本番の乱数品質には影響しません。


ゲームへの組み込み — 誰が切るか

各ラウンドの 起家プレイヤー が山札を切ります。

execute_with_spinner(
    &format!("Deck setup and {} a shuffle...", player.get_name()),
    &format!("Deck setup and {} a shuffle end.", player.get_name()),
    || {
        if player.has_human() {
            Human::deck_shuffle(deck.get_cards());
        } else {
            cpu.deck_shuffle(player, deck.get_cards());
        }
    },
);

ラウンドが進むと起家がローテーションするので、 Beginner CPU が切ったラウンドVeteran CPU が切ったラウンド で、山札の混ざり方が変わります。

Deal the cards. のプログレスバー。1 枚配るごとに進む様子。

配札(deal_setup)も同様に、1 枚ごと 10〜100ms のランダム待機を入れて、プログレスバーと連動させています。


CPU 難易度設計 — Strategy パターン

CPU の振る舞いは CpuStrategy trait で統一しています。

pub trait CpuStrategy {
    fn deck_shuffle(&self, deck: &mut Vec<Card>);
    fn organize_hand(&self, player: &mut Player);
    fn choose_card(&self, target_hand_len: usize) -> usize;
}

3 つのメソッドが、CPU の 切り方・隠し方・引き方 に対応します。
前回書いたジョーカーの隠し方は organize_hand、今回のシャッフルは deck_shuffle です。

実装は BeginnerStrategy / MediumStrategy / VeteranStrategy / GamblerStrategy / RandomStrategy / NoneStrategy の 6 種。
Cpu 構造体が PlayerType を見て、適切な Strategy を Box<dyn CpuStrategy> で返します。


6 スロット抽選 — グループ内に個体差を出す

起動時に選ぶのは グループ(Beginner / Medium / Veteran)です。
実際の CPU 1 体ごとの強さは、6 スロットから 1/6 で抽選 されます。

CpuLevelGroup::Beginner => [
    Beginner, Beginner, Beginner,
    Medium,   Medium,
    Random,
],
CpuLevelGroup::Veteran => [
    Medium,  Veteran, Veteran,
    Veteran, Gambler, Random,
],
グループ 6 スロットの内訳
Beginner Beginner×3, Medium×2, Random×1
Medium Beginner×1, Medium×3, Gambler×1, Random×1
Veteran Medium×1, Veteran×3, Gambler×1, Random×1

同じ「Veteran グループ」を選んでも、CPU 2 に Gambler が来ることもあります。
同じ設定でも毎回違う混成 になるのが意図です。

▲ 起動時の CPU Strategy level group Input
1: Beginner / 2: Medium / 3: Veteran / 0: Random。


6 人格の総まとめ

前回の「ジョーカーの隠し方」「引き方」と合わせた、CPU 全体像です。

戦略 切り方 ジョーカーの隠し 引き方
Beginner ヒンズーのみ(少なめ) 左端(ソート後) 左端 or 右端
Medium ヒンズー + リフル 右端 中央付近
Veteran 全部(多め) 履歴ベース 完全ランダム
Gambler ヒンズー + リフル + ダブルカット シャッフルのみ 完全ランダム
Random shuffle() 一発 シャッフルのみ 完全ランダム
None ディール + ダブルカット 中央付近(ソート後) 完全ランダム

Gambler — 「切るのは上手い、引くのは運任せ」

Gambler は Veteran と同程度のリフル回数を持ちながら、 organize_hand はただシャッフルするだけ、choose_card は完全ランダムです。

山札を混ぜる技術はあるが、場当たり的に打つ — そんなキャラクターとして設計しました。
Veteran グループに 1 スロットだけ混ざるので、たまに「手強いのに読めない」 CPU が現れます。

Random — すべてが運

cards.shuffle() だけのシンプルな実装です。
グループ抽選の「はずれ枠」として各グループに 1 スロットずつ入っています。


人間プレイヤーの切り方

人間も CPU と同じ 4 種シャッフルを使います(各 default() パラメータ)。

pub fn deck_shuffle(cards: &mut Vec<Card>) {
    hindu_shuffle(cards, &HinduParams::default());
    riffle_shuffle(cards, &RiffleParams::default());
    deal_shuffle(cards, &DealParams::default());
    double_cut(cards);
}

起家になったラウンドでは、プレイヤー自身が Veteran 級の切り方 で山札を混ぜることになります。
「自分が切ったときだけ山札がきれいに混ざる」という、ちょっとしたアドバンテージです。


設計で意識したこと

1. パラメータを struct で分離

HinduParams / RiffleParams / DealParams として切り出し、 beginner() / medium() / veteran() / gambler() の factory メソッドで返しています。

シャッフル本体のロジックは共通、数値だけ CPU ごとに変える 構造です。

2. 難易度 = 切り方 + 隠し方 + 引き方

どれか 1 つだけ強くするのではなく、3 つを セット で設計しました。
Beginner は「切りも雑、隠しもバレやすい、引き方も偏る」。
Veteran は「切りも丁寧、隠しも履歴ベース、引きはランダムで読めない」。

3. 同じグループでも毎回違う

6 スロット抽選により、3 人 CPU でも Gambler / Random が混ざる可能性があります。
固定の弱い CPU 3 体 ではなく、混成チーム として遊べます。

▲ 1 ゲーム内で CPU 1 (Gambler) / CPU 2 (Medium) / CPU 3 (Veteran) のように、括弧内の名前がバラバラな状態。


動かしてみる

git clone https://github.com/yoshitaka-k/old_maid.git
cd old_maid
cargo run

おすすめの観察ポイント:

  1. Veteran グループ を選び、起家が Veteran CPU のラウンドでシャッフル時間が長いか確認
  2. 同じグループでも CPU ごとに (Gambler) など表示が変わるか確認
  3. 自分が起家のラウンドで、スピナーのプレイヤー名が Player になるか確認

まとめ

  • 4 種のシャッフル(ヒンズー / リフル / ディール / ダブルカット)を 組み合わせ て切り方の個性を出した
  • パラメータ struct で 回数・枚数・山の数 を CPU ごとに変えた
  • thread::sleep で CLI ゲームとしての テンポ を調整した
  • CpuStrategy trait + 6 スロット抽選で 混成 CPU チーム を実現した

前回の「履歴ベースのジョーカー隠し」と合わせると、 切る・隠す・引く の 3 要素すべてに CPU 個性がある、という全体像になります。


前回・次回


参考リンク