Skip to content

天文馆开馆

全章零件总装。结构图先挂墙上:太阳带 Spin 自转加 Pulse 脉动;水星、地球各坐各的轨道盘;月亮盘挂在地球名下,月亮坐盘沿——三层转盘全靠一个 spin 系统驱动;彗星不进层级,沿椭圆自由飞行,机头追着运动方向;观测员小满每两秒对照一次月亮的两本账,彗星出入取景框时记上一笔。

rust
//! Listing 12-13:青石天文馆开馆——本章零件全装配
//! 转盘层级管公转,Spin 管旋转,彗星管朝向,观测站对照两本账

use bevy::prelude::*;

/// 匀速旋转:转盘与太阳通用(弧度/秒)
#[derive(Component)]
struct Spin {
    speed: f32,
}

/// 脉动:缩放随正弦呼吸
#[derive(Component)]
struct Pulse;

/// 标记:彗星
#[derive(Component)]
struct Comet;

/// 标记:月亮
#[derive(Component)]
struct Moon;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, (spin, pulse, fly_comet, observe))
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(Camera2d);

    // 太阳:自转 + 脉动
    commands.spawn((
        Spin { speed: 0.6 },
        Pulse,
        Sprite::from_color(Color::srgb(1.0, 0.8, 0.2), Vec2::splat(80.0)),
    ));

    // 水星的轨道盘 → 水星
    commands.spawn((
        Spin { speed: 1.2 },
        Transform::IDENTITY,
        Visibility::default(),
        children![(
            Sprite::from_color(Color::srgb(0.7, 0.65, 0.6), Vec2::splat(20.0)),
            Transform::from_xyz(120.0, 0.0, 0.0),
        )],
    ));

    // 地球的轨道盘 → 地球 → 月亮的轨道盘 → 月亮
    commands.spawn((
        Spin { speed: 0.5 },
        Transform::IDENTITY,
        Visibility::default(),
        children![(
            Sprite::from_color(Color::srgb(0.3, 0.55, 0.9), Vec2::splat(30.0)),
            Transform::from_xyz(240.0, 0.0, 0.0),
            children![(
                Spin { speed: 2.0 },
                Transform::IDENTITY,
                Visibility::default(),
                children![(
                    Moon,
                    Sprite::from_color(Color::srgb(0.8, 0.8, 0.78), Vec2::splat(12.0)),
                    Transform::from_xyz(55.0, 0.0, 0.0),
                )],
            )],
        )],
    ));

    // 彗星:不进层级,自由飞行
    commands.spawn((
        Comet,
        Sprite::from_color(Color::srgb(0.85, 0.95, 1.0), Vec2::new(14.0, 36.0)),
        Transform::from_xyz(320.0, 0.0, 0.0),
    ));

    println!("老盖:天文馆开馆,诸星就位。");
}

/// 有 Spin 就转:太阳的自转、各轨道盘的公转,一视同仁
fn spin(time: Res<Time>, mut gears: Query<(&Spin, &mut Transform)>) {
    for (gear, mut transform) in &mut gears {
        transform.rotate_z(gear.speed * time.delta_secs());
    }
}

/// 太阳呼吸:缩放在 1.0 ± 0.15 之间起伏
fn pulse(time: Res<Time>, mut pulsers: Query<&mut Transform, With<Pulse>>) {
    for mut transform in &mut pulsers {
        transform.scale = Vec3::splat(1.0 + 0.15 * (time.elapsed_secs() * 2.0).sin());
    }
}

/// 彗星沿椭圆飞,机头追着运动方向
fn fly_comet(time: Res<Time>, mut comets: Query<&mut Transform, With<Comet>>) {
    let t = time.elapsed_secs() * 0.8;
    for mut transform in &mut comets {
        let next = Vec3::new(320.0 * t.cos(), 180.0 * t.sin(), 0.0);
        let motion = (next - transform.translation).truncate();
        transform.translation = next;
        if let Ok(heading) = Dir2::new(motion) {
            transform.rotation = Quat::from_rotation_arc(Vec3::Y, heading.extend(0.0));
        }
    }
}

/// 观测站:每两秒对照月亮的两本账;彗星出入取景框时记一笔
fn observe(
    time: Res<Time>,
    moon: Single<(&Transform, &GlobalTransform), With<Moon>>,
    comet: Single<&Transform, With<Comet>>,
    mut report_clock: Local<f32>,
    mut comet_in_frame: Local<bool>,
) {
    // 月亮的台账:籍册(局部)对实测(世界)
    *report_clock += time.delta_secs();
    if *report_clock >= 2.0 {
        *report_clock -= 2.0;
        let (local, global) = *moon;
        println!(
            "观测站:月亮 籍册 [{:.0}, {:.0}],实测 [{:.0}, {:.0}]",
            local.translation.x,
            local.translation.y,
            global.translation().x,
            global.translation().y
        );
    }

    // 彗星的出入镜记录:状态翻转才开口
    let frame = Rect::from_center_size(Vec2::ZERO, Vec2::new(600.0, 360.0));
    let inside = frame.contains(comet.translation.truncate());
    if inside != *comet_in_frame {
        println!(
            "观测站:彗星{}镜,位于 [{:.0}, {:.0}]。",
            if inside { "入" } else { "出" },
            comet.translation.x,
            comet.translation.y
        );
        *comet_in_frame = inside;
    }
}

Listing 12-13:完整示例——青石天文馆(src/main.rs)

console
cargo run -p ch12-transforms

窗口里太阳呼吸、双星绕日、月亮贴着地球画小圆、彗星侧着身子扫过穹顶;控制台同步记着台账:

text
老盖:天文馆开馆,诸星就位。
观测站:彗星入镜,位于 [299, 64]。
观测站:月亮 籍册 [55, 0],实测 [145, 148]
观测站:彗星出镜,位于 [-301, 62]。
观测站:月亮 籍册 [55, 0],实测 [-146, 190]
观测站:彗星入镜,位于 [-300, -63]。
观测站:月亮 籍册 [55, 0],实测 [-278, 72]
观测站:彗星出镜,位于 [301, -61]。

总装截图:胀大的金色太阳斜在中央,水星在上方,蓝色地球带着小月亮在左侧,浅色长条彗星在右下方斜着身子爬升

Figure 12-12:天文馆开馆实拍——太阳正胀着身子,月亮贴在地球身边,彗星斜着机身爬升

三处值得回头多看一眼:

  • 月亮的籍册一动不动,实测每报必变Transform 永远是那句“离月亮盘 55”;GlobalTransform 是三层转盘逐级相乘的结果,帧帧不同——两本账的分工浓缩在这一行输出里。顺带一提:观测站挂在 Update,读到的实测是上一帧传播的结果,对一份两秒一报的台账毫无影响——这正是“旧一帧”该被坦然接受的场合;
  • 彗星出入镜的坐标全压着 ±300 的边线。取景框 Rect 半宽正是 300——几何判断与运动各管各的,互不知情却严丝合缝。报告只在 contains 的结果翻转时开口,盯帧死循环里塞打印而不刷屏,靠的是 Local 记住上回的状态(第 4 章的旧识);
  • 运动代码只有三个系统,没有一个知道“谁绕着谁”spin 不挑实体,pulse 只认太阳,fly_comet 自顾自飞——轨道结构整个住在 setup 的那棵 children! 树里。运动是局部的,几何是层级的;改剧本(加一颗火星、给月亮再挂卫星)动的是树,不是逻辑。

小结

  • 坐标系:原点在窗口中心,+X 右、+Y 上(与屏幕坐标的 y 向下相反)、+Z 出屏,右手系;2D 与 3D 同一套约定,实体与相机的“前方”是 −Z。2D 里 z 是图层号——z 大的画在前,默认相机只见 ±1000
  • Vec2/Vec3:位置与箭头共用的类型。位置相减得箭头;length/distance 量长短(比大小用 length_squared),normalize 留方向(防零向量),dot 答“多同向”,lerp 取中间,extend/truncate 在 2D/3D 之间补 z、截 z
  • Transform 三件套:translationrotation: Quatscale,构造走 from_xyz().with_rotation().with_scale() 链。改法两派:累加(rotate_zrotate_around,速度记得乘 delta_secs)与盖写(按时间公式赋值)。rotate_around 自带姿态同转,只挪位置用 translate_around
  • 朝向:选一根局部轴当“脸”,Quat::from_rotation_arc 把它拧到目标方向;查询当前朝向用 up()/right()/forward()Dir2/Dir3 是保证归一化的方向护照,零向量在构造时被 Err 拦下;2D 平面数学用 Rot2,但 Transform::rotation 永远是 Quat
  • 层级与传播:子实体的 Transform 是相对父亲的局部坐标——“相对运动”用树描述,不用代码追。引擎在 PostUpdateTransformSystems::Propagate 集合)沿树累乘出世界坐标 GlobalTransform:你写局部账,引擎折算世界账。同帧早于传播读世界账会旧一帧;在意时排到传播之后,或用 TransformHelper 现算
  • B0004:孩子有沿树继承的组件而父亲没有——继承链断档。缺 Transform 的父亲让全家瘫在原点;缺 Visibility 的暂时无症状、日后“隐藏全家”失灵。空实体当爹,Transform + Visibility 二件套带齐
  • 几何原语Rect 管平面范围(contains/union/intersect),primitives 模块的 Circle/Annulus 等纯数学形状会量周长(Measured2d)、会归位越界点(closest_point);它们都蹲在自己的原点,提问前先换算坐标。第 15 章起,同一批类型将变成看得见的网格

练习

  1. 潮汐锁定:运行 Listing 12-13,盯住月亮——它在公转中缓缓转着身子(被月亮盘带的)。给月亮自己挂上 Spin { speed: -2.0 }(与月亮盘等速反向),先预测它的姿态会怎样,再运行验证。
  2. 地球的自转:直接给 Listing 12-13 的地球挂 Spin { speed: 3.0 },预测月亮会发生什么惨剧,运行印证。然后参考正文“锚点–本体分离”的思路修复:在地球的位置放一个不带 Sprite 的锚点实体,地球本体和月亮盘都挂它名下——本体随便转,月亮不受牵连。
  3. 巨人国:给 Listing 12-9 的地球轨道盘补一个 .with_scale(Vec3::splat(1.5)),先预测哪些东西会变大(只有地球?月亮呢?月亮的轨道半径呢?),运行对答案,用“传播是逐级相乘”解释所见。
  4. 彗星警报:给 Listing 12-13 的观测站加一条规则:当彗星的运动方向与“彗星指向太阳的方向”点积大于 0.99 时,打印“彗星正全速冲向太阳!”——差分方向的手法在 fly_comet 里,点积的手法在 Listing 12-3。小心别让它每帧刷屏。

太阳系仪交付,但有一处粗糙被小满记在本子上:取景框是写死的 Rect,跟真正的窗口边界、跟“画面里到底看得见什么”毫无瓜葛。管这件事的器材叫相机——下一章拆开这台取景器:Camera2dCamera3d、正交与透视投影、视口与多相机分屏。