よしたかの日常

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

Rust で High & Low — Ratatui Canvas でトランプを描く

おはこんばんにちは!

1 本目 では High & Low のルールとゲームループを書きました。 今回は カードの見た目 です。

card_capture の TUI では、カードはテキスト(Paragraph)で表示していました。 High & Low では Ratatui の Canvas ウィジェット を使い、 スートとランクを Line / Circle / Points で自前描画 しています。

この記事では、なぜ Canvas にしたか、Shape trait の分割設計、 フェーズによる表裏切り替え、Braille マーカーの選び方を整理します。

▲ Dealer Card。Canvas 上にスート(左上)とランク(右下)が Braille ドットで描かれている状態。


なぜテキストから Canvas へ

card_capture 2 本目では BlockPosition とマウス入力が主題で、カード自体は ♥A のような 文字列 で足りていました。

High & Low では 常に 2 枚のカードが画面中央 にあるゲームです。 文字 1 行より、カードっぽい矩形 + スート記号 + ランク図形 のほうが没入感が出ます。

観点 card_capture high_and_low
カード表示 Paragraph + Unicode 記号 Canvas + Shape
スート ♥ ♦ 等の 1 文字 Heart / Spade 等を Line/Circle で描画
ランク A K 等の 1 文字 Ace / King 等を Line で描画
裏面 なし BackDesign(×印 + 同心円)
枚数 手札 4 枚 × 2 人 2 枚固定

TUI 入門(イベントループ、BlockPosition)は card_capture 2 本目 に任せ、 ここでは Canvas 描画だけ に絞ります。


全体構成 — “rendar/” 以下

src/rendar/
  canvas/
    card_drawing.rs    ← Canvas ウィジェットの入口
    suit_drawing.rs    ← スート → Shape の dispatch
    rank_drawing.rs    ← ランク → Shape の dispatch
  trump/
    suit/              ← Heart, Spade, Diamond, Clover, Joker
    rank/              ← Ace, Two, …, King(13 ファイル)
    back_design.rs     ← 裏面
  content_block/
    field.rs           ← ディーラー / プレイヤー 2 枚を並べる

ランク 13 種 × スート 5 種をファイル分割 しています。 1 ファイル = 1 図形、というルールです。

card_capture の handle/ 分割(フェーズごとにファイル)と同じ発想で、 「描画単位 = 1 モジュール」 にしています。


Canvas ウィジェットの基本 — “card_drawing.rs”

1 枚のカードは Canvas::default() で描きます。

let canvas = Canvas::default()
    .x_bounds(X_BOUNDS)   // [-40.0, 80.0]
    .y_bounds(Y_BOUNDS)   // [-40.0, 70.0]
    .marker(Marker::Braille)
    .paint(|ctx| {
        paint_card(ctx, card.0, card.1, card.2, phase);
    });

frame.render_widget(canvas, canvas_area);
設定 値 意味
x_bounds / y_bounds 固定の f64 範囲 Canvas 内部の座標系
marker Marker::Braille Braille ドット(⠿ 系)で線を描く
paint クロージャ 毎フレーム Context に Shape を積む

Marker::Braille は 解像度が高く、Line が滑らか に見えるのが選定理由です。 Block/Paragraph では表現できない斜線や円を、ターミナル文字の密度で近似しています。

カード上部にはラベル用の Paragraph を別レイアウトで載せています。

let vertical = Layout::vertical([
    Constraint::Length(3),   // "Dealer Card" / "Player Card"
    Constraint::Fill(1),     // Canvas 本体
]);

“CardRectangle” — カード内の座標系

Canvas 内での描画位置は CardRectangle で管理します。

pub const CARD_RECT: CardRectangle = CardRectangle {
    x: 0.0,
    y: 0.0,
    width: 50.0,
    height: 50.0,
};

スートは 左上、ランクは 右下 に配置します。

// suit_drawing.rs — 左上
let suit_x = rectangle.x + rectangle.width / 3.0;
let suit_y = rectangle.y + rectangle.height / 1.4;

// rank_drawing.rs — 右下
let rank_x = rectangle.x + rectangle.width - rectangle.width / 3.5;
let rank_y = rectangle.y + rectangle.height - rectangle.height / 1.4;

サイズは rectangle.width.min(rectangle.height) * 係数 でスケールするので、 レイアウトを変えても カード枠に対する比率が保たれます。


“Shape” trait — 1 図形 = 1 構造体

Ratatui の Canvas は Shape trait を実装した型を ctx.draw() で描きます。

impl Shape for Heart {
    fn draw(&self, painter: &mut Painter) {
        // Line と Circle を組み合わせてハート型
        Line::new(right_x, right_y, bottom_x, bottom_y, self.color).draw(painter);
        Circle::new(left_x + self.size * 0.45, left_y + self.size * 0.2, radius, self.color).draw(painter);
        // ...
    }
}

Heart は 2 本の斜線 + 2 つの円 で近似しています。 Ace は 逆 V 字 + 横棒 の 3 本の Line です。

impl Shape for Ace {
    fn draw(&self, painter: &mut Painter) {
        let lines = [
            Line::new(top_x, top_y, right_bottom_x, right_bottom_y, self.color),
            Line::new(top_x, top_y, left_bottom_x, left_bottom_y, self.color),
            Line::new(left_middle_x, left_middle_y, right_middle_x, right_middle_y, self.color),
        ];
        for line in &lines { line.draw(painter); }
    }
}
ファイル 描画手法 例
suit/heart.rs Line + Circle ハート
suit/spade.rs Line + Circle スペード(逆ハート + 茎)
suit/diamond.rs Line ダイヤ(4 辺)
rank/ten.rs Line 「10」の縦棒 2 本
rank/jack.rs Line J の字

数字が大きいランク(8, 9, 10)は Line 本数が増えますが、 どれも同じ (x, y, size, color) インターフェース です。


dispatch 層 — “suit_drawing” / “rank_drawing”

paint_card からは dispatch 関数だけを呼びます。

match current_card {
    CurrentCard::Dealer => {
        suit_drawing(ctx, rectangle, card);
        ctx.layer();
        rank_drawing(ctx, rectangle, card);
    }
    CurrentCard::Player => { /* フェーズで分岐 — 後述 */ }
}

suit_drawing は Card.suit() で match し、対応する Shape を ctx.draw します。

let color = match card.suit() {
    Suit::Heart | Suit::Diamond => Color::Red,
    Suit::Clover | Suit::Spade => Color::DarkGray,
    Suit::Joker => Color::Reset,
};
match card.suit() {
    Suit::Heart => ctx.draw(&Heart { x: suit_x, y: suit_y, size: suit_size, color }),
    // ...
}

rank_drawing も同様に Rank enum で 13 分岐です。 match の塊を 1 ファイルに閉じ込め、Shape 本体は別ファイル — 追加・差し替えが楽になります。


“ctx.layer()” — 重ね順の制御

スートを描いたあと ctx.layer() を呼んでからランクを描きます。

suit_drawing(ctx, rectangle, card);
ctx.layer();
rank_drawing(ctx, rectangle, card);

Canvas は paint クロージャ内で 描画順 = 重ね順 です。 layer() でレイヤーを分けることで、ランクがスートの上に来ることを保証しています。

外枠の Rectangle もカードがあるときだけ描きます。

if card.is_some() {
    ctx.draw(&Rectangle::new(rectangle.x, rectangle.y, rectangle.width, rectangle.height, Color::Reset));
    ctx.layer();
}

フェーズで見せ方を変える — 表と裏

High & Low 最大の UI 設計ポイントは プレイヤーカードの表裏 です。

CurrentCard::Player => {
    // Playing 中は裏面
    if card.is_some() && phase == GamePhase::Playing {
        ctx.draw(&BackDesign {
            x: rectangle.x + rectangle.width / 2.0,
            y: rectangle.y + rectangle.height / 2.0,
            size: rectangle.width.min(rectangle.height) * 0.37,
        });
    }
    // Result / End では表向き
    else if card.is_some() && (phase == GamePhase::Result || phase == GamePhase::End) {
        suit_drawing(ctx, rectangle, card);
        rank_drawing(ctx, rectangle, card);
    }
}
フェーズ ディーラーカード プレイヤーカード
Deal / Playing 表(スート + ランク) 裏(BackDesign)
Result / End 表 表(めくる)

Blackjack のホールカード(1 本目)と似た 「見せるタイミング = ゲーム体験」 ですが、 Blackjack は表示層の open フラグ、High & Low は GamePhase で描画分岐 です。 ルール層の Game はフェーズを知っているだけで、Canvas の詳細は知りません。

▲ Playing フェーズ。左ディーラーは ♣︎A 等が見える。右プレイヤーは緑×印 + 赤黄の同心円(BackDesign)。

▲ Result フェーズ。右プレイヤーカードが表向きになり、スートとランクが見える状態。


裏面 — “BackDesign”

裏面は Shape 1 つで完結します。

impl Shape for BackDesign {
    fn draw(&self, painter: &mut Painter) {
        // 緑の × 字(2 本の Line)
        Line::new(/* 左上 → 右下 */, Color::Green).draw(painter);
        Line::new(/* 右上 → 左下 */, Color::Green).draw(painter);

        // 赤と黄の同心円
        Circle::new(cx, cy, self.size, Color::Red).draw(painter);
        Circle::new(cx, cy, self.size * 0.75, Color::Yellow).draw(painter);
    }
}

本物のトランプ裏模様ほど凝らしていませんが、 「まだめくれていない」ことが一目でわかる 程度の記号化です。 Playing 中にプレイヤー側だけ BackDesign が出るので、High / Low の選択が「まだ結果が見えない」状態と視覚的に一致します。


フィールドへの配置 — “field.rs”

2 枚のカードは render_field で横並びにします。

let horizontal = Layout::horizontal([
    Constraint::Percentage(50),
    Constraint::Percentage(50),
]).spacing(1);
let [dealer, player] = field.layout(&horizontal);

card_drawing(frame, dealer, (&CARD_RECT, CurrentCard::Dealer, app.game.dealer_card()), app.current_phase);
card_drawing(frame, player, (&CARD_RECT, CurrentCard::Player, app.game.player_card()), app.current_phase);

card_capture の render_content_block が毎フレーム positions.clear() するのと同様、 レイアウト計算と描画を 1 パス で行います。 High & Low ではクリック対象がカードではなく High/Low ボタン側なので、 カード Rect は BlockPosition に登録していません。


スートの色分け

Suit::Heart | Suit::Diamond => Color::Red,
Suit::Clover | Suit::Spade   => Color::DarkGray,

old_maid 3 本目 の CLI 色分けと同じ発想です。 Canvas でも 赤スート / 黒スート の区別を維持しています。


描画パイプライン全体

ui::render
  ├── header
  ├── content(CurrentScreen で分岐)
  │     └── render_content_block
  │           ├── field.rs → card_drawing × 2
  │           │     └── paint_card
  │           │           ├── Rectangle(外枠)
  │           │           ├── suit_drawing → Heart / Spade / …
  │           │           ├── rank_drawing → Ace / King / …
  │           │           └── BackDesign(Player + Playing)
  │           └── buttons.rs → High / Low / Enter
  └── footer

毎 draw で Canvas 全体を描き直します。 Ratatui TUI の基本リズム(card_capture 2 本目)は同じです。


追加・変更するときの手順

新しいランクやスートを足す場合:

  1. src/rendar/trump/rank/ または suit/ に Shape 実装を追加
  2. rank_drawing.rs / suit_drawing.rs の match に 1 行追加
  3. trump/card_enum/ 側の enum が既にあれば、それ以外は触らない

ゲームロジック(game.rs)は変更不要 です。 card_capture で handle/flags.rs が Ratatui を知らなかったのと同じ分離です。


card_capture から Canvas へ移るチェックリスト

  1. 座標系を固定する — x_bounds / y_bounds と CardRectangle で内部座標を統一
  2. Shape を小さく分割 — 1 ファイル 1 図形で diff が読みやすい
  3. dispatch 層を挟む — paint_card から直接 Shape を new しない
  4. フェーズで描画分岐 — ルール状態(Playing / Result)を paint に渡す
  5. ctx.layer() で重ね順 — スート → ランクの順を保証

依存クレート

ratatui = "0.30.0"
color-eyre = "0.6.5"
getset = "0.1.6"
figlet-rs = "1.0.0"

Canvas は ratatui 本体に含まれています(追加 crate 不要)。 card_capture と同じく console / indicatif / rustyline は使っていません。


動かしてみる

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

Canvas 観察ポイント:

  1. ディーラーカードのスート(赤/黒)とランク図形(Ace の逆 V 等)
  2. Playing 中、プレイヤー側だけ BackDesign(× + 円)になる
  3. Enter 後 Result フェーズでプレイヤーカードが表向きに切り替わる
  4. ターミナルをリサイズしてもカード比率が保たれるか
  5. 異なるスート(♥ ♦ ♣ ♠)の Shape がそれぞれ異なる図形になるか

まとめ

High & Low の Canvas 描画は次のとおりです。

  • Canvas + Marker::Braille — ターミナル上で線と円を高密度ドット描画
  • Shape trait × ファイル分割 — ランク 13 + スート 5 + 裏面 1
  • suit_drawing / rank_drawing — match dispatch で Shape を選択
  • GamePhase による表裏切り替え — Playing は BackDesign、Result で表
  • ctx.layer() — 外枠 → スート → ランクの重ね順
  • ルール層は Canvas 非依存 — 描画は rendar/ だけの関心事

card_capture が TUI 操作 の実験だったのに対し、 High & Low は TUI 表現 の実験 — 同じ Ratatui でも切り口が違います。


前の記事 / 関連

← 1 本目: ルールとゲームループ
TUI 基礎 → card_capture 2 本目
ホールカードの表示タイミング → blackjack 1 本目


参考リンク