Skip to content

记分:让台上的事传出去

记分最顺手的写法,是给 check_collisions 再塞一个 ResMut<Score>,碎瓦处 +1。能跑。但往后看一节就知道这条路的尽头:碎瓦还要响一声脆的(下下节的音效),里程碑还要上台词,闭幕还要清点战果——全走这条路,碰撞系统就得认识记分牌、认识音响、认识结算屏,一个物理系统长成了总调度。

第 7 章碰碰车场给过答案:车手报告事实,DJ 和记分员各取所需。碰撞系统只负责把“发生了什么”喊出去,谁爱听谁听。当年 DJ 喊的是一行 println!,今天换成真账本、真锣鼓,而写读两端的代码形状一点没变。

Knock:台上的动静

rust
/// 台上的一声动静。碰撞系统只管写,谁爱听谁听——第 7 章碰碰车场的解耦
#[derive(Message)]
enum Knock {
    /// 球撞墙
    Wall,
    /// 球撞凳
    Paddle,
    /// 筒瓦掉了釉,还没碎
    Crack,
    /// 一片瓦碎了
    Shatter,
}

/// 战果:碎了几片瓦
#[derive(Resource, Default)]
struct Score(u32);

Listing 20-5(其一):Knock 消息与 Score 资源(examples/listing-20-05.rs)

变体的措辞值得停一秒:是 Shatter(瓦碎了),不是 AddScorePlaySound消息说事实,不下指令——写消息的人不预设有几个读者、各自要干什么,这正是解耦能成立的全部前提。眼下四种动静,20.5 节沟开始吃球时会添第五种。

碰撞系统瘦身

rust
/// 碰撞系统现在只剩本职:反弹、扣耐久,再把动静报出去
fn check_collisions(
    mut commands: Commands,
    mut knocks: MessageWriter<Knock>,
    ball: Single<(&Transform, &mut Velocity), With<Ball>>,
    mut colliders: Query<(
        Entity,
        &Transform,
        &Collider,
        Has<Paddle>,
        Option<(&mut Sprite, &mut Health)>,
    )>,
) {
    let (ball_transform, mut velocity) = ball.into_inner();
    let bounding = BoundingCircle::new(ball_transform.translation.truncate(), BALL_RADIUS);

    for (entity, transform, collider, is_paddle, brick) in &mut colliders {
        let target = Aabb2d::new(transform.translation.truncate(), collider.size / 2.0);
        let Some(side) = hit_side(bounding, target) else {
            continue;
        };
        let (reflect_x, reflect_y) = match side {
            Side::Left => (velocity.x > 0.0, false),
            Side::Right => (velocity.x < 0.0, false),
            Side::Top => (false, velocity.y < 0.0),
            Side::Bottom => (false, velocity.y > 0.0),
        };
        if reflect_x {
            velocity.x = -velocity.x;
        }
        if reflect_y {
            velocity.y = -velocity.y;
        }

        match brick {
            Some((mut sprite, mut health)) => {
                health.0 -= 1;
                if health.0 == 0 {
                    commands.entity(entity).despawn();
                    knocks.write(Knock::Shatter);
                } else {
                    sprite.color = TILE_COLOR;
                    knocks.write(Knock::Crack);
                }
            }
            None => {
                knocks.write(if is_paddle {
                    Knock::Paddle
                } else {
                    Knock::Wall
                });
            }
        }
    }
}

Listing 20-5(其二):check_collisions 只剩本职——反弹、扣耐久、报信

Has<Paddle>(第 4 章)区分凳与墙,MessageWriter(第 7 章)把每一声动静投进通道。留意一个跨调度细节:消息写在 FixedUpdate,读者住 Update——第 7 章讲双缓冲时说过,带 TimePlugin 的 App 会对固定调度“特殊照顾”,消息保证一条不丢。

记分牌

读者侧两个系统,一个记账、一个写牌:

rust
/// 记分牌(左上角那行字)
#[derive(Component)]
struct ScoreBoard;

/// 听 Knock 记账:只有 Shatter 算分,里程碑配一句台词
fn tally(mut knocks: MessageReader<Knock>, mut score: ResMut<Score>) {
    for knock in knocks.read() {
        if matches!(knock, Knock::Shatter) {
            score.0 += 1;
            match score.0 {
                1 => println!("场记:头一片,开张。"),
                28 => println!("场记:过半了——还剩 28 片。"),
                50 => println!("场记:还剩 6 片,稳着点。"),
                _ => {}
            }
        }
    }
}

/// 分数变了才重排版——第 5 章的资源变更检测在调度层把关
fn refresh_scoreboard(score: Res<Score>, mut board: Single<&mut Text2d, With<ScoreBoard>>) {
    board.0 = format!("瓦 {}/{}", score.0, TOTAL_BRICKS);
}

Listing 20-5(其三):tally 记账、refresh 写牌——闸门是 resource_changed

tally 只认 Shatter,顺手在里程碑处开口——场记的台词从本节起退居二线,只在节点出声。refresh_scoreboard 挂着 run_if(resource_changed::<Score>)(第 5 章的资源变更检测):第 16 章警告过“改字就重排版”,没人碎瓦的那几百帧,这个系统连门都不进。注册时两者 .chain(),记完账立刻写牌,同帧见数。

牌子本身是一行 Text2d,锚点 CENTER_LEFT 钉在顶墙左上方(第 16 章的字、第 15 章的锚点语义):

rust
/// 挂记分牌:一行 Text2d,钉在顶墙左上方
fn rig_scoreboard(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn((
        ScoreBoard,
        Text2d::new(format!("瓦 0/{TOTAL_BRICKS}")),
        TextFont {
            font: FontSource::Handle(asset_server.load("fonts/book-sans-sc-bold.otf")),
            font_size: FontSize::Px(30.0),
            ..default()
        },
        TextColor(TEXT_COLOR),
        Anchor::CENTER_LEFT,
        Transform::from_xyz(LEFT_WALL - WALL_THICKNESS / 2.0, BOARD_Y, 5.0),
    ));
}

Listing 20-5(其四):记分牌——字模资产来自第 16 章的家当

字体用 asset_server.load 按路径取——第 14 章说过 AssetServer 按路径去重,后面菜单、结算屏处处要字,尽管放心重复 load,仓库里只有一份。

rust
fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "打瓦".into(),
                ..default()
            }),
            ..default()
        }))
        .insert_resource(ClearColor(BACKDROP))
        .init_resource::<Intent>()
        .init_resource::<Score>()
        .add_message::<Knock>()
        .add_systems(Startup, (setup_court, rig_scoreboard))
        .add_systems(
            RunFixedMainLoop,
            collect_intent.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
        )
        .add_systems(
            FixedUpdate,
            (move_paddle, apply_velocity, check_collisions).chain(),
        )
        .add_systems(
            Update,
            (tally, refresh_scoreboard.run_if(resource_changed::<Score>)).chain(),
        )
        .run();
}

Listing 20-5(其五):注册——add_message、init_resource,与 Update 里的记分二人组

运行:

console
cargo run -p ch20-breakout --example listing-20-05
text
老雷:散场不散人——后台的《打瓦》摊子支起来了。
场记:头一片,开张。

战局中的一帧:左上角记分牌写着瓦 3/56,瓦阵底行恰好缺了三片

Figure 20-6:记分牌与瓦阵对账——缺几片,牌上就记几片

对照 Figure 20-6 数一数:缺口三个,牌上写 3。碰撞系统不认识这块牌子,牌子也不知道瓦长什么样,中间只隔着一条消息通道——下下节音响接进来的时候,你会再次体会这条通道的好处。

不过球一掉,这一切就僵住了:分数停在原地,台上空无一球,程序还活着,游戏却“没了”。它缺的不是哪个系统,而是阶段——开局、进行、收场。该立状态机了。