天文馆开馆
全章零件总装。结构图先挂墙上:太阳带 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、截 zTransform三件套:translation、rotation: Quat、scale,构造走from_xyz().with_rotation().with_scale()链。改法两派:累加(rotate_z、rotate_around,速度记得乘delta_secs)与盖写(按时间公式赋值)。rotate_around自带姿态同转,只挪位置用translate_around- 朝向:选一根局部轴当“脸”,
Quat::from_rotation_arc把它拧到目标方向;查询当前朝向用up()/right()/forward()。Dir2/Dir3是保证归一化的方向护照,零向量在构造时被Err拦下;2D 平面数学用Rot2,但Transform::rotation永远是Quat - 层级与传播:子实体的
Transform是相对父亲的局部坐标——“相对运动”用树描述,不用代码追。引擎在PostUpdate(TransformSystems::Propagate集合)沿树累乘出世界坐标GlobalTransform:你写局部账,引擎折算世界账。同帧早于传播读世界账会旧一帧;在意时排到传播之后,或用TransformHelper现算 - B0004:孩子有沿树继承的组件而父亲没有——继承链断档。缺
Transform的父亲让全家瘫在原点;缺Visibility的暂时无症状、日后“隐藏全家”失灵。空实体当爹,Transform+Visibility二件套带齐 - 几何原语:
Rect管平面范围(contains/union/intersect),primitives模块的Circle/Annulus等纯数学形状会量周长(Measured2d)、会归位越界点(closest_point);它们都蹲在自己的原点,提问前先换算坐标。第 15 章起,同一批类型将变成看得见的网格
练习
- 潮汐锁定:运行 Listing 12-13,盯住月亮——它在公转中缓缓转着身子(被月亮盘带的)。给月亮自己挂上
Spin { speed: -2.0 }(与月亮盘等速反向),先预测它的姿态会怎样,再运行验证。 - 地球的自转:直接给 Listing 12-13 的地球挂
Spin { speed: 3.0 },预测月亮会发生什么惨剧,运行印证。然后参考正文“锚点–本体分离”的思路修复:在地球的位置放一个不带Sprite的锚点实体,地球本体和月亮盘都挂它名下——本体随便转,月亮不受牵连。 - 巨人国:给 Listing 12-9 的地球轨道盘补一个
.with_scale(Vec3::splat(1.5)),先预测哪些东西会变大(只有地球?月亮呢?月亮的轨道半径呢?),运行对答案,用“传播是逐级相乘”解释所见。 - 彗星警报:给 Listing 12-13 的观测站加一条规则:当彗星的运动方向与“彗星指向太阳的方向”点积大于 0.99 时,打印“彗星正全速冲向太阳!”——差分方向的手法在
fly_comet里,点积的手法在 Listing 12-3。小心别让它每帧刷屏。
太阳系仪交付,但有一处粗糙被小满记在本子上:取景框是写死的 Rect,跟真正的窗口边界、跟“画面里到底看得见什么”毫无瓜葛。管这件事的器材叫相机——下一章拆开这台取景器:Camera2d 与 Camera3d、正交与透视投影、视口与多相机分屏。