おはこんばんにちは!
ここんとこ、Rust の勉強がてら、ターミナルで遊べるトランプゲームを作っています。
前回、old_maid でババ抜きを作ったあと、今度は Scoundrel(悪党) です。
1 人用のダンジョン風ソリティアで、山札からカードを引き、ルームに並んだ 4 枚から選んで進んでいきます。
CPU との心理戦はなく、自分の HP と装備をどう管理するか がすべてのゲームです。
この記事では、Scoundrel のルール整理と、Rust でのゲームループ設計を紹介します。
- リポジトリ: yoshitaka-k/scoundrel
- 起動:
cargo run

▲ cargo run 直後。FIGlet の「Scoundrel」タイトルとバージョン行が見える状態。
- Scoundrel とは
- 1 ターンの流れ — ルームから 3 枚選ぶ
- 山札の構成 — “Deck::new”
- ゲームループ — “game.rs”
- ルールの本体 — “GameSession”
- 入力 — スキップとデフォルト値
- 画面表示 — crossterm で部分更新
- モジュール構成
- 設計で意識したこと
- 動かしてみる
- まとめ
- 次回予告
- 参考リンク
Scoundrel とは
Scoundrel は、トランプ 1 組の一部を使った 1 人用ダンジョン探索ゲーム です。
ダンジョン(山札)の奥へ進みながら、ルームに現れたカードを 1 枚ずつ処理していきます。
| スート | 役割 | この実装での枚数 |
|---|---|---|
| ♥ ハート | ポーション(HP 回復) | 2〜10(9 枚) |
| ♦ ダイヤ | 武器(装備) | 2〜10(9 枚) |
| ♣ クラブ | モンスター(戦闘) | A〜K(13 枚) |
| ♠ スペード | モンスター(戦闘) | A〜K(13 枚) |
合計 44 枚 です。ジョーカーは使いません。
プレイヤーは HP 20 からスタート。装備なしの状態でモンスターと戦うと、モンスターのランク分だけダメージを受けます。
装備があれば 装備のランク − モンスターのランク がダメージになり、差分がマイナスのときだけ HP が減ります。
エースはゲーム内で 14 として扱います。
pub fn get_rank(&self) -> isize { if self.rank == 1 { ACE_RANK // 14 } else { self.rank } }
1 ターンの流れ — ルームから 3 枚選ぶ
Scoundrel の基本サイクルは次のとおりです。
- 山札からカードを引き、ルームに 4 枚 並べる
- そのうち 3 枚を選んで処理 する(残り 1 枚はルームに残す)
- 次のルームへ — 残り 1 枚に山札から 3 枚を補充 して、再び 4 枚にする
4 枚そろった状態では 0: Skip も選べます。 スキップすると、ルームのカードをすべて山札に戻して、並べ直します。
ルーム 4 枚を配る ↓ 1 枚目を選ぶ → スートに応じて処理(回復 / 装備 / 戦闘) ↓ 2 枚目を選ぶ → 同様に処理 ↓ 3 枚目を選ぶ → 同様に処理 ↓ 残り 1 枚 + 山札から 3 枚補充 → 次のルーム(4 枚) ↓ 山札が尽きたら Game Clear HP が 0 以下になったら Game Over

▲ ルームに 4 枚並んだ状態。1: ♦︎6 / 2: ♠︎5 / ... と番号付きで表示され、[INPUT] プロンプトで選ぶ画面。
山札の構成 — “Deck::new”
山札の組み立ては src/trump/deck.rs にあります。
ハート・ダイヤは 2〜10 だけ、クラブ・スペードは A〜K です。
for suit in ["h", "d"] { for rank in [2, 3, 4, 5, 6, 7, 8, 9, 10] { cards.push(Card::new(suit, rank)); } } for suit in ["c", "s"] { for rank in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] { cards.push(Card::new(suit, rank)); } }
draw は Vec::pop で山の上(末尾)から引きます。
ゲーム開始時にシャッフルしたあと、この順番でルームに配られます。
シャッフルの実装詳細は old_maid 2 本目 で書いた 4 種の切り方を流用しています。
Scoundrel では CPU 難易度がないので、各 Params::default() をまとめて呼ぶだけです。
ゲームループ — “game.rs”
メインループは src/game.rs の app() にあります。
毎ターン、画面をクリアしてから描画し直します。
execute!( stroke, Clear(ClearType::All), MoveTo(0, 0) )?;
selected_count がゲームのフェーズを管理しています。
selected_count |
意味 |
|---|---|
0 |
ルームを補充するタイミング(山札から引いて 4 枚にそろえる) |
1 |
1 枚目を選んだ直後 |
2 |
2 枚目を選んだ直後 |
3 |
3 枚目を選んだ直後 → 次のルームへ |
if selected_count == 0 { if !GameSession::room_setup(&mut deck, &mut field) { println!("Game Clear."); break 'app Ok(()); } player.selected_clear(); if field.get_room_len() < ROOM_MAX_CARD { selected_count = 1; } }
ルームが 4 枚そろっているときは selected_count は 0 のままスタートし、
カードを 1 枚選ぶたびに +1 されていきます。3 枚選ぶと 3 になり、次のルームへ進みます。
山札が足りず 4 枚に満たないときは selected_count = 1 で始まり、
残り枚数ぶんだけ選んで終了します(終盤用の例外処理)。
このときスキップ(0)は選べません。
3 枚選び終わると selected_count > 2 になり、スピナー付きで次のルームへ進みます。
次のループで room_setup が呼ばれ、残り 1 枚に山札から 3 枚補充 されます。
if selected_count > 2 { execute_with_spinner("Next Terun ...", "", || { wait_for_long_dramatic_pause(); }); selected_count = 0; }
ルールの本体 — “GameSession”
ゲームロジックは src/logic/game_session.rs の GameSession に集約しています。
game.rs はループと表示、GameSession はルール、という分担です。
ルームの配布とスキップ
pub fn room_setup(deck: &mut Deck, field: &mut Field) -> bool { while field.get_room_len() < ROOM_MAX_CARD { if let Some(card) = deck.draw() { field.add_room_card(card); } else { return false; // 山札切れ → Game Clear } } true }
スキップ時はルームのカードをすべて山札に戻します。
pub fn re_room_setup(deck: &mut Deck, field: &mut Field) { let room = field.get_room(); for card in room { deck.push(card.clone()); } field.room_clear(); }
スート別の処理
選んだカードは identify_suit で分岐します。
match card.get_suit().as_str() { SUIT_STR_HART => Self::potion(player, card), // HP 回復 SUIT_STR_DIAMOND => Self::equip(player, card), // 装備更新 SUIT_STR_CLOVER => Self::battle(player, card), // 戦闘 SUIT_STR_SPADE => Self::battle(player, card), // 戦闘 _ => {} }
| 処理 | 内容 |
|---|---|
potion |
カードのランク分だけ HP を加算(上限 20) |
equip |
装備をそのカードに差し替え(上書き) |
battle |
装備ランク − モンスターランク がマイナスなら HP を減らす |
戦闘のダメージ計算:
let mut equip: isize = 0; if let Some(_card) = player.get_equip() { equip = _card.get_rank(); } let damage: isize = equip - card.get_rank(); if damage < 0 { player.damage_hp(damage); }
装備なし(equip = 0)で ♠4(ランク 4)と戦うと、ダメージ −4 です。
♦2 を装備していれば 2 − 4= −2 で 2 ダメージに抑えられます。

▲ Battle Result. の行。Damage: -2 pt と残り HP が表示されている状態。
入力 — スキップとデフォルト値
ルームカードの選択は room_card_selected で行います。
4 枚そろっていて、直前のターンがスキップでないときだけ 0(スキップ)が有効です。
if field.get_room_len() < ROOM_MAX_CARD || is_skip { min_num = 1; max_num = field.get_room_len(); }
input_usize_read_line は空 Enter でデフォルト値(1)を使います。
範囲外の入力はエラーを出して再入力を促します。
入力まわりの共通実装は old_maid 3 本目 で書いた rustyline のパターンと同じです。
画面表示 — crossterm で部分更新
Scoundrel では console クレートではなく crossterm で色付けとカーソル制御をしています。
- 毎ターン
Clear+MoveTo(0, 0)で画面全体をリセット - HP と装備は
MoveTo(0, 16)など 固定座標 に描画 - HP が 14 未満で黄色、6 未満で赤に変化
let mut player_hp = format!("{} point", player.get_hp()).green(); if player.get_hp() < 14 { player_hp = format!("{} point", player.get_hp()).yellow(); } if player.get_hp() < 6 { player_hp = format!("{} point", player.get_hp()).red(); }
ルームカードや戦闘ログは通常の println!、ステータスだけカーソル移動 — という使い分けです。

▲ 画面左下付近に Health. / Equip Card. / Selected History. が固定表示されている状態。
モジュール構成
src/ trump/ Card, Deck, Field, Player(ドメイン) logic/ GameSession, shuffle(ルール) cli/ 表示・入力・スピナー game.rs メインループ main.rs エントリポイント
| モジュール | 責務 |
|---|---|
trump |
カード・山札・フィールド・プレイヤーのデータ |
logic::GameSession |
ルール判定(ルーム配布、スート処理、ゲームオーバー) |
cli |
ターミナルへの出力と入力 |
game |
ループ制御と画面更新のタイミング |
old_maid と同じ 「ドメイン / ロジック / CLI / 進行」 の 4 分割です。 ババ抜きから引き継いだ骨格の上に、Scoundrel 用のルールを載せ替えています。
設計で意識したこと
1. ソロゲームなので GameSession にルールを集約
old_maid では CpuStrategy trait で CPU ごとの振る舞いを分けていました。
Scoundrel に CPU はいないので、ルール関数を GameSession の associated function にまとめる 形にしました。
game.rs は「いつ room_setup を呼ぶか」「selected_count をどう進めるか」だけを知っています。
2. selected_count で「3 枚選ぶ」制約を表現
「1 ルームで 3 枚選び、1 枚残して山札から 3 枚補充する」という Scoundrel の核心ルールを、
専用の state machine ではなく カウンタ 1 つ で表現しています。
> 2(= 3 枚選んだ)で次ルームに進み、room_setup が不足分を山札から引く — という流れです。
3. スキップは山札への返却
スキップしたカードを捨て札にするのではなく、山札に戻す 実装にしています。
re_room_setup で deck.push するだけなので、ルール変更にも追従しやすい構造です。
4. 選択履歴を残す
Player::selected に選んだカードを蓄積し、Selected History. として表示しています。
ゲーム結果には影響しませんが、「何を選んできたか」 を振り返れるようにしています。
動かしてみる
git clone https://github.com/yoshitaka-k/scoundrel.git cd scoundrel cargo run
おすすめの観察ポイント:
- ルーム 4 枚のとき
0: Skipが表示されるか - 3 枚選んだあとルームに 1 枚残り、次のターンで山札から 3 枚補充されるか
- ♥ を選んだあと HP が回復し、上限 20 で止まるか
- ♦ を選ぶと装備が上書きされるか
- 装備なしで ♣♠ を選ぶとダメージを受けるか
- 山札が尽きたとき
Game Clear.になるか
まとめ
Scoundrel は次のような設計になっています。
- 44 枚の特殊デッキ で、スートごとに回復・装備・戦闘が決まる
- ルーム 4 枚から 3 枚選び、1 枚残して山札から 3 枚補充 するサイクルを
selected_countで管理 - スキップ でルームのカードを山札に戻せる
GameSessionにルールを集約し、game.rsはループ専用- crossterm で画面リセットとステータスの部分描画
CPU との読み合いはありませんが、「装備をいつ更新するか」「ポーションをいつ使うか」「スキップするか」という リソース管理 がゲームの本体です。
次回予告
次の記事では、old_maid から scoundrel へ移したとき、
どのコードをそのまま使い、どこを捨てて、何を足したか を整理する予定です。
- シャッフル・スピナー・入力まわりの再利用
consoleからcrosstermへの移行CpuStrategyを外してGameSessionにした話