三合一:输入映射
开张前夜,老雷验收,四个示例各开各的窗口。他只问了一句:“看客换设备还得换场子?一台戏,全都接。”
把前四节的系统原样堆进一个 App 当然能跑,但堆出来的东西没法养:walk 的逻辑写了三遍(键盘一遍、摇杆一遍、将来再来一遍),“出剑”散落在键盘系统和手柄系统里各管各的;哪天要做键位重绑定、要录像回放、要给阿燕配个 AI 陪练,会发现玩法代码和设备代码长在了一起,撕都撕不开。
工程上的答案叫输入映射(input mapping):在设备与玩法之间立一层“意图”。设备系统只做翻译——把各自的方言译成“想往哪走”“想不想出剑”;动作系统只认意图,不认识任何设备。本章压轴的体验场《来者不拒》就按这个骨架搭:
/// 哪路看客在发话——只影响通报与 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)
意图分两种寿命,是这层设计里最容易踩的坑:walk 与 strike 是瞬时的——“此刻手上的状态”,每帧由 begin 系统清空、各设备重新填写,谁也不会残留;dash_to 是持续的——“点过的目的地”,跨帧活着,到站才销,亲手走位也能把它夺回来。分错寿命的症状很典型:瞬时项忘清空,松手了角色还在走;持续项每帧清空,点了令旗角色只挪一帧。
先听后动的次序用第 6 章的 SystemSet 钉死:
/// 先听后动:所有设备读完,动作系统才开工
#[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 的数字量,手柄把摇杆的模拟量原样递交,写的是同一个字段:
/// 键盘: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);
}
}/// 手柄:摇杆/十字键写 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:
/// 走位只认 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 优先,令旗其次,谁写的它不问
整个结构画出来是一只漏斗:
Figure 17-7:意图层——改键位只动左列,改玩法只动右列,加设备就是多一个 read_* 系统
这层间接带来的好处值得一一点名:改键只动 read_keyboard 一处;加设备是纯增量(方向盘、体感、网络遥控,各写一个 read_*);回放与 AI 不过是又一个往 Intent 里写字的系统——录下每帧意图重放一遍,阿燕就把当晚的戏重演一遍;第 29 章做键位重绑定界面时,改的也只是翻译表。冲突的裁决也集中在了一处:体验场的规矩是“链上靠后的设备说话算数、手动走位抢过令旗”,全写在 act_move 的十几行里,要换仲裁规则不用翻四个文件。生态里把这层做成通用库的代表是 leafwing-input-manager(附录 D 的候选名单成员),思路与这里同源,规模更大。
开张
全部零件就位,完整代码如下——HUD 用第 16 章的 Text2d 富文本报“正在听谁的”,剑光与木桩的判定在 act_strike,帧动画沿用第 15 章的图集拨号:
//! 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)
cargo run -p ch17-input老雷:体验场《来者不拒》开张——键盘鼠标手柄触屏,哪路看客都接。
场记:A/D 走,空格出剑;点台面插旗;摇杆南键同理。Esc 散场。
场记:键盘看客上手了。
阿燕:头一记,开张。
场记:鼠标看客上手了。
老雷:散场。今儿的看客都伺候好了。
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流水(KeyboardInput、MouseMotion、MouseWheel、TouchInput、GamepadEvent……),InputPlugin在PreUpdate把流水折叠成快照;同一帧里所有Update系统读到的快照完全一致。玩法问快照,要逐条细节(文本、系统重复、窗口归属)才读流水 - 快照三问:
pressed按住期间帧帧为真,just_pressed/just_released只在按下/松开那一帧为真;操作系统的自动重复进不了快照。三问的运行条件版在bevy::input::common_conditions(input_just_pressed等) KeyCode是物理位置(W3C 命名:KeyA、Digit1、ArrowLeft……),换键盘布局不用改键;键帽刻字问逻辑键ButtonInput<Key>。旧教程的KeyCode::A是 0.12 之前的化石,编译器会拦- 鼠标两套数据:按键走
ButtonInput<MouseButton>;位置走window.cursor_position()(Option,出窗为None)+viewport_to_world_2d反算世界坐标。转镜头用原始位移AccumulatedMouseMotion,别用光标差分;滚轮AccumulatedMouseScroll记得折算 Line/Pixel 两种单位;锁光标拧窗口实体的CursorOptions(Locked后光标冻结、位移照流;平台支持有差异) - 手柄是实体:挂
Gamepad(按键 + 摇杆快照,三问同款,摇杆与扳机是 −1..1/0..1 的模拟量)、Name、GamepadSettings;轮询left_stick()是原始读数,死区自己留;事件值才走AxisSettings滤波(死区默认 ±0.05),模拟键判“按没按”走ButtonSettings(0.75/0.65)。插拔走GamepadConnectionEvent,断开只摘组件、设置保留;震动反向写GamepadRumbleRequest消息。键盘快照随窗口焦点清空(KeyboardFocusLost),手柄不随 - 触摸按手指记账:
Touches资源,三个迭代器对应三问,每根手指有稳定 id 与位置/位移;坐标与光标同体系,同一条反算路。Canceled相位要当“松手”处理;Pinch 等手势消息目前仅 macOS/iOS - 输入映射:设备系统翻译成意图,动作系统只认意图;瞬时意图每帧清空、持续意图到站才销;改键、加设备、回放、重绑定界面都只动翻译层
练习
- 疾走:给
Intent加一个sprint: bool,左 Shift 或手柄西键按住时为真,act_move里把步速乘 1.6。体会“加一种玩法”在意图层架构下各动哪几个文件。 - 侧键闪现:用
MouseButton::Back/Forward让阿燕直接闪现到台口两端(写dash_to即可)。没有侧键的鼠标,就换成滚轮按下Middle。 - 滚轮上膛:把 Listing 17-5 的滚轮缩放搬进体验场,让看客边玩边调远近。注意
crane要查相机的Projection而动作系统在查阿燕——借用规则会提醒你该把它放进哪个系统集。 - 蓄力斩:手柄的
RightTrigger2是 0..1 的模拟量。扣住蓄力、松开出剑,伤害随扣下深度走——把 17.2 节“运劲”的玩法用模拟量重做一遍,对比键盘版“按住时长”与扳机版“扣下深度”的手感差异。 - 场记的速记屏:把 17.2 节的
transcript系统接进体验场,但别打印——用一行Text2d把最近一条KeyboardInput消息显示在台口(第 16 章的提词器手艺)。按住一个键,亲眼看“系统重复”刷新那行字、而阿燕纹丝不动。
体验场能开夜场了,但有桩账还没算:阿燕的步速、剑光的寿命、木桩的摇晃,全都乘着一个 time.delta_secs() 在走——这个从第 2 章就在用的数到底是什么?快门快慢不一的机器上它怎么保证公平?“每秒恰好跳一下”的定时器、暂停时该停的和不该停的,还有 17.2 节欠下的“FixedUpdate 里读输入会丢拍”——时间的全部账目,下一章一并清算。