Skip to content

三合一:输入映射

开张前夜,老雷验收,四个示例各开各的窗口。他只问了一句:“看客换设备还得换场子?一台戏,全都接。”

把前四节的系统原样堆进一个 App 当然能跑,但堆出来的东西没法养:walk 的逻辑写了三遍(键盘一遍、摇杆一遍、将来再来一遍),“出剑”散落在键盘系统和手柄系统里各管各的;哪天要做键位重绑定、要录像回放、要给阿燕配个 AI 陪练,会发现玩法代码和设备代码长在了一起,撕都撕不开。

工程上的答案叫输入映射(input mapping):在设备与玩法之间立一层“意图”。设备系统只做翻译——把各自的方言译成“想往哪走”“想不想出剑”;动作系统只认意图,不认识任何设备。本章压轴的体验场《来者不拒》就按这个骨架搭:

rust
/// 哪路看客在发话——只影响通报与 HUD,不影响动作
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Source {
    Keyboard,
    Mouse,
    Gamepad,
    Touch,
}

impl Source {
    fn label(self) -> &'static str {
        match self {
            Source::Keyboard => "键盘",
            Source::Mouse => "鼠标",
            Source::Gamepad => "手柄",
            Source::Touch => "触屏",
        }
    }
}

/// 意图层:设备们往里写“想做什么”,动作系统从里读
/// walk 与 strike 是瞬时意图,每帧清空重填;dash_to 是持续意图,到站才销
#[derive(Resource, Default)]
struct Intent {
    walk: f32,
    strike: bool,
    dash_to: Option<f32>,
    source: Option<Source>,
}

Listing 17-8(其一):意图层——设备往里写,动作从里读(src/main.rs)

意图分两种寿命,是这层设计里最容易踩的坑:walkstrike瞬时的——“此刻手上的状态”,每帧由 begin 系统清空、各设备重新填写,谁也不会残留;dash_to持续的——“点过的目的地”,跨帧活着,到站才销,亲手走位也能把它夺回来。分错寿命的症状很典型:瞬时项忘清空,松手了角色还在走;持续项每帧清空,点了令旗角色只挪一帧。

先听后动的次序用第 6 章的 SystemSet 钉死:

rust
/// 先听后动:所有设备读完,动作系统才开工
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum BoothSystems {
    Listen,
    Act,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .insert_resource(ClearColor(Color::srgb(0.10, 0.10, 0.14)))
        .init_resource::<Intent>()
        .init_resource::<Hits>()
        .configure_sets(
            Update,
            (BoothSystems::Listen, BoothSystems::Act).chain(),
        )
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                begin,
                (read_keyboard, read_mouse, read_gamepad, read_touch),
            )
                .chain()
                .in_set(BoothSystems::Listen),
        )
        .add_systems(
            Update,
            (
                (act_move, act_strike),
                (animate, fade_slash, wobble_dummy, hud, door),
                exit_booth.run_if(input_just_pressed(KeyCode::Escape)),
            )
                .chain()
                .in_set(BoothSystems::Act),
        )
        .run();
}

Listing 17-8(其二):Listen 先于 Act——所有设备读完,动作才开工

四个翻译官里挑两个对照着看——键盘把按键译成 ±1 的数字量,手柄把摇杆的模拟量原样递交,写的是同一个字段

rust
/// 键盘:A/D 与左右箭头写 walk,空格写 strike
fn read_keyboard(keyboard: Res<ButtonInput<KeyCode>>, mut intent: ResMut<Intent>) {
    let mut walk = 0.0;
    if keyboard.any_pressed([KeyCode::KeyA, KeyCode::ArrowLeft]) {
        walk -= 1.0;
    }
    if keyboard.any_pressed([KeyCode::KeyD, KeyCode::ArrowRight]) {
        walk += 1.0;
    }
    if walk != 0.0 {
        intent.walk = walk;
        intent.source = Some(Source::Keyboard);
    }
    if keyboard.just_pressed(KeyCode::Space) {
        intent.strike = true;
        intent.source = Some(Source::Keyboard);
    }
}
rust
/// 手柄:摇杆/十字键写 walk,南键写 strike——出剑命中另有一阵震动犒赏
fn read_gamepad(
    gamepads: Query<(Entity, &Gamepad)>,
    mut intent: ResMut<Intent>,
    mut rumbles: MessageWriter<GamepadRumbleRequest>,
) {
    for (entity, gamepad) in &gamepads {
        let stick = gamepad.left_stick().x;
        let stick = if stick.abs() > STICK_DEADZONE { stick } else { 0.0 };
        let push = (stick + gamepad.dpad().x).clamp(-1.0, 1.0);
        if push != 0.0 {
            intent.walk = push; // 模拟量原样交上去:半推半速
            intent.source = Some(Source::Gamepad);
        }
        if gamepad.just_pressed(GamepadButton::South) {
            intent.strike = true;
            intent.source = Some(Source::Gamepad);
            rumbles.write(GamepadRumbleRequest::Add {
                duration: Duration::from_millis(300),
                intensity: GamepadRumbleIntensity::strong_motor(0.6),
                gamepad: entity,
            });
        }
    }
}

Listing 17-8(其三):两个翻译官——数字量与模拟量殊途同归

动作侧从此眼里只有 Intent

rust
/// 走位只认 Intent:手上的 walk 优先,令旗其次——谁写进来的,它不问
fn act_move(
    time: Res<Time>,
    mut intent: ResMut<Intent>,
    mut ayan: Single<(&mut Transform, &mut Ayan)>,
    mut flag_visibility: Single<&mut Visibility, (With<Flag>, Without<Ayan>)>,
) {
    let (transform, ayan) = &mut *ayan;
    ayan.moving = false;

    if intent.walk != 0.0 {
        // 亲手走位夺回指挥权:撤旗
        if intent.dash_to.take().is_some() {
            **flag_visibility = Visibility::Hidden;
        }
        transform.translation.x = (transform.translation.x
            + intent.walk * WALK_SPEED * time.delta_secs())
        .clamp(-STAGE_HALF, STAGE_HALF);
        ayan.facing = intent.walk.signum();
        ayan.moving = true;
    } else if let Some(goal_x) = intent.dash_to {
        let step = DASH_SPEED * time.delta_secs();
        let distance = goal_x - transform.translation.x;
        ayan.facing = distance.signum();
        if distance.abs() <= step {
            transform.translation.x = goal_x;
            intent.dash_to = None;
            **flag_visibility = Visibility::Hidden;
        } else {
            transform.translation.x += step * distance.signum();
            ayan.moving = true;
        }
    }
}

Listing 17-8(其四):走位只认意图——walk 优先,令旗其次,谁写的它不问

整个结构画出来是一只漏斗:

示意图:四个设备读取系统的箭头汇入中央的 Intent 资源,再分流到 act_move、act_strike 与 HUD 等动作系统;下方标注 Listen 集先于 Act 集

Figure 17-7:意图层——改键位只动左列,改玩法只动右列,加设备就是多一个 read_* 系统

这层间接带来的好处值得一一点名:改键只动 read_keyboard 一处;加设备是纯增量(方向盘、体感、网络遥控,各写一个 read_*);回放与 AI 不过是又一个往 Intent 里写字的系统——录下每帧意图重放一遍,阿燕就把当晚的戏重演一遍;第 29 章做键位重绑定界面时,改的也只是翻译表。冲突的裁决也集中在了一处:体验场的规矩是“链上靠后的设备说话算数、手动走位抢过令旗”,全写在 act_move 的十几行里,要换仲裁规则不用翻四个文件。生态里把这层做成通用库的代表是 leafwing-input-manager(附录 D 的候选名单成员),思路与这里同源,规模更大。

开张

全部零件就位,完整代码如下——HUD 用第 16 章的 Text2d 富文本报“正在听谁的”,剑光与木桩的判定在 act_strike,帧动画沿用第 15 章的图集拨号:

rust
//! Listing 17-9:体验场《来者不拒》——键盘、鼠标、手柄、触屏使唤同一个阿燕
//! 所有设备只负责把“意图”写进 Intent 资源;走位、出剑、动画各干各的,
//! 谁也不认识键盘长什么样。改键、加设备,都只动“听”的那一半。

use bevy::input::common_conditions::input_just_pressed;
use bevy::input::gamepad::{
    GamepadConnection, GamepadConnectionEvent, GamepadRumbleIntensity, GamepadRumbleRequest,
};
use bevy::prelude::*;
use bevy::sprite::Anchor;
use std::time::Duration;

const FLOOR: f32 = -180.0;
const STAGE_HALF: f32 = 560.0;
const WALK_SPEED: f32 = 240.0;
const DASH_SPEED: f32 = 340.0;
/// 木人桩的站位与出剑够得着的距离
const DUMMY_X: f32 = 380.0;
const REACH: f32 = 150.0;
/// 自留的摇杆死区(17.5 节的账)
const STICK_DEADZONE: f32 = 0.15;

// ANCHOR: intent
/// 哪路看客在发话——只影响通报与 HUD,不影响动作
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Source {
    Keyboard,
    Mouse,
    Gamepad,
    Touch,
}

impl Source {
    fn label(self) -> &'static str {
        match self {
            Source::Keyboard => "键盘",
            Source::Mouse => "鼠标",
            Source::Gamepad => "手柄",
            Source::Touch => "触屏",
        }
    }
}

/// 意图层:设备们往里写“想做什么”,动作系统从里读
/// walk 与 strike 是瞬时意图,每帧清空重填;dash_to 是持续意图,到站才销
#[derive(Resource, Default)]
struct Intent {
    walk: f32,
    strike: bool,
    dash_to: Option<f32>,
    source: Option<Source>,
}
// ANCHOR_END: intent

/// 阿燕:朝向与这一帧是否真的在走(动画用)
#[derive(Component)]
struct Ayan {
    facing: f32,
    moving: bool,
}

/// 帧动画的节拍器
#[derive(Component)]
struct FrameClock(Timer);

/// 标记:令旗 / 木人桩 / HUD 文本根
#[derive(Component)]
struct Flag;
#[derive(Component)]
struct Dummy {
    wobble: Timer,
}
#[derive(Component)]
struct Hud;

/// 剑光:一闪即逝
#[derive(Component)]
struct SlashGlow {
    life: Timer,
}

/// 记分:中桩多少记
#[derive(Resource, Default)]
struct Hits(u32);

// ANCHOR: sets
/// 先听后动:所有设备读完,动作系统才开工
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum BoothSystems {
    Listen,
    Act,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .insert_resource(ClearColor(Color::srgb(0.10, 0.10, 0.14)))
        .init_resource::<Intent>()
        .init_resource::<Hits>()
        .configure_sets(
            Update,
            (BoothSystems::Listen, BoothSystems::Act).chain(),
        )
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                begin,
                (read_keyboard, read_mouse, read_gamepad, read_touch),
            )
                .chain()
                .in_set(BoothSystems::Listen),
        )
        .add_systems(
            Update,
            (
                (act_move, act_strike),
                (animate, fade_slash, wobble_dummy, hud, door),
                exit_booth.run_if(input_just_pressed(KeyCode::Escape)),
            )
                .chain()
                .in_set(BoothSystems::Act),
        )
        .run();
}
// ANCHOR_END: sets

fn setup(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
    commands.spawn(Camera2d);
    let regular = asset_server.load("fonts/book-sans-sc-regular.otf");
    let bold = asset_server.load("fonts/book-sans-sc-bold.otf");

    // 台面与木人桩
    commands.spawn((
        Sprite {
            image: asset_server.load("props/dock-plank.png"),
            custom_size: Some(Vec2::new(1400.0, 56.0)),
            image_mode: SpriteImageMode::Tiled {
                tile_x: true,
                tile_y: true,
                stretch_value: 4.0,
            },
            ..default()
        },
        Transform::from_xyz(0.0, FLOOR - 28.0, 0.0),
    ));
    commands.spawn((
        Dummy {
            wobble: Timer::from_seconds(0.25, TimerMode::Once),
        },
        Sprite::from_image(asset_server.load("props/wooden-dummy.png")),
        Anchor::BOTTOM_CENTER,
        Transform::from_xyz(DUMMY_X, FLOOR, 2.0).with_scale(Vec3::splat(4.0)),
    ));

    // 阿燕:十二格连环画整本带上——前六格养神,后六格走路
    let layout = layouts.add(TextureAtlasLayout::from_grid(
        UVec2::new(32, 40),
        6,
        2,
        None,
        None,
    ));
    commands.spawn((
        Ayan {
            facing: 1.0,
            moving: false,
        },
        FrameClock(Timer::from_seconds(0.1, TimerMode::Repeating)),
        Sprite::from_atlas_image(
            asset_server.load("actors/ayan-sheet.png"),
            TextureAtlas { layout, index: 0 },
        ),
        Anchor::BOTTOM_CENTER,
        Transform::from_xyz(-150.0, FLOOR, 3.0).with_scale(Vec3::splat(4.0)),
    ));

    // 令旗
    commands.spawn((
        Flag,
        Sprite::from_color(Color::srgb(0.95, 0.78, 0.22), Vec2::splat(18.0)),
        Transform::from_xyz(0.0, FLOOR + 9.0, 0.5)
            .with_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_4)),
        Visibility::Hidden,
    ));

    // HUD:正在听谁的 + 中桩计数(第 16 章的富文本)
    commands.spawn((
        Hud,
        Text2d::new("听差:"),
        TextFont {
            font: FontSource::Handle(bold.clone()),
            font_size: FontSize::Px(30.0),
            ..default()
        },
        TextColor(Color::srgb(0.91, 0.72, 0.29)),
        Anchor::CENTER_LEFT,
        Transform::from_xyz(-600.0, 300.0, 5.0),
        children![
            (
                TextSpan::new("虚位以待"),
                TextFont {
                    font: FontSource::Handle(bold),
                    font_size: FontSize::Px(30.0),
                    ..default()
                },
                TextColor(Color::srgb(0.55, 0.57, 0.62)),
            ),
            (
                TextSpan::new(" 中桩 ×0"),
                TextFont {
                    font: FontSource::Handle(regular),
                    font_size: FontSize::Px(20.0),
                    ..default()
                },
                TextColor(Color::srgb(0.55, 0.57, 0.62)),
            ),
        ],
    ));

    println!("老雷:体验场《来者不拒》开张——键盘鼠标手柄触屏,哪路看客都接。");
    println!("场记:A/D 走,空格出剑;点台面插旗;摇杆南键同理。Esc 散场。");
}

/// 开帧清账:瞬时意图归零,持续意图(令旗)留着
fn begin(mut intent: ResMut<Intent>) {
    intent.walk = 0.0;
    intent.strike = false;
}

// ANCHOR: read_keyboard
/// 键盘:A/D 与左右箭头写 walk,空格写 strike
fn read_keyboard(keyboard: Res<ButtonInput<KeyCode>>, mut intent: ResMut<Intent>) {
    let mut walk = 0.0;
    if keyboard.any_pressed([KeyCode::KeyA, KeyCode::ArrowLeft]) {
        walk -= 1.0;
    }
    if keyboard.any_pressed([KeyCode::KeyD, KeyCode::ArrowRight]) {
        walk += 1.0;
    }
    if walk != 0.0 {
        intent.walk = walk;
        intent.source = Some(Source::Keyboard);
    }
    if keyboard.just_pressed(KeyCode::Space) {
        intent.strike = true;
        intent.source = Some(Source::Keyboard);
    }
}
// ANCHOR_END: read_keyboard

/// 鼠标:左键点到哪,旗插到哪
fn read_mouse(
    mouse: Res<ButtonInput<MouseButton>>,
    window: Single<&Window>,
    camera: Single<(&Camera, &GlobalTransform)>,
    mut flag: Single<(&mut Transform, &mut Visibility), With<Flag>>,
    mut intent: ResMut<Intent>,
) {
    if !mouse.just_pressed(MouseButton::Left) {
        return;
    }
    let Some(cursor) = window.cursor_position() else {
        return;
    };
    let (camera, camera_transform) = *camera;
    let Ok(point) = camera.viewport_to_world_2d(camera_transform, cursor) else {
        return;
    };
    let (flag_transform, flag_visibility) = &mut *flag;
    flag_transform.translation = point.extend(0.5);
    **flag_visibility = Visibility::Visible;
    intent.dash_to = Some(point.x.clamp(-STAGE_HALF, STAGE_HALF));
    intent.source = Some(Source::Mouse);
}

// ANCHOR: read_gamepad
/// 手柄:摇杆/十字键写 walk,南键写 strike——出剑命中另有一阵震动犒赏
fn read_gamepad(
    gamepads: Query<(Entity, &Gamepad)>,
    mut intent: ResMut<Intent>,
    mut rumbles: MessageWriter<GamepadRumbleRequest>,
) {
    for (entity, gamepad) in &gamepads {
        let stick = gamepad.left_stick().x;
        let stick = if stick.abs() > STICK_DEADZONE { stick } else { 0.0 };
        let push = (stick + gamepad.dpad().x).clamp(-1.0, 1.0);
        if push != 0.0 {
            intent.walk = push; // 模拟量原样交上去:半推半速
            intent.source = Some(Source::Gamepad);
        }
        if gamepad.just_pressed(GamepadButton::South) {
            intent.strike = true;
            intent.source = Some(Source::Gamepad);
            rumbles.write(GamepadRumbleRequest::Add {
                duration: Duration::from_millis(300),
                intensity: GamepadRumbleIntensity::strong_motor(0.6),
                gamepad: entity,
            });
        }
    }
}
// ANCHOR_END: read_gamepad

/// 触屏:落指即插旗,和鼠标走同一条反算路
fn read_touch(
    touches: Res<Touches>,
    camera: Single<(&Camera, &GlobalTransform)>,
    mut flag: Single<(&mut Transform, &mut Visibility), With<Flag>>,
    mut intent: ResMut<Intent>,
) {
    let (camera, camera_transform) = *camera;
    for touch in touches.iter_just_pressed() {
        let Ok(point) = camera.viewport_to_world_2d(camera_transform, touch.position()) else {
            continue;
        };
        let (flag_transform, flag_visibility) = &mut *flag;
        flag_transform.translation = point.extend(0.5);
        **flag_visibility = Visibility::Visible;
        intent.dash_to = Some(point.x.clamp(-STAGE_HALF, STAGE_HALF));
        intent.source = Some(Source::Touch);
    }
}

// ANCHOR: act_move
/// 走位只认 Intent:手上的 walk 优先,令旗其次——谁写进来的,它不问
fn act_move(
    time: Res<Time>,
    mut intent: ResMut<Intent>,
    mut ayan: Single<(&mut Transform, &mut Ayan)>,
    mut flag_visibility: Single<&mut Visibility, (With<Flag>, Without<Ayan>)>,
) {
    let (transform, ayan) = &mut *ayan;
    ayan.moving = false;

    if intent.walk != 0.0 {
        // 亲手走位夺回指挥权:撤旗
        if intent.dash_to.take().is_some() {
            **flag_visibility = Visibility::Hidden;
        }
        transform.translation.x = (transform.translation.x
            + intent.walk * WALK_SPEED * time.delta_secs())
        .clamp(-STAGE_HALF, STAGE_HALF);
        ayan.facing = intent.walk.signum();
        ayan.moving = true;
    } else if let Some(goal_x) = intent.dash_to {
        let step = DASH_SPEED * time.delta_secs();
        let distance = goal_x - transform.translation.x;
        ayan.facing = distance.signum();
        if distance.abs() <= step {
            transform.translation.x = goal_x;
            intent.dash_to = None;
            **flag_visibility = Visibility::Hidden;
        } else {
            transform.translation.x += step * distance.signum();
            ayan.moving = true;
        }
    }
}
// ANCHOR_END: act_move

/// 出剑也只认 Intent:剑光一闪,够得着木桩就记一记
fn act_strike(
    intent: Res<Intent>,
    mut hits: ResMut<Hits>,
    mut commands: Commands,
    ayan: Single<(&Transform, &Ayan)>,
    mut dummy: Single<&mut Dummy>,
) {
    if !intent.strike {
        return;
    }
    let (transform, ayan) = *ayan;

    // 剑光:朝向一侧斜出的一道白
    commands.spawn((
        SlashGlow {
            life: Timer::from_seconds(0.18, TimerMode::Once),
        },
        Sprite::from_color(Color::srgb(0.92, 0.96, 1.0), Vec2::new(110.0, 10.0)),
        Transform::from_xyz(
            transform.translation.x + ayan.facing * 95.0,
            FLOOR + 110.0,
            4.0,
        )
        .with_rotation(Quat::from_rotation_z(ayan.facing * -0.5)),
    ));

    // 够得着、且面朝木桩,才算中桩
    let offset = DUMMY_X - transform.translation.x;
    if offset.abs() <= REACH && offset.signum() == ayan.facing {
        hits.0 += 1;
        dummy.wobble.reset();
        match hits.0 {
            1 => println!("阿燕:头一记,开张。"),
            n if n % 5 == 0 => println!("场记:中桩 {n} 记——这位看客是练家子。"),
            _ => {}
        }
    }
}

/// 帧动画:走路用后六格,养神用前六格;朝向翻 flip_x
fn animate(time: Res<Time>, mut ayan: Single<(&mut Sprite, &mut FrameClock, &Ayan)>) {
    let (sprite, clock, ayan) = &mut *ayan;
    let Some(atlas) = sprite.texture_atlas.as_mut() else {
        return;
    };
    let (start, end) = if ayan.moving { (6, 11) } else { (0, 5) };
    if !(start..=end).contains(&atlas.index) {
        atlas.index = start;
    }
    if clock.0.tick(time.delta()).just_finished() {
        atlas.index = if atlas.index >= end {
            start
        } else {
            atlas.index + 1
        };
    }
    sprite.flip_x = ayan.facing < 0.0;
}

/// 剑光转瞬即逝
fn fade_slash(
    time: Res<Time>,
    mut glows: Query<(Entity, &mut SlashGlow, &mut Sprite)>,
    mut commands: Commands,
) {
    for (entity, mut glow, mut sprite) in &mut glows {
        glow.life.tick(time.delta());
        sprite.color.set_alpha(1.0 - glow.life.fraction());
        if glow.life.is_finished() {
            commands.entity(entity).despawn();
        }
    }
}

/// 挨打的木桩晃两下(第 16 章的老相识)
fn wobble_dummy(time: Res<Time>, mut dummy: Single<(&mut Dummy, &mut Transform)>) {
    let (dummy, transform) = &mut *dummy;
    dummy.wobble.tick(time.delta());
    let strength = 1.0 - dummy.wobble.fraction();
    transform.rotation =
        Quat::from_rotation_z(0.05 * strength * (time.elapsed_secs() * 40.0).sin());
}

// ANCHOR: hud
/// HUD:换了设备就改“听差”,中了桩就改计数;头回上手的设备登记通报
fn hud(
    intent: Res<Intent>,
    hits: Res<Hits>,
    board: Single<Entity, With<Hud>>,
    mut writer: Text2dWriter,
    mut shown: Local<(Option<Source>, u32)>,
    mut greeted: Local<Vec<Source>>,
) {
    if *shown == (intent.source, hits.0) {
        return;
    }
    *shown = (intent.source, hits.0);

    if let Some(source) = intent.source {
        *writer.text(*board, 1) = source.label().to_string();
        writer.color(*board, 1).0 = Color::srgb(0.92, 0.93, 0.96);
        if !greeted.contains(&source) {
            greeted.push(source);
            println!("场记:{}看客上手了。", source.label());
        }
    }
    *writer.text(*board, 2) = format!(" 中桩 ×{}", hits.0);
}
// ANCHOR_END: hud

/// 门房:手柄进出场通报(与 Listing 17-6 同款)
fn door(mut connections: MessageReader<GamepadConnectionEvent>) {
    for event in connections.read() {
        match &event.connection {
            GamepadConnection::Connected { name, .. } => {
                println!("场记:手柄看客进场——“{name}”。");
            }
            GamepadConnection::Disconnected => {
                println!("场记:手柄 {} 拔线离场。", event.gamepad);
            }
        }
    }
}

/// 散场:Esc 一声令下,App 退出(第 7 章的 AppExit)
fn exit_booth(mut exit: MessageWriter<AppExit>) {
    println!("老雷:散场。今儿的看客都伺候好了。");
    exit.write(AppExit::Success);
}

Listing 17-9:完整示例——体验场《来者不拒》(src/main.rs)

console
cargo run -p ch17-input
text
老雷:体验场《来者不拒》开张——键盘鼠标手柄触屏,哪路看客都接。
场记:A/D 走,空格出剑;点台面插旗;摇杆南键同理。Esc 散场。
场记:键盘看客上手了。
阿燕:头一记,开张。
场记:鼠标看客上手了。
老雷:散场。今儿的看客都伺候好了。

动图:阿燕被键盘牵着迈步走向木人桩,HUD 显示听差为键盘;空格出剑剑光闪过、木桩摇晃、中桩计数跳到一;随后鼠标在台面左侧点下令旗,HUD 换为鼠标,阿燕掉头跑向令旗

Figure 17-8:《来者不拒》——键盘走位出剑,鼠标点地插旗,HUD 实时换“听差”

几处接线值得回头看:

  • begin 站在 Listen 链头:瞬时意图清零、持续意图留着——意图层的寿命规矩落实成一个三行系统;
  • HUD 只在变化时动笔Local 缓存上一次显示的(设备, 计数),没变就早退——第 16 章警告过“改字就重排版”,这里是惯用的省料法。换听差、记中桩用 Text2dWriter 按序号直改两个 TextSpan
  • 手柄的震动写在翻译官里是个有意的妥协:震动要指名实体,而 Intent 故意不记设备细节。让“回敬”跟着“听见”走,动作侧保持干净;
  • Esc 散场走第 7 章的 AppExit 消息,挂载用的还是 input_just_pressed——首尾两章的零件在同一行里碰头。

试一把:键盘按住 D 不放,同时用鼠标在西头点一面旗——阿燕理都不理,继续向东(act_move 里 walk 优先,且顺手把 dash_to 销了)。再试松开键盘后点旗、半路推一下摇杆:令旗作废,摇杆接管。所有这些“谁说了算”,都发生在同一个函数里——这就是意图层的价值。

小结

  • 输入两种读法:原始事件是 Message 流水(KeyboardInputMouseMotionMouseWheelTouchInputGamepadEvent……),InputPluginPreUpdate 把流水折叠成快照;同一帧里所有 Update 系统读到的快照完全一致。玩法问快照,要逐条细节(文本、系统重复、窗口归属)才读流水
  • 快照三问pressed 按住期间帧帧为真,just_pressedjust_released 只在按下/松开那一帧为真;操作系统的自动重复进不了快照。三问的运行条件版在 bevy::input::common_conditionsinput_just_pressed 等)
  • KeyCode 是物理位置(W3C 命名:KeyADigit1ArrowLeft……),换键盘布局不用改键;键帽刻字问逻辑键 ButtonInput<Key>。旧教程的 KeyCode::A 是 0.12 之前的化石,编译器会拦
  • 鼠标两套数据:按键走 ButtonInput<MouseButton>;位置走 window.cursor_position()Option,出窗为 None)+ viewport_to_world_2d 反算世界坐标。转镜头用原始位移 AccumulatedMouseMotion,别用光标差分;滚轮 AccumulatedMouseScroll 记得折算 Line/Pixel 两种单位;锁光标拧窗口实体的 CursorOptionsLocked 后光标冻结、位移照流;平台支持有差异)
  • 手柄是实体:挂 Gamepad(按键 + 摇杆快照,三问同款,摇杆与扳机是 −1..1/0..1 的模拟量)、NameGamepadSettings;轮询 left_stick() 是原始读数,死区自己留;事件值才走 AxisSettings 滤波(死区默认 ±0.05),模拟键判“按没按”走 ButtonSettings(0.75/0.65)。插拔走 GamepadConnectionEvent,断开只摘组件、设置保留;震动反向写 GamepadRumbleRequest 消息。键盘快照随窗口焦点清空(KeyboardFocusLost),手柄不随
  • 触摸按手指记账Touches 资源,三个迭代器对应三问,每根手指有稳定 id 与位置/位移;坐标与光标同体系,同一条反算路。Canceled 相位要当“松手”处理;Pinch 等手势消息目前仅 macOS/iOS
  • 输入映射:设备系统翻译成意图,动作系统只认意图;瞬时意图每帧清空、持续意图到站才销;改键、加设备、回放、重绑定界面都只动翻译层

练习

  1. 疾走:给 Intent 加一个 sprint: bool,左 Shift 或手柄西键按住时为真,act_move 里把步速乘 1.6。体会“加一种玩法”在意图层架构下各动哪几个文件。
  2. 侧键闪现:用 MouseButton::BackForward 让阿燕直接闪现到台口两端(写 dash_to 即可)。没有侧键的鼠标,就换成滚轮按下 Middle
  3. 滚轮上膛:把 Listing 17-5 的滚轮缩放搬进体验场,让看客边玩边调远近。注意 crane 要查相机的 Projection 而动作系统在查阿燕——借用规则会提醒你该把它放进哪个系统集。
  4. 蓄力斩:手柄的 RightTrigger2 是 0..1 的模拟量。扣住蓄力、松开出剑,伤害随扣下深度走——把 17.2 节“运劲”的玩法用模拟量重做一遍,对比键盘版“按住时长”与扳机版“扣下深度”的手感差异。
  5. 场记的速记屏:把 17.2 节的 transcript 系统接进体验场,但别打印——用一行 Text2d 把最近一条 KeyboardInput 消息显示在台口(第 16 章的提词器手艺)。按住一个键,亲眼看“系统重复”刷新那行字、而阿燕纹丝不动。

体验场能开夜场了,但有桩账还没算:阿燕的步速、剑光的寿命、木桩的摇晃,全都乘着一个 time.delta_secs() 在走——这个从第 2 章就在用的数到底是什么?快门快慢不一的机器上它怎么保证公平?“每秒恰好跳一下”的定时器、暂停时该停的和不该停的,还有 17.2 节欠下的“FixedUpdate 里读输入会丢拍”——时间的全部账目,下一章一并清算。