Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Bevy 0.14 第一人称转视角踩坑全记:从黑屏到指针锁定到子实体相机

本文记录用 Rust + Bevy 0.14 移植一个 Three.js FPS 游戏到 macOS 时,“光标动不了、视角也不转“这一连串问题的真实排查链。每个坑都附最小复现代码与根因。

项目背景

原作是一个 Electron + Three.js 的 12v12 AI 对战 FPS(B 站 BV13UKP6mEdz,Kimi K3 生成战地游戏)。用 Rust + Bevy 0.14 重写,目标是 macOS 上能 cargo run 出一个能部署、走路、射击、AI 对战的 demo。骨架代码完成后,游戏能跑、主菜单能显示、点 START 能进入部署界面、选兵种点 DEPLOY 能进入游戏场景。但进了游戏就出现——光标动不了,视角也不转

踩坑链条总览

坑号现象根因修复
进游戏全黑屏没有 3D 相机渲染世界Startup 里 spawn Camera3dBundle
UI 全叠在左上角UI 元素没有父子层级嵌套,都默认绝对定位到原点改用 with_children 嵌套
进游戏 panic index out of boundsInputState 的 Vec 用 Default 是空,clear() 后索引越界初始化 vec![false; 256]
光标动不了,视角也不转指针锁定后 CursorMoved 事件不再触发改用 MouseMotion 读取位移
视角 yaw/pitch 更新了但画面没转3D 相机是独立实体,和玩家没关联把相机作为玩家子实体 spawn
按钮点击不响应Changed<Interaction> 过滤器在按钮切状态时漏事件去掉 Changed,每帧轮询

坑 ①:进游戏全黑屏

症状: cargo run 后窗口弹出,主菜单能看到(说明 UI 相机在),但点 START → DEPLOY 进入游戏后画面全黑,只剩 UI 元素悬在黑色背景上。

根因: 游戏场景中没有 3D 相机。主菜单场景有 UI 相机(Camera2dBundle),但游戏场景需要 3D 相机来渲染 3D 世界。

修复: 在进入游戏场景时 spawn Camera3dBundle

#![allow(unused)]
fn main() {
fn spawn_game_camera(mut commands: Commands) {
    commands.spawn(Camera3dBundle {
        transform: Transform::from_xyz(0.0, 2.0, 5.0).looking_at(Vec3::ZERO, Vec3::Y),
        ..default()
    });
}
}

坑 ②:UI 全叠在左上角

症状: 进入游戏场景后,UI 元素(弹药显示、血量条等)全部挤在窗口左上角,没有任何布局。

根因: 所有 UI 元素都用 PositionType::Absolute 且没有设置 left/top,默认全部定位到原点 (0, 0)

修复: 改用 with_children 实现父子层级嵌套,去掉绝对定位:

#![allow(unused)]
fn main() {
commands.spawn((
    NodeBundle {
        style: Style {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            flex_direction: FlexDirection::Column,
            ..default()
        },
        ..default()
    },
    GameUiRoot,
)).with_children(|parent| {
    parent.spawn((
        TextBundle::from_section("弹药: 30/30", TextStyle::default()),
        AmmoText,
    ));
    parent.spawn((
        TextBundle::from_section("HP: 100", TextStyle::default()),
        HpText,
    ));
});
}

坑 ③:进游戏 panic index out of bounds

症状: 点击 DEPLOY 按钮后,游戏直接崩溃,控制台输出 index out of bounds

根因: InputStatepressed_set: Vec<bool>#[derive(Default)] 初始化成一个空 Vec。在每帧更新时,clear() 后循环 pressed_set[i] = false 访问不存在的索引。

修复:Default 实现中分配足够的容量:

#![allow(unused)]
fn main() {
#[derive(Resource)]
struct InputState {
    pressed_set: Vec<bool>,
    mouse_delta: Vec2,
}

impl Default for InputState {
    fn default() -> Self {
        Self {
            pressed_set: vec![false; 256],
            mouse_delta: Vec2::ZERO,
        }
    }
}
}

坑 ④:光标动不了,视角也不转(核心坑)

症状: 进入游戏场景后,鼠标移动时游戏画面没有任何反应。日志显示 MouseMotionCursorMoved 事件没有被触发。

根因: 当使用 CursorGrabMode::Locked 锁定指针后,CursorMoved 事件不再触发。Bevy 0.14 在指针锁定模式下,操作系统不再报告光标的绝对位置变化,因此 CursorMoved 事件流停止。

解法: 改用 MouseMotion 事件(DeviceEvent 级),它读取的是鼠标的相对位移,不受指针锁定影响:

#![allow(unused)]
fn main() {
fn player_look(
    mut mouse_motion: EventReader<MouseMotion>,
    mut player_query: Query<&mut Player, With<PlayerFlag>>,
) {
    for motion in mouse_motion.read() {
        if let Ok(mut player) = player_query.get_single_mut() {
            player.yaw -= motion.delta.x * 0.003;
            player.pitch = (player.pitch - motion.delta.y * 0.003)
                .clamp(-1.54, 1.54);  // 限制俯仰角 ±90°
        }
    }
}
}

坑 ⑤:视角 yaw/pitch 更新了但画面没转

症状: 日志确认 player.yawplayer.pitch 在鼠标移动时更新了,但屏幕上的画面没有旋转。

根因: 3D 相机是在 Startup 系统里 spawn 的独立实体,和玩家实体没有任何关联。玩家实体的 Transform 旋转了,但相机的位置和朝向没变。

修复: 把相机作为玩家的子实体 spawn,这样相机自动跟随玩家的位置和旋转:

#![allow(unused)]
fn main() {
fn spawn_player(mut commands: Commands) {
    commands.spawn((
        PlayerFlag,
        SpatialBundle {
            transform: Transform::from_xyz(0.0, 0.0, 0.0),
            ..default()
        },
    )).with_children(|parent| {
        parent.spawn(Camera3dBundle {
            transform: Transform::from_xyz(0.0, 1.6, 0.0),  // 眼睛高度
            ..default()
        });
    });
}
}

坑 ⑥:按钮点击不响应

症状: SETTINGS 按钮、兵种卡片等点击后没有反应,但按钮样式确实有 hover 效果。

根因: 使用 Query<(&Interaction, &Btn), Changed<Interaction>> 时,Changed 过滤器在按钮从 NoneHoveredHoveredClicked 的状态转换中,由于实体 spawn/despawn 的时机问题,部分事件丢失。

修复: 去掉 Changed<Interaction> 过滤器,每帧轮询所有按钮的状态:

#![allow(unused)]
fn main() {
fn button_handler(
    query: Query<(&Interaction, &Btn)>,
    mut next_state: ResMut<NextState<GameState>>,
) {
    for (interaction, btn) in query.iter() {
        if *interaction == Interaction::Clicked {
            match btn {
                Btn::Start => next_state.set(GameState::Deploy),
                Btn::Settings => next_state.set(GameState::Settings),
                // ...
            }
        }
    }
}
}