Skip to content

搭台:场地与条凳

万事从一个空 main.rs 开始。第一晚的目标很克制:把台面围出来,把条凳摆上去,让它听使唤。

常量先行

打砖块的场地是一堆“写死的几何”:墙在哪、凳多大、球多快。把它们全部提成常量、集中放在文件头上,是官方 breakout.rs 的做法,也是值得照搬的工程习惯——后面每一节都会回来改这几行,而不是满文件找魔法数字:

rust
// 场地几何:默认 Camera2d 下 1 个世界单位 = 1 逻辑像素,原点在窗口正中
const WALL_THICKNESS: f32 = 14.0;
const LEFT_WALL: f32 = -430.0; // 左右墙与顶墙的中心线
const RIGHT_WALL: f32 = 430.0;
const TOP_WALL: f32 = 250.0;
const ARENA_BOTTOM: f32 = -370.0; // 台口下沿:侧墙垂到这里,底边不封口

const PADDLE_SIZE: Vec2 = Vec2::new(110.0, 18.0);
const PADDLE_Y: f32 = -280.0;
const PADDLE_SPEED: f32 = 520.0;
const PADDLE_MARGIN: f32 = 8.0; // 凳腿离墙的最小空隙

const BACKDROP: Color = Color::srgb(0.10, 0.10, 0.14); // 夜幕
const WALL_COLOR: Color = Color::srgb(0.38, 0.32, 0.27); // 戏台木框
const PADDLE_COLOR: Color = Color::srgb(0.84, 0.65, 0.37); // 条凳木色

Listing 20-1(其一):场地几何与配色——一处声明,处处引用(examples/listing-20-01.rs)

坐标的语境是第 12、13 章定下的:默认 Camera2d 下原点在窗口正中、y 向上,1 个世界单位对应 1 逻辑像素。所以 TOP_WALL = 250.0 就是“顶墙中心线在窗口中心上方 250 像素”。注意 ARENA_BOTTOM 只是侧墙垂到的深度——底边没有墙。那道口子是沟,是这个游戏全部紧张感的来源,第 20.5 节它会开始吃球。

组件只有一个。条凳是场上唯一听玩家使唤的东西,给它一个标记(第 3 章的老手艺):

rust
/// 条凳:场上唯一听玩家使唤的东西
#[derive(Component)]
struct Paddle;

Listing 20-1(其二):Paddle 标记组件

输入骨架:第一天就立对

最偷懒的推凳写法是在系统里直接查 ButtonInput<KeyCode>。能跑,但第 17 章末尾的教训言犹在耳:设备代码和玩法代码一旦长在一起,撕开就要动刀。这个项目从第一行就按意图层的骨架搭——设备只负责翻译,玩法只认意图:

rust
/// 意图层(第 17 章的骨架):设备只管往里写,玩法只管从里读
#[derive(Resource, Default)]
struct Intent {
    /// 推凳方向:-1.0(左)到 +1.0(右),每帧由设备重写
    steer: f32,
}

/// 收集这一帧手上的输入——键盘给数字量,摇杆给模拟量,殊途同归
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;
    }
    for gamepad in &gamepads {
        let stick = gamepad.left_stick().x;
        if stick.abs() > 0.15 {
            steer += stick; // 摇杆的死区自己留——第 17 章的账
        }
        steer += gamepad.dpad().x;
    }
    intent.steer = steer.clamp(-1.0, 1.0);
}

Listing 20-1(其三):意图层——键盘的数字量与摇杆的模拟量殊途同归

Intent 眼下只有一项 steer,每帧由 collect_intent 清账重写(第 17 章说的“瞬时意图”寿命规矩)。键盘贡献 ±1 的数字量,手柄的摇杆把模拟量原样递交(死区照第 17 章的账自己留),最后 clamp 进 [-1, 1]——家里有手柄的读者,此刻已经可以用摇杆推凳了,这就是意图层“加设备是纯增量”的甜头。

collect_intent 的住址有讲究。推凳是要参与碰撞的物理运动,第 18 章的口诀说它该住 FixedUpdate;而输入收集必须每帧跑、还得赶在本帧鼓点之前——第 18.5 节给过现成答案:RunFixedMainLoopSystems::BeforeFixedMainLoop。骨架立对了,等 20.5 节发球键(一个真正的瞬时输入)加进来时,一行缓冲代码都不用补。

推凳

rust
/// 推凳:在鼓点上结算位移,两头让墙拦住
fn move_paddle(
    intent: Res<Intent>,
    time: Res<Time>,
    mut paddle: Single<&mut Transform, With<Paddle>>,
) {
    let reach = RIGHT_WALL - WALL_THICKNESS / 2.0 - PADDLE_SIZE.x / 2.0 - PADDLE_MARGIN;
    let next = paddle.translation.x + intent.steer * PADDLE_SPEED * time.delta_secs();
    paddle.translation.x = next.clamp(-reach, reach);
}

Listing 20-1(其四):move_paddle——速度乘固定步长,两头让墙拦住

Single(第 4 章)拿到唯一的条凳;Res<Time>FixedUpdate 里自动照出固定钟(第 18.4 节的镜子),每拍恰好一个步长。reach 的算式值得读一遍:右墙中心线,减半个墙厚,减半个凳长,再留 PADDLE_MARGIN 的缝——凳子最远只能贴到墙根,clamp 一行钉死。

装配与开张

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>()
        .add_systems(Startup, setup_court)
        .add_systems(
            RunFixedMainLoop,
            collect_intent.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
        )
        .add_systems(FixedUpdate, move_paddle)
        .run();
}

Listing 20-1(其五):App 装配——窗口标题、清屏色、两个调度里的三个系统

setup_court 负责搭台。三面墙没有专门的组件——它们目前只是三张 Sprite::from_color 的色块(第 15 章),位置与尺寸从常量算出来,用一个数组循环生成:

rust
/// 搭台:相机、三面墙、一条凳
fn setup_court(mut commands: Commands) {
    commands.spawn(Camera2d);

    // 三面墙:左右两面垂到台口下沿,顶上一面横贯;底边不封——那是沟
    let side_height = TOP_WALL - ARENA_BOTTOM + WALL_THICKNESS;
    let side_center_y = (TOP_WALL + ARENA_BOTTOM) / 2.0;
    let top_width = RIGHT_WALL - LEFT_WALL + WALL_THICKNESS;
    let walls = [
        (
            Vec2::new(LEFT_WALL, side_center_y),
            Vec2::new(WALL_THICKNESS, side_height),
        ),
        (
            Vec2::new(RIGHT_WALL, side_center_y),
            Vec2::new(WALL_THICKNESS, side_height),
        ),
        (
            Vec2::new(0.0, TOP_WALL),
            Vec2::new(top_width, WALL_THICKNESS),
        ),
    ];
    for (position, size) in walls {
        commands.spawn((
            Sprite::from_color(WALL_COLOR, size),
            Transform::from_translation(position.extend(0.0)),
        ));
    }

    // 条凳
    commands.spawn((
        Paddle,
        Sprite::from_color(PADDLE_COLOR, PADDLE_SIZE),
        Transform::from_xyz(0.0, PADDLE_Y, 0.0),
    ));

    println!("老雷:散场不散人——后台的《打瓦》摊子支起来了。");
}

Listing 20-1(其六):搭台——三面墙、一条凳

运行:

console
cargo run -p ch20-breakout --example listing-20-01
text
老雷:散场不散人——后台的《打瓦》摊子支起来了。

两幅并排截图:左幅条凳停在台面正中,右幅按住 D 后条凳贴到右墙根停下

Figure 20-2:搭台完成——条凳听键盘与摇杆使唤,推到头被墙夹住

夜色幕布、木框台面、一条会跑的凳子。A/D 或方向键推一推,顶到墙根就停——clamp 在干活。台子有了,缺一只球。