开幕与闭幕
现在的程序一开窗就开打,球丢完就装死。真实的游戏有全局阶段——第 10 章街机厅末尾立过字据:“第 20 章的 Breakout 用的就是本章这套骨架:Menu / Playing / GameOver 三态加暂停子状态,菜单 UI 与局内实体各挂各的 DespawnOnExit。”兑现的时候到了。暂停子状态留给下一节(它得和声音一起拧),本节先立三态,把“一局”这个概念造出来。
三态与战果
/// 全场只有三种活法:后台待客、一局进行中、一局收场
#[derive(States, Clone, PartialEq, Eq, Hash, Debug, Default)]
enum GameState {
#[default]
Menu,
Playing,
GameOver,
}
/// 一局的结论——闭幕的瞬间写下,结算屏(和下一节的锣鼓)都看它
#[derive(Resource, Clone, Copy)]
enum Outcome {
/// 瓦砸完了
Cleared,
/// 绣球用尽
Spilled,
}Listing 20-6(其一):GameState 三态与 Outcome 战果(examples/listing-20-06.rs)
#[derive(States)] 的全家桶、#[default] 的起点,都是第 10 章的熟面孔。Outcome 是个普通资源:闭幕的那一瞬由判定系统写下,结算屏(和下一节的锣鼓)只看它的脸色。也可以把战果塞进状态本身(GameOver { won: bool }——第 10 章见过带数据的状态),但那样 OnEnter 就得按值写两份;一份资源配一个干净的 GameOver,注册少一半。
搭台拆台:DespawnOnExit 全员挂牌
setup_court 从 Startup 搬家到 OnEnter(GameState::Playing),函数体几乎没动——只是每个 spawn 多挂一行 DespawnOnExit(GameState::Playing):墙、凳、瓦、球、字牌,一个不漏。这正是第 10.3 节的标准搭配:OnEnter 搭台,DespawnOnExit 挂牌,离开 Playing 的那一次转换里,引擎替你把台拆得干干净净;“再来一局”就是再进一次 Playing,搭的是全新的台。
球的生成单独立了个小铺面,因为往后它不止开局用一次:
/// 粘在凳上待发的球
#[derive(Component)]
struct Glued;
/// 还剩几只绣球
#[derive(Resource, Default)]
struct Lives(u32);
/// 球的铸模:圆网格与红材质的提货单,补球时直接克隆(第 14 章的省钱诀窍)
#[derive(Resource)]
struct BallStock {
mesh: Handle<Mesh>,
material: Handle<ColorMaterial>,
}
/// 出一只新绣球:趴在凳上等发球
fn spawn_ball(commands: &mut Commands, stock: &BallStock) {
commands.spawn((
Ball,
Glued,
Velocity(Vec2::ZERO),
Mesh2d(stock.mesh.clone()),
MeshMaterial2d(stock.material.clone()),
Transform::from_xyz(0.0, GLUE_Y, 1.0),
DespawnOnExit(GameState::Playing),
));
}Listing 20-6(其二):Glued、Lives 与球的铸模
BallStock 存着球的网格与材质的提货单——第 14 章说过 clone 提货单只加计数不复制货,落沟补球时直接克隆,不必再开模。新球带着 Glued 出生:趴在凳上,速度为零,等一声令下。
发球:瞬时输入过鼓点
发球键是这个项目第一个瞬时输入——just_pressed 只真一帧,而消费它的系统住在 FixedUpdate。第 18.5 节开堂审过这桩案子:直接在鼓点里读快照,会丢拍或重拍。药方当时就抓好了:每帧必跑的收集系统把瞬时攒进意图,鼓点上消费、消费即清零。我们的收集站从 20.1 节就立在 BeforeFixedMainLoop,现在只是给 Intent 添一项:
/// 意图层添了一项瞬时意图:发球
#[derive(Resource, Default)]
struct Intent {
steer: f32,
/// 这一拍要不要发球——收集系统攒着,鼓点上消费(18.5 的账)
serve: bool,
}
fn collect_intent(
keyboard: Res<ButtonInput<KeyCode>>,
gamepads: Query<&Gamepad>,
mut intent: ResMut<Intent>,
) {
let mut steer = 0.0;
if keyboard.any_pressed([KeyCode::KeyA, KeyCode::ArrowLeft]) {
steer -= 1.0;
}
if keyboard.any_pressed([KeyCode::KeyD, KeyCode::ArrowRight]) {
steer += 1.0;
}
if keyboard.just_pressed(KeyCode::Space) {
intent.serve = true; // 攒着,等鼓点来收
}
for gamepad in &gamepads {
let stick = gamepad.left_stick().x;
if stick.abs() > 0.15 {
steer += stick;
}
steer += gamepad.dpad().x;
if gamepad.just_pressed(GamepadButton::South) {
intent.serve = true;
}
}
intent.steer = steer.clamp(-1.0, 1.0);
}Listing 20-6(其三):serve 入账——攒着,等鼓点来收
消费端两件事:贴着凳走,听令出发。
/// 待发的球跟着凳走
fn follow_paddle(
paddle: Single<&Transform, With<Paddle>>,
mut glued: Query<&mut Transform, (With<Glued>, Without<Paddle>)>,
) {
for mut transform in &mut glued {
transform.translation.x = paddle.translation.x;
}
}
/// 发球:消费 serve 意图——没人接就作废,不留到下一拍
fn serve_ball(
mut intent: ResMut<Intent>,
mut commands: Commands,
mut glued: Query<(Entity, &mut Velocity), With<Glued>>,
) {
let pressed = std::mem::take(&mut intent.serve); // 消费即清零
if !pressed {
return;
}
for (entity, mut velocity) in &mut glued {
velocity.0 = SERVE_DIRECTION.normalize() * BALL_SPEED;
commands.entity(entity).remove::<Glued>();
}
}Listing 20-6(其四):follow_paddle 与 serve_ball——mem::take 消费即清零
std::mem::take 把 serve 读出来的同时归零——不管有没有球可发。这是有意的:球在半空时按下的空格不该被记账,否则下一只球一出生就被一拍旧令发射出去。follow_paddle 的查询里那个 Without<Paddle>(第 4 章)让两个 Transform 借用井水不犯河水。
沟与胜负
底边那道口子,现在开始吃球:
/// 盯着沟:球掉下去就收走;还有绣球就补一只,没有就闭幕
fn watch_gutter(
mut commands: Commands,
mut lives: ResMut<Lives>,
mut knocks: MessageWriter<Knock>,
mut next_state: ResMut<NextState<GameState>>,
stock: Res<BallStock>,
ball: Single<(Entity, &Transform), With<Ball>>,
) {
let (entity, transform) = *ball;
if transform.translation.y > GUTTER_Y {
return;
}
commands.entity(entity).despawn();
knocks.write(Knock::Gutter);
lives.0 -= 1;
if lives.0 > 0 {
println!("场记:一只绣球喂了沟——还剩 {} 只。", lives.0);
spawn_ball(&mut commands, &stock);
} else {
commands.insert_resource(Outcome::Spilled);
next_state.set(GameState::GameOver);
}
}
/// 盯着瓦阵:一片不剩就是满堂彩
fn check_cleared(
bricks: Query<(), With<Health>>,
mut commands: Commands,
mut next_state: ResMut<NextState<GameState>>,
) {
if bricks.is_empty() {
commands.insert_resource(Outcome::Cleared);
next_state.set(GameState::GameOver);
}
}Listing 20-6(其五):watch_gutter 与 check_cleared——两位裁判
watch_gutter 是第五种动静 Knock::Gutter 的写者:球过线,收走、记账、看命数——还有绣球就补一只新的(克隆铸模),没有就写下 Outcome::Spilled、申请闭幕。check_cleared 盯着另一头:场上再无 Health,就是满堂彩。两个细节:
- 球被收走之后呢?接下来的几拍里
Single<…, With<Ball>>匹配数为零——第 4 章说过Single失配时系统静默跳过。碰撞、落沟两个系统自动歇业,新球落地它们自动复工,一行专门的代码都不用写; - 最后一片瓦碎在哪一拍,哪一拍就能宣布满堂彩。
despawn是Commands,但第 6.5 节讲过:链上有排序约束时,调度器会自动插同步点——check_collisions排在check_cleared前面,命令在两者之间落地,胜利不会迟到一拍。还有个边角:最后一片瓦和最后一只球同拍出事时,check_cleared后跑、覆盖掉Spilled——清完即胜,规则书写在系统顺序里。
两块幕布
菜单和结算屏是两棵 Text2d 小树:根实体只有 Transform 加 Visibility,文字都是子实体(children!,第 9 章),DespawnOnExit 挂在根上——第 10.3 节说过,despawn 带着整棵子树走,一张牌托管全家:
/// 后台招牌:进 Menu 搭,出 Menu 引擎拆——DespawnOnExit 挂在根上,整棵树托管
fn show_menu(mut commands: Commands, asset_server: Res<AssetServer>) {
let bold = asset_server.load("fonts/book-sans-sc-bold.otf");
let regular = asset_server.load("fonts/book-sans-sc-regular.otf");
commands.spawn((
DespawnOnExit(GameState::Menu),
Transform::default(),
Visibility::default(),
children![
(
Text2d::new("打 瓦"),
TextFont {
font: FontSource::Handle(bold),
font_size: FontSize::Px(110.0),
..default()
},
TextColor(TEXT_COLOR),
Transform::from_xyz(0.0, 110.0, 5.0),
),
(
Text2d::new("夜戏散场后的保留节目"),
TextFont {
font: FontSource::Handle(regular.clone()),
font_size: FontSize::Px(26.0),
..default()
},
TextColor(MUTED_COLOR),
Transform::from_xyz(0.0, 28.0, 5.0),
),
(
Text2d::new("空格 开局 Esc 离场"),
TextFont {
font: FontSource::Handle(regular),
font_size: FontSize::Px(30.0),
..default()
},
TextColor(TEXT_COLOR),
Transform::from_xyz(0.0, -96.0, 5.0),
),
],
));
}
/// 结算屏:标题看 Outcome,分数行读 Score
fn show_curtain(
mut commands: Commands,
asset_server: Res<AssetServer>,
outcome: Res<Outcome>,
score: Res<Score>,
) {
let (headline, verdict) = match *outcome {
Outcome::Cleared => ("满堂彩!", format!("{TOTAL_BRICKS} 片瓦,一片不剩")),
Outcome::Spilled => ("绣球散尽", format!("这局砸下 {} 片瓦", score.0)),
};
println!("场记:{headline}——{verdict}。");
commands.spawn((
DespawnOnExit(GameState::GameOver),
Transform::default(),
Visibility::default(),
children![
(
Text2d::new(headline),
TextFont {
font: FontSource::Handle(asset_server.load("fonts/book-sans-sc-bold.otf")),
font_size: FontSize::Px(84.0),
..default()
},
TextColor(TEXT_COLOR),
Transform::from_xyz(0.0, 90.0, 5.0),
),
(
Text2d::new(verdict),
TextFont {
font: FontSource::Handle(asset_server.load("fonts/book-sans-sc-regular.otf")),
font_size: FontSize::Px(30.0),
..default()
},
TextColor(MUTED_COLOR),
Transform::from_xyz(0.0, 10.0, 5.0),
),
(
Text2d::new("空格 再来一局 Esc 回后台"),
TextFont {
font: FontSource::Handle(asset_server.load("fonts/book-sans-sc-regular.otf")),
font_size: FontSize::Px(30.0),
..default()
},
TextColor(TEXT_COLOR),
Transform::from_xyz(0.0, -96.0, 5.0),
),
],
));
}Listing 20-6(其六):后台招牌与结算屏——OnEnter 搭,DespawnOnExit 拆
show_curtain 读 Outcome 定标题、读 Score 报战果——记住这两个参数,20.7 节它们要当一回案件主角。开关状态机的钥匙不多,三把:
/// 后台的键:空格开局,Esc 离场
fn menu_keys(
keyboard: Res<ButtonInput<KeyCode>>,
mut next_state: ResMut<NextState<GameState>>,
mut exit: MessageWriter<AppExit>,
) {
if keyboard.just_pressed(KeyCode::Space) {
next_state.set(GameState::Playing);
}
if keyboard.just_pressed(KeyCode::Escape) {
println!("老雷:收摊。各回各屋。");
exit.write(AppExit::Success);
}
}
/// 结算屏的键:空格再来,Esc 回后台
fn curtain_keys(keyboard: Res<ButtonInput<KeyCode>>, mut next_state: ResMut<NextState<GameState>>) {
if keyboard.just_pressed(KeyCode::Space) {
next_state.set(GameState::Playing);
}
if keyboard.just_pressed(KeyCode::Escape) {
next_state.set(GameState::Menu);
}
}Listing 20-6(其七):menu_keys 与 curtain_keys——NextState 申请单与 AppExit 谢幕
NextState::set 填申请单、下一帧帧首生效,AppExit 用消息谢幕——第 10 章与第 7.5 节的原样复用。装配全景:
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "打瓦".into(),
..default()
}),
..default()
}))
.insert_resource(ClearColor(BACKDROP))
.init_state::<GameState>()
.init_resource::<Intent>()
.init_resource::<Score>()
.init_resource::<Lives>()
.add_message::<Knock>()
.add_systems(Startup, (rig_camera, greet))
.add_systems(OnEnter(GameState::Menu), show_menu)
.add_systems(OnEnter(GameState::Playing), (setup_court, rig_scoreboard))
.add_systems(OnEnter(GameState::GameOver), show_curtain)
.add_systems(
RunFixedMainLoop,
collect_intent
.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop)
.run_if(in_state(GameState::Playing)),
)
.add_systems(
FixedUpdate,
(
move_paddle,
follow_paddle,
serve_ball,
apply_velocity,
check_collisions,
watch_gutter,
check_cleared,
)
.chain()
.run_if(in_state(GameState::Playing)),
)
.add_systems(
Update,
(
menu_keys.run_if(in_state(GameState::Menu)),
curtain_keys.run_if(in_state(GameState::GameOver)),
(
tally,
refresh_scoreboard.run_if(resource_changed::<Score>),
refresh_lives.run_if(resource_changed::<Lives>),
)
.chain(),
),
)
.run();
}Listing 20-6(其八):注册全景——三个 OnEnter 搭台,run_if(in_state) 把守两个调度
读这段注册时盯住两类挂载点的分工(第 10 章的总纲):换幕瞬间的活(搭台)全在 OnEnter;逐帧的活(物理、按键、记分)全靠 run_if 把门——collect_intent 和整条固定调度链挂 in_state(GameState::Playing),菜单里没有物理;menu_keys 和 curtain_keys 各守各的状态。相机搬去了 Startup:它不属于任何一局,全场只此一台。
Figure 20-7:状态机全图——子状态那一半下一节点亮
开张试营业
cargo run -p ch20-breakout --example listing-20-06老雷:夜戏散了,伙计们后台耍一局《打瓦》——空格开局。
场记:开台——56 片瓦,3 只绣球。
场记:头一片,开张。
场记:一只绣球喂了沟——还剩 2 只。
场记:一只绣球喂了沟——还剩 1 只。
场记:绣球散尽——这局砸下 5 片瓦。
场记:开台——56 片瓦,3 只绣球。
Figure 20-8:开幕与闭幕——两块幕布都是 OnEnter 搭的台
亲手把流程走一遍:空格开局,球乖乖趴在凳上随你走;再按空格,球离凳上天。三只绣球都喂了沟,结算屏落下来报战果;空格再来一局——上一局的残瓦、旧球、字牌全无踪影,台是新的,账也是新的(上面那段输出正是这么跑出来的:散尽之后再开台)。Esc 回后台,后台再 Esc,散伙。
状态机闭环了。还差两笔账:球撞了半天,一声不吭;玩到一半想去倒杯水,没有暂停。第 19 章欠的三件套,下一节连本带利。