よしたかの日常

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

Rust で Scoundrel(悪党)を作った — ソロダンジョンのルールとゲームループ

おはこんばんにちは!

ここんとこ、Rust の勉強がてら、ターミナルで遊べるトランプゲームを作っています。
前回old_maid でババ抜きを作ったあと、今度は Scoundrel(悪党) です。

1 人用のダンジョン風ソリティアで、山札からカードを引き、ルームに並んだ 4 枚から選んで進んでいきます。
CPU との心理戦はなく、自分の HP と装備をどう管理するか がすべてのゲームです。

この記事では、Scoundrel のルール整理と、Rust でのゲームループ設計を紹介します。

cargo run 直後。FIGlet の「Scoundrel」タイトルとバージョン行が見える状態。


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 の基本サイクルは次のとおりです。

  1. 山札からカードを引き、ルームに 4 枚 並べる
  2. そのうち 3 枚を選んで処理 する(残り 1 枚はルームに残す)
  3. 次のルームへ — 残り 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));
    }
}

drawVec::pop で山の上(末尾)から引きます。
ゲーム開始時にシャッフルしたあと、この順番でルームに配られます。

シャッフルの実装詳細は old_maid 2 本目 で書いた 4 種の切り方を流用しています。
Scoundrel では CPU 難易度がないので、各 Params::default() をまとめて呼ぶだけです。


ゲームループ — “game.rs”

メインループは src/game.rsapp() にあります。
毎ターン、画面をクリアしてから描画し直します。

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_count0 のままスタートし、 カードを 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.rsGameSession に集約しています。
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_setupdeck.push するだけなので、ルール変更にも追従しやすい構造です。

4. 選択履歴を残す

Player::selected に選んだカードを蓄積し、Selected History. として表示しています。
ゲーム結果には影響しませんが、「何を選んできたか」 を振り返れるようにしています。


動かしてみる

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

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

  1. ルーム 4 枚のとき 0: Skip が表示されるか
  2. 3 枚選んだあとルームに 1 枚残り、次のターンで山札から 3 枚補充されるか
  3. ♥ を選んだあと HP が回復し、上限 20 で止まるか
  4. ♦ を選ぶと装備が上書きされるか
  5. 装備なしで ♣♠ を選ぶとダメージを受けるか
  6. 山札が尽きたとき Game Clear. になるか

まとめ

Scoundrel は次のような設計になっています。

  • 44 枚の特殊デッキ で、スートごとに回復・装備・戦闘が決まる
  • ルーム 4 枚から 3 枚選び、1 枚残して山札から 3 枚補充 するサイクルを selected_count で管理
  • スキップ でルームのカードを山札に戻せる
  • GameSession にルールを集約し、game.rs はループ専用
  • crossterm で画面リセットとステータスの部分描画

CPU との読み合いはありませんが、「装備をいつ更新するか」「ポーションをいつ使うか」「スキップするか」という リソース管理 がゲームの本体です。


次回予告

次の記事では、old_maid から scoundrel へ移したとき、
どのコードをそのまま使い、どこを捨てて、何を足したか を整理する予定です。

  • シャッフル・スピナー・入力まわりの再利用
  • console から crossterm への移行
  • CpuStrategy を外して GameSession にした話

参考リンク