おはこんばんにちは!
前回 に続いて、Rustで作ったババ抜きについて書いて行こうかなと。
前回 は、引かれた位置の履歴を使ってジョーカーを隠す CPU の話を書きました。
今回は 山札の切り方 と CPU 難易度の設計 について。
Rust の勉強がてら作っている old_maid では、
rand の shuffle 一発ではなく、実際のトランプの切り方に近い処理 を組み合わせています。
さらに CPU ごとに「どの切り方を何回やるか」を変えて、難易度の個性を出しています。
- リポジトリ: yoshitaka-k/old_maid
- 起動:
cargo run

▲ 配札前の Deck setup and CPU 1 a shuffle... スピナー。起家プレイヤー名が入っている状態。
- なぜ “shuffle()” だけにしなかったか
- 4 種類の切り方
- 切り方の組み合わせ — CPU ごとの「レパートリー」
- “thread::sleep” — 早すぎる問題
- ゲームへの組み込み — 誰が切るか
- CPU 難易度設計 — Strategy パターン
- 6 スロット抽選 — グループ内に個体差を出す
- 6 人格の総まとめ
- 人間プレイヤーの切り方
- 設計で意識したこと
- 動かしてみる
- まとめ
- 前回・次回
- 参考リンク
なぜ “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_position は cards_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
おすすめの観察ポイント:
- Veteran グループ を選び、起家が Veteran CPU のラウンドでシャッフル時間が長いか確認
- 同じグループでも CPU ごとに
(Gambler)など表示が変わるか確認 - 自分が起家のラウンドで、スピナーのプレイヤー名が
Playerになるか確認
まとめ
- 4 種のシャッフル(ヒンズー / リフル / ディール / ダブルカット)を 組み合わせ て切り方の個性を出した
- パラメータ struct で 回数・枚数・山の数 を CPU ごとに変えた
thread::sleepで CLI ゲームとしての テンポ を調整したCpuStrategytrait + 6 スロット抽選で 混成 CPU チーム を実現した
前回の「履歴ベースのジョーカー隠し」と合わせると、 切る・隠す・引く の 3 要素すべてに CPU 個性がある、という全体像になります。
前回・次回
- 前回: 履歴ベースの心理戦 CPU
- 次回(予定): ターミナル演出 —
indicatif/console/figlet-rsで CLI ゲームを作る
参考リンク
- old_maid リポジトリ
- 前回記事: 履歴ベースの心理戦 CPU
- Rust Book: trait オブジェクト