おはこんばんにちは!
1 本目 では High & Low のルールとゲームループを書きました。 今回は カードの見た目 です。
card_capture の TUI では、カードはテキスト(Paragraph)で表示していました。
High & Low では Ratatui の Canvas ウィジェット を使い、
スートとランクを Line / Circle / Points で自前描画 しています。
この記事では、なぜ Canvas にしたか、Shape trait の分割設計、
フェーズによる表裏切り替え、Braille マーカーの選び方を整理します。
- リポジトリ: yoshitaka-k/high_and_low
- 起動:
cargo run

▲ Dealer Card。Canvas 上にスート(左上)とランク(右下)が Braille ドットで描かれている状態。
- なぜテキストから Canvas へ
- 全体構成 — “rendar/” 以下
- Canvas ウィジェットの基本 — “card_drawing.rs”
- “CardRectangle” — カード内の座標系
- “Shape” trait — 1 図形 = 1 構造体
- dispatch 層 — “suit_drawing” / “rank_drawing”
- “ctx.layer()” — 重ね順の制御
- フェーズで見せ方を変える — 表と裏
- 裏面 — “BackDesign”
- フィールドへの配置 — “field.rs”
- スートの色分け
- 描画パイプライン全体
- 追加・変更するときの手順
- card_capture から Canvas へ移るチェックリスト
- 依存クレート
- 動かしてみる
- まとめ
- 前の記事 / 関連
- 参考リンク
なぜテキストから 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 本目)は同じです。
追加・変更するときの手順
新しいランクやスートを足す場合:
src/rendar/trump/rank/またはsuit/にShape実装を追加rank_drawing.rs/suit_drawing.rsの match に 1 行追加trump/card_enum/側の enum が既にあれば、それ以外は触らない
ゲームロジック(game.rs)は変更不要 です。
card_capture で handle/flags.rs が Ratatui を知らなかったのと同じ分離です。
card_capture から Canvas へ移るチェックリスト
- 座標系を固定する —
x_bounds/y_boundsとCardRectangleで内部座標を統一 - Shape を小さく分割 — 1 ファイル 1 図形で diff が読みやすい
- dispatch 層を挟む —
paint_cardから直接 Shape を new しない - フェーズで描画分岐 — ルール状態(Playing / Result)を
paintに渡す 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 観察ポイント:
- ディーラーカードのスート(赤/黒)とランク図形(Ace の逆 V 等)
- Playing 中、プレイヤー側だけ BackDesign(× + 円)になる
- Enter 後 Result フェーズでプレイヤーカードが表向きに切り替わる
- ターミナルをリサイズしてもカード比率が保たれるか
- 異なるスート(♥ ♦ ♣ ♠)の Shape がそれぞれ異なる図形になるか
まとめ
High & Low の Canvas 描画は次のとおりです。
Canvas+Marker::Braille— ターミナル上で線と円を高密度ドット描画Shapetrait × ファイル分割 — ランク 13 + スート 5 + 裏面 1suit_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 本目