😽

麻雀アプリ(GUI)をフルRustで書いてみた

2024/08/19に公開

はじめに

前回の投稿で麻雀のエンジンを Rust で組む話をしました。今回は夏休みの宿題的に GUI アプリケーションを Rust で組んだので、技術選定の経緯や実際に書いた感触を話します。

なぜ Rust で GUI アプリケーションを作るのか

麻雀のロジックを Rust で組み上げていて、当初は DLL に書き出して C#で GUI を作ったり、WASM にして React で書くことを構想していましたが、まだロジック部分が未完成の段階でデバッグを繰り返しながら完成させていくことを考えると、DLL や WASM などといったインタフェースでは非常にデバッグがしづらく開発のスピードが出ないことが懸念でした。GUI も含めて Rust で組めれば end to end でデバッグできるため挙動をつかみやすくビルドも一発でできるので開発効率があがるためチャレンジしたくなりました。

Rust の GUI ライブラリの選定

Rust の GUI ライブラリは少ないながらもいくつか存在します。今回は GUI からネイティブのロジックまで e2e で動かしたいため、ブラウザベースで作られている Tauri などは候補から外れます。

候補に残ったのは次の 2 つです。

どちらも甲乙つけがたいですが、今回は Elm Architecture を採用しており、State、Message、View が明確に分かれていてアプリケーションを組むイメージがつきやすい iced を採用しました。

スクリーンショット

現状、一人麻雀ができるところまで実装できました。表示は微妙ですがリーチとツモ判定ができるところまで進みました。
スクリーンショット

実装コードの紹介

実際に書いたコードをかいつまんで説明します。

捨て牌表示

捨て牌を表す数値を牌画像をの path に変換して image にします。それを Vec として返す関数として実装しました。view 側ではRow::from_vecを利用して横に並べて表示します。

unsafe fn kawahai<'a>(&self) -> Vec<Element<'a, Message>> {
    match self.state {
        AppState::Created => {
            vec![]
        }
        AppState::Started | AppState::Ended => {
            let state = &G_STATE;
            let kawahai = &state.players[0].kawahai;
            let kawahai_num = state.players[0].kawahai_len;

            kawahai[0..kawahai_num as usize]
                .iter()
                .map(|pai| container(image(painum2path(pai.pai_num as u32))).into())
                .collect()
        }
    }
}

手牌表示

捨て牌と同様に数値を牌画像に置き換えて Vec として返します。手牌はボタンにしていて、押されたら対応するメッセージが送信されるように実装しています。ゲーム終了したら押せないように button は外しています。他にもいいやり方があるかもしれません。

unsafe fn tehai<'a>(&self) -> Vec<Element<'a, Message>> {
    match self.state {
        AppState::Created => {
            vec![]
        }
        AppState::Started => {
            let state = &G_STATE;
            let tehai = &state.players[0].tehai;
            let tehai_num = state.players[0].tehai_len;
            debug!("tehai_num = {}", tehai_num);

            let mut ui_tehai: Vec<Element<'a, Message>> = tehai[0..tehai_num as usize]
                .iter()
                .enumerate()
                .map(|(index, pai)| {
                    button(image(painum2path(pai.pai_num as u32)))
                        .on_press(Message::Dahai(index))
                        .into()
                })
                .collect();

            ui_tehai.push(
                button(image(painum2path(state.players[0].tsumohai.pai_num as u32)))
                    .on_press(Message::Dahai(13))
                    .into(),
            );

            ui_tehai
        }
        AppState::Ended => {
            let state = &G_STATE;
            let tehai = &state.players[0].tehai;
            let tehai_num = state.players[0].tehai_len;
            debug!("tehai_num = {}", tehai_num);

            let mut ui_tehai: Vec<Element<'a, Message>> = tehai[0..tehai_num as usize]
                .iter()
                .map(|pai| image(painum2path(pai.pai_num as u32)).into())
                .collect();

            ui_tehai.push(image(painum2path(state.players[0].tsumohai.pai_num as u32)).into());

            ui_tehai
        }
    }
}

更新系

更新系は update 関数に実装します。メッセージに対応した処理を実行して state を書き換えていきます。ツモアガリした場合、流局した場合はモーダルを表示します。

impl Application for App {

    fn update(&mut self, event: Message) -> Command<Message> {
        match event {
            Message::Start => unsafe {
                // ...略
                Command::none()
            },
            Message::Dahai(index) => unsafe {
                let state = &mut G_STATE;
                if index < state.players[0].tehai_len as usize {
                    let pai = &state.players[0].tehai[index];
                    debug!("Dahai {}", pai.pai_num);
                } else {
                    let pai = &state.players[0].tsumohai;
                    debug!("Dahai {}", pai.pai_num);
                }
                state.sutehai(&mut self.play_log, index, false);
                self.turns += 1;

                if self.turns > 18 {
                    self.state = AppState::Ended;
                    self.show_modal("流局");
                } else {
                    state.tsumo(&mut self.play_log);
                }
                Command::none()
            },
            Message::Tsumo => {
                unsafe {
                    let state = &mut G_STATE;
                    let result = state.tsumo_agari(&mut self.play_log);

                    match result {
                        Ok(agari) => {
                            self.state = AppState::Ended;

                            self.show_modal(&format!(
                                "{:?}\n{}翻\n{}符\n{}点",
                                agari.yaku, agari.han, agari.fu, agari.score
                            ));
                        }
                        Err(m) => {
                            self.show_modal(&format!("{:?}", m));
                        }
                    }
                }
                Command::none()
            }
            Message::Riichi => {
                self.is_riichi = !self.is_riichi;
                Command::none()
            }
            Message::FontLoaded => Command::none(),
            Message::HideModal => {
                self.is_show_modal = false;
                Command::none()
            }
            Message::ShowModal(mes) => {
                self.is_show_modal = true;
                self.modal_message = mes;
                Command::none()
            }
        }
    }
}

描画系

描画は view 関数で実装します。各種ボタン、捨て牌、手牌、モーダルの表示が実装されています。モーダルは iced のサンプルを参考に作っています。単一のフラグで制御しているため複数のモーダルを出し分けることになると大変かもしれません。

fn view(&self) -> Element<Message> {
    unsafe {
        let shanten = player_shanten(0);
        let content: Element<_> = column![
            button("Start").on_press(Message::Start),
            text(format!("{} シャンテン", shanten)),
            Row::from_vec(self.kawahai()),
            Row::from_vec(self.tehai()),
            row![
                button("ツモ").on_press(Message::Tsumo),
                button("リーチ").on_press(Message::Riichi)
            ]
            .spacing(10)
        ]
        .spacing(10)
        .padding(10)
        .into();

        let containered_content = container(content);

        if self.is_show_modal {
            let modal = container(
                column![
                    text(self.modal_message.clone()),
                    button("Close").on_press(Message::HideModal),
                ]
                .spacing(10)
                .padding(10),
            )
            .style(theme::Container::Box);

            Modal::new(containered_content, modal).into()
        } else {
            containered_content.into()
        }
    }
}

フォントの読み込み

個人的に一番苦戦したところかもしれません。iced 0.12.1 ではシステムフォントではないフォントを使う場合、iced::font::load を使ってファイルから読み出す必要がありますが、それだけでは使われず、iced::Settings に対応する名前を default_font にいれる必要があります。

const FONT_BYTES: &'static [u8] = include_bytes!("../fonts/Mamelon-5-Hi-Regular.otf");

impl Application for App {
    fn new(_flags: ()) -> (Self, Command<Message>) {
        let load_font = iced::font::load(FONT_BYTES).map(|_| Message::FontLoaded);
        (
            App {
                play_log: play_log::PlayLog::new(),
                state: AppState::Created,
                is_riichi: false,
                turns: 0,
                is_show_modal: false,
                modal_message: String::new(),
            },
            load_font,
        )
    }

    type Executor = executor::Default;

    type Theme = iced::Theme;

    type Flags = ();
}

fn main() -> iced::Result {
    env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).

    App::run(iced::Settings {
        antialiasing: true,
        default_font: iced::Font::with_name("マメロン"),
        ..iced::Settings::default()
    })
}

Rust で GUI を作る魅力

ここでは Rust を使って GUI アプリケーションを作るメリットを書きます。

どの環境でも動く

Rust は対応している OS が広いので Windows でも Mac でも Linux でもビルドすれば動かすことができます。マルチプラットフォームアプリケーションを Java や Qt を使って頑張っていた時期もありましたがその感覚をネイティブアプリケーションでできるのはすごいです。もしかするとパフォーマンスを求められる GUI アプリケーションでは Rust が選択肢に入る時代が来るかもしれません。

強力な Message

これは Rust の強みと言えますが、Message で使う enum にはパラメータを渡すことができるため、非常に直感的にイベントをパラメータ付きで渡すことができます。当然パラメータには型がついているため型安全に実行できるのも強みです。これが C 言語の enum のようだったら地獄でした(WIN32 が実際その地獄です 笑)

Rust で GUI を作るつらみ

逆に Rust + iced でアプリケーションを組んで感じたつらみを書きます。

アプリケーションの状態の肥大化

iced ではアプリケーション内の State を単一の構造体に格納して view メソッドで描画します。React の useState のようにコンポーネント内部に State をもたせることはできません。良い面もありますが、複雑な UI になっていくと UI の状態が一つの構造体に押し込まれることになるために構造体が肥大化していきます。

コンポーネントの再利用性

React の Atomic Design といった再利用可能コンポーネントを組むノウハウがまだないため、どうやってコンポーネントを作っていくか、どういうインタフェースにすべきかというところがわかっていません。ゆえに現状は一つの view メソッドに button、text、image といったプリミティブコンポーネントを書き連ねてその場限りの状態でアプリを組み上げています。もし大きい粒度でコンポーネントを再利用したいニーズが生まれた場合はこのやり方は大きな足かせになると思います。

ドキュメント不足

GUI のカタログが存在しないため、使ってみたときにどんな見た目になるのかは出たとこ勝負になるところがつらいです。iced だけでなく拡張の widget が別 crateになっておりサンプルなどを頼りに自力で実現手段を探し当てる力が求められます。

まとめ

本記事では麻雀 GUI アプリケーションを Rust で組んだ事例で技術選定の経緯や実際に書く場合のメリット・デメリットを紹介しました。これから Rust を使ってアプリケーションを作ろうと検討する方の参考に少しでもなれば幸いです。

Discussion