RustのBevyクレートでアナログ時計を作る

RustのBevyクレートを使ってアナログ時計を作ったので記録を残したい。

基本的にはドキュメントを参考に

BevyはECSというパラダイム、概念を使っている。まずはこれを理解する。これについては公式のチュートリアルを見ればすぐわかる:

Getting Started
Bevy is a refreshingly simple data-driven game engine built in Rust. It is free and open-source forever!

非公式ドキュメントにはデータベースの行と列みたいなものだという説明があった。確かにSystemとする関数のQuery引数にはそんな印象がある。非公式ドキュメントの関連ページはこちら:

Intro: Your Data - Unofficial Bevy Cheat Book

作った時計について

時計は普通に存在しそうな一秒ごとに秒針が進む壁掛けのシンプルな2Dのアナログ時計である。描画と配置と親子関係について整理したかったので、インタラクト性はない。追加するつもりではある。そのあたりについては別で書こうと思う。

1秒ごとに秒針・長針・短針のすべてが適切な角度だけ回転する。つまり、1秒ごとに

  • 秒針は、6度進む。
  • 長針は、0.1度進む。
  • 短針は、長針の1/12だけ、つまり、0.0083333333度進む。

bevyではラジアンで指定することになっているので、ラジアンで計算したり、f32.to_radians()で度数法から弧度法に変換したりする。

今回のは描画タイミングと回転の中心についてがメイン。

描画

Updateごとに描画を行える。

今回は1秒に1回の描画の更新にした。FixedUpdateにシステムを追加することになる。

いや、UIのようなものと思って実装しているのでFixedUpdateではなくUpdateでいいだろう。 Time<Real>を使えば、Updateでもう少し理想的な描画にできそうだ。FixedUpdateが1FPS固定では全てが1秒ごとに描画されることになりそうで拡張性が低くなる可能性が高い。

それぞれの針にタイマーをコンポーネントとして持たせて、描画タイミングごとにタイマーを進めて、1秒が過ぎていたら前回の更新時からの経過時間の分だけ針を回転させるという方法をとっている。これで1秒ごとに針が動いているように見えるはず。

タイマーに関してtickメソッドなどは公式チュートリアルでも使っているので、そちらをまずやっておくべきだろう。

transformと親子関係

Bevyの回転は中心の周りで回転するのが基本のようだ。

長方形は中心(2つの対角線の交点)の周りで回転してしまう。時計の針として長方形を使うのであれば、回転の中心は、図形の中心ではなくそこから長辺方向のどちらかに寄るべきだろう。そうでなければ回転した時に時計の針であると認識できない。

その一方で、細長い二等辺三角形を使うと、頂点部分をy座標を大きめにし、底辺部分の2点のy座標を負の値に設定することでそのままこれを回転させても、違和感のない時計の針のような回転を実現はできる。しかし、角度によっては頂点付近の描画がなくなってしまう(細すぎて描画できない?)ことがあったのでこれは使わないことにした。

長方形の場合はどうしても対角線の中心で回転してしまうので、長方形に対して小さい円を親として設定した。

こうすることで、親である円を回転させると、それを中心として子も回転することになる。つまり時計の針のように回転させられる。

親の円は描画しているが、透明にしたり可能な限り小さくしたりで見えないようにもできそうだ。単純に隠す(hide)なんかもあったような気がする。

使える図形などはこちらの公式サンプルなどを利用した:

bevy/examples/2d/2d_shapes.rs at latest · bevyengine/bevy
A refreshingly simple data-driven game engine built in Rust - bevyengine/bevy

コード全体

残りはコード全体を載せておこう。コメントで少し補足がある:

use std::f32::consts::PI;

use bevy::prelude::*;
use bevy::sprite::{MaterialMesh2dBundle, Mesh2dHandle};
use chrono::Timelike;

fn main() {
    App::new()
        // .insert_resource(Time::from_hz(1.))  // 全体の描画間隔が1Hzになるのは場合によっては不都合
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, (setup, setup_bg))
        .add_systems(
            Update,
            (update_second_hand, update_minute_hand, update_hour_hand).chain(), // この実装ではchainで順序付けする必要はない。
        )
        .run();
}

// 描画する針の長さ。Camera2dBundleでデフォルトを使っているのでピクセル単位
// ref: https://bevy-cheatbook.github.io/2d/camera.html#scaling-mode
const LENGTH_HOUR_HAND: f32 = 160.0;
const LENGTH_MINUTE_HAND: f32 = 240.0;
const LENGTH_SECOND_HAND: f32 = 260.0;

// a marker
#[derive(Component)]
struct HourHandBase;

// a marker
#[derive(Component)]
struct MinuteHandBase;

// a marker
#[derive(Component)]
struct SecondHandBase;

// それぞれの針の描画タイミング計測用タイマー。リソースだと更新が大変になりそうなので個々に持たせる
#[derive(Component)]
struct ClockTimer(Timer);

// 経過時間計算用コンポーネント
#[derive(Component)]
struct ElapsedSeconds(f32);

fn setup(
    time: Res<Time<Real>>,
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2dBundle::default());

    let now = chrono::Local::now();
    let (rad_hour, rad_minute, rad_second) = calc_hands_radians(&now);

    // 回転用のベースを用意し、これを回転させる。3針それぞれで親子の組を作るため、.id()でEntityを取得する
    let second_hand_base = commands
        .spawn((
            SecondHandBase,
            ElapsedSeconds(time.elapsed_seconds_wrapped()),
            ClockTimer(Timer::from_seconds(1.0, TimerMode::Repeating)),
            MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(Circle::new(3.))),
                material: materials.add(Color::ANTIQUE_WHITE),
                transform: Transform::from_xyz(0., 0., 10.)
                    .with_rotation(Quat::from_axis_angle(-Vec3::Z, rad_second)), // 画面垂直奥向きが0,0,-1の向き。時計回り向きが正の角度になる。
                ..Default::default()
            },
        ))
        .id();
    // 表示される針は回転もなにもしないので長方形だけ作成して針のように見えるように12時にあるとみなし、上に移動させる。
    let second_hand = commands
        .spawn(MaterialMesh2dBundle {
            mesh: Mesh2dHandle(meshes.add(Rectangle::new(4., LENGTH_SECOND_HAND))),
            material: materials.add(Color::ANTIQUE_WHITE),
            transform: Transform::from_xyz(0., LENGTH_SECOND_HAND / 2.0 - 25.0, 3.), // はみ出るのは適当な値に設定している
            ..Default::default()
        })
        .id();
    // 親子関係を構築
    commands.entity(second_hand_base).add_child(second_hand);

    let minute_hand_base = commands
        .spawn((
            MinuteHandBase,
            ElapsedSeconds(time.elapsed_seconds_wrapped()),
            ClockTimer(Timer::from_seconds(1.0, TimerMode::Repeating)),
            MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(Circle::new(3.0))),
                material: materials.add(Color::ANTIQUE_WHITE),
                transform: Transform::from_xyz(0.0, 0.0, 9.0)
                    .with_rotation(Quat::from_axis_angle(-Vec3::Z, rad_minute)),
                ..Default::default()
            },
        ))
        .id();
    let minute_hand = commands
        .spawn(MaterialMesh2dBundle {
            mesh: Mesh2dHandle(meshes.add(Rectangle::new(5.0, LENGTH_MINUTE_HAND))),
            material: materials.add(Color::ANTIQUE_WHITE),
            transform: Transform::from_xyz(0.0, LENGTH_MINUTE_HAND / 2.0 - 30.0, 2.0),
            ..Default::default()
        })
        .id();
    commands.entity(minute_hand_base).add_child(minute_hand);

    let hour_hand_base = commands
        .spawn((
            HourHandBase,
            ElapsedSeconds(time.elapsed_seconds_wrapped()),
            ClockTimer(Timer::from_seconds(1.0, TimerMode::Repeating)),
            MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(Circle::new(3.0))),
                material: materials.add(Color::ANTIQUE_WHITE),
                transform: Transform::from_xyz(0.0, 0.0, 8.0)
                    .with_rotation(Quat::from_axis_angle(-Vec3::Z, rad_hour)),
                ..Default::default()
            },
        ))
        .id();
    let hour_hand = commands
        .spawn(MaterialMesh2dBundle {
            mesh: Mesh2dHandle(meshes.add(Rectangle::new(6.0, LENGTH_HOUR_HAND))),
            material: materials.add(Color::ANTIQUE_WHITE),
            transform: Transform::from_xyz(0.0, LENGTH_HOUR_HAND / 2.0 - 20.0, 1.0),
            ..Default::default()
        })
        .id();
    commands.entity(hour_hand_base).add_child(hour_hand);
}

/// 文字盤の描画
fn setup_bg(mut commands: Commands, asset_aserver: Res<AssetServer>) {
    // fontはGoogle Fontsから用意したものをroot/assets/fonts/に配置などする。
    let font = asset_aserver.load("fonts/<FONT_NAME>.ttf");
    let text_style = TextStyle {
        font: font.clone(),
        font_size: 45.0,
        color: Color::WHITE,
    };
    let text_justification = JustifyText::Center;

    for i in 1..13 {
        let degree = i * 30;
        let mut tstyl = text_style.clone();
        if i % 3 != 0 {
            tstyl.font_size = 25.0;
        }
        commands.spawn(Text2dBundle {
            text: Text::from_section(format!("{}", i), tstyl).with_justify(text_justification),
            transform: Transform::from_xyz(
                LENGTH_MINUTE_HAND * (degree as f32).to_radians().sin(),
                LENGTH_MINUTE_HAND * (degree as f32).to_radians().cos(),
                0.0,
            ),
            ..Default::default()
        });
    }
}

/// 与えられた&DateTimeから短針長針秒針それぞれの00:00からの角度を返す。単位はラジアン。
fn calc_hands_radians(dtime: &chrono::DateTime<chrono::Local>) -> (f32, f32, f32) {
    let h = if dtime.hour() >= 12 {
        dtime.hour() - 12
    } else {
        dtime.hour()
    };
    let h = h as f32;
    let m = dtime.minute() as f32;
    let s = dtime.second() as f32;

    let deg_second = s * 6.0;
    let deg_minute = m * 6.0 + s / 60.0 * 6.0;
    let deg_hour = h * 30.0 + m / 2.0 + s / 120.0;

    (
        deg_hour.to_radians(),
        deg_minute.to_radians(),
        deg_second.to_radians(),
    )
}

fn update_second_hand(
    time: Res<Time<Real>>,
    mut query: Query<(&mut Transform, &mut ElapsedSeconds, &mut ClockTimer), With<SecondHandBase>>,
) {
    for (mut tf, mut es, mut ct) in &mut query {
        if ct.0.tick(time.delta()).just_finished() {
            // 1秒経ったら描画タイミング、前回からの経過時間分だけ回転する
            let rad = (time.elapsed_seconds_wrapped() - es.0) * 2f32 * PI / 60f32;
            // Z軸は画面の平面に対して垂直で画面表向きが正(⦿)。rotate_axisは指定した軸に対して時計回りに回転する。
            tf.rotate_axis(-Vec3::Z, rad);
            // update elapsed time
            es.0 = time.elapsed_seconds_wrapped();
        }
    }
}

// 上の秒針との違いは引数と回転角
fn update_minute_hand(
    time: Res<Time<Real>>,
    mut query: Query<(&mut Transform, &mut ElapsedSeconds, &mut ClockTimer), With<MinuteHandBase>>,
) {
    for (mut tf, mut es, mut ct) in &mut query {
        if ct.0.tick(time.delta()).just_finished() {
            let rad = (time.elapsed_seconds_wrapped() - es.0) * 2f32 * PI / 3600f32;
            tf.rotate_axis(-Vec3::Z, rad);
            es.0 = time.elapsed_seconds_wrapped();
        }
    }
}

// 2つ上の秒針との違いは引数と回転角
fn update_hour_hand(
    time: Res<Time<Real>>,
    mut query: Query<(&mut Transform, &mut ElapsedSeconds, &mut ClockTimer), With<HourHandBase>>,
) {
    for (mut tf, mut es, mut ct) in &mut query {
        if ct.0.tick(time.delta()).just_finished() {
            let rad = (time.elapsed_seconds_wrapped() - es.0) * 2f32 * PI / (3600f32 * 12f32);
            tf.rotate_axis(-Vec3::Z, rad);
            es.0 = time.elapsed_seconds_wrapped();
        }
    }
}

// windowをドラッグしたりすると動きが止まるのであまり重要ではないが正確性がなくなる。
// これはFixedTimestepを使っていて、相対角度を求めている場合に起こる。
// 毎回絶対的時間を取得して回転させるとうまくいくかもしれない。

// fn update_second_hand(time: Res<Time<Fixed>>, mut query: Query<&mut Transform, With<SecondHand>>) {
//     for mut tf in &mut query {
//         tf.rotate_axis(-Vec3::Z, time.delta_seconds() * 6. * PI / 180.);
//     }
// }

// fn update_minute_hand(time: Res<Time<Fixed>>, mut query: Query<&mut Transform, With<MinuteHand>>) {
//     for mut tf in &mut query {
//         tf.rotate_axis(-Vec3::Z, time.delta_seconds() * 6. / 60. * PI / 180.)
//     }
// }

// fn update_hour_hand(time: Res<Time<Fixed>>, mut query: Query<&mut Transform, With<HourHand>>) {
//     for mut tf in &mut query {
//         tf.rotate_axis(-Vec3::Z, time.delta_seconds() * 6. / 3600. * PI / 180.)
//     }
// }

// fn print_hands(query: Query<&Hands>) {
//     for hands in &query {
//         println!(
//             "h: {} m: {} s: {}",
//             hands.deg_hour, hands.deg_minute, hands.deg_second
//         );
//     }
// }

おわり

あまりRustらしいコードが書けていないがbevyらしいコードにはなっていてほしい。まだbevyでやってみたいことはあるので、その辺は試していきたいと思う。

非公式ドキュメントは部分ごとに詳しい説明がされているので大変参考になる。たまに工事中のところがあることには注意。仕方ないね。

回転についてはそこそこ悩んだので親の設定は柔軟に考えたい。UpdateとFixedUpdateについては使い分けなどがまだいまいちなのでドキュメントなど読みたい。

以上です。ありがとうございました。



広告(Amazonアソシエイト)

https://amzn.to/3Vv5qGA
Rustハンズオン
Amazon.co.jp: Rustハンズオン eBook : 掌田津耶乃: Kindleストア
タイトルとURLをコピーしました