Skip to content

开幕与闭幕

现在的程序一开窗就开打,球丢完就装死。真实的游戏有全局阶段——第 10 章街机厅末尾立过字据:“第 20 章的 Breakout 用的就是本章这套骨架:Menu / Playing / GameOver 三态加暂停子状态,菜单 UI 与局内实体各挂各的 DespawnOnExit。”兑现的时候到了。暂停子状态留给下一节(它得和声音一起拧),本节先立三态,把“一局”这个概念造出来。

三态与战果

rust
/// 全场只有三种活法:后台待客、一局进行中、一局收场
#[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_courtStartup 搬家到 OnEnter(GameState::Playing),函数体几乎没动——只是每个 spawn 多挂一行 DespawnOnExit(GameState::Playing):墙、凳、瓦、球、字牌,一个不漏。这正是第 10.3 节的标准搭配:OnEnter 搭台,DespawnOnExit 挂牌,离开 Playing 的那一次转换里,引擎替你把台拆得干干净净;“再来一局”就是再进一次 Playing,搭的是全新的台。

球的生成单独立了个小铺面,因为往后它不止开局用一次:

rust
/// 粘在凳上待发的球
#[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 添一项:

rust
/// 意图层添了一项瞬时意图:发球
#[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 入账——攒着,等鼓点来收

消费端两件事:贴着凳走,听令出发。

rust
/// 待发的球跟着凳走
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::takeserve 读出来的同时归零——不管有没有球可发。这是有意的:球在半空时按下的空格不该被记账,否则下一只球一出生就被一拍旧令发射出去。follow_paddle 的查询里那个 Without<Paddle>(第 4 章)让两个 Transform 借用井水不犯河水。

沟与胜负

底边那道口子,现在开始吃球:

rust
/// 盯着沟:球掉下去就收走;还有绣球就补一只,没有就闭幕
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 失配时系统静默跳过。碰撞、落沟两个系统自动歇业,新球落地它们自动复工,一行专门的代码都不用写;
  • 最后一片瓦碎在哪一拍,哪一拍就能宣布满堂彩despawnCommands,但第 6.5 节讲过:链上有排序约束时,调度器会自动插同步点——check_collisions 排在 check_cleared 前面,命令在两者之间落地,胜利不会迟到一拍。还有个边角:最后一片瓦和最后一只球同拍出事时,check_cleared 后跑、覆盖掉 Spilled——清完即胜,规则书写在系统顺序里。

两块幕布

菜单和结算屏是两棵 Text2d 小树:根实体只有 TransformVisibility,文字都是子实体(children!,第 9 章),DespawnOnExit 挂在根上——第 10.3 节说过,despawn 带着整棵子树走,一张牌托管全家:

rust
/// 后台招牌:进 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_curtainOutcome 定标题、读 Score 报战果——记住这两个参数,20.7 节它们要当一回案件主角。开关状态机的钥匙不多,三把:

rust
/// 后台的键:空格开局,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 节的原样复用。装配全景:

rust
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_keyscurtain_keys 各守各的状态。相机搬去了 Startup:它不属于任何一局,全场只此一台。

《打瓦》状态机:Menu、Playing(内含 Running 与 Paused 子状态)、GameOver 三态及全部转换路径,各状态标注挂靠的 DespawnOnExit

Figure 20-7:状态机全图——子状态那一半下一节点亮

开张试营业

console
cargo run -p ch20-breakout --example listing-20-06
text
老雷:夜戏散了,伙计们后台耍一局《打瓦》——空格开局。
场记:开台——56 片瓦,3 只绣球。
场记:头一片,开张。
场记:一只绣球喂了沟——还剩 2 只。
场记:一只绣球喂了沟——还剩 1 只。
场记:绣球散尽——这局砸下 5 片瓦。
场记:开台——56 片瓦,3 只绣球。

两幅并排截图:左幅后台招牌“打瓦”与开局提示,右幅结算屏“绣球散尽——这局砸下 5 片瓦”

Figure 20-8:开幕与闭幕——两块幕布都是 OnEnter 搭的台

亲手把流程走一遍:空格开局,球乖乖趴在凳上随你走;再按空格,球离凳上天。三只绣球都喂了沟,结算屏落下来报战果;空格再来一局——上一局的残瓦、旧球、字牌全无踪影,台是新的,账也是新的(上面那段输出正是这么跑出来的:散尽之后再开台)。Esc 回后台,后台再 Esc,散伙。

状态机闭环了。还差两笔账:球撞了半天,一声不吭;玩到一半想去倒杯水,没有暂停。第 19 章欠的三件套,下一节连本带利。