Bevy 0.14 光标踩坑全记录
🚨 重要提醒
本项目(rust-bevy-mighty-rodent)光标系统开发过程中踩的坑,按发现时序整理。供日后维护和他项目复刻参考。
环境:Bevy 0.14.2 + macOS Retina。
坑 1:系统光标藏不掉
现象:自绘 sprite 光标渲染了,但 macOS 默认黑边白光标仍可见,两个光标叠一起。
根因:Bevy 0.14 的 CursorIcon 枚举没有 None 变体——不能通过设 Cursor::icon 藏掉系统光标。旧认知以为只能靠自绘 sprite z=100 盖住,但 UI 层独立渲染会盖住 sprite(见坑 4)。
解法:Bevy 0.14 的 Cursor struct 有个 visible: bool 字段,设 false 就藏掉系统光标。
// ✅ 正确:Cursor.visible=false 藏系统光标
cursor: Cursor {
visible: false,
icon: CursorIcon::Default, // CursorIcon 无 None 变体,这行只是占位
..default()
},
// ❌ 错误:CursorIcon 没有 None 变体
cursor: Cursor { icon: CursorIcon::None, ..default() } // 编译不过
坑 2:光标 sprite 渲染了但看不见
现象:spawn_cursor_sprite spawn 了 SpriteBundle(z=100),日志确认 sprite 跟鼠标走(cursor sprite at world=(57.3,35.7) 鼠标移动时坐标变),但游戏窗口里看不见光标。按钮高亮能触发说明鼠标坐标是真有值的。
根因:Bevy UI 渲染层独立盖在所有 2D sprite 之上。SpriteBundle z=100 在 2D sprite 层最高,但 UI 层(菜单框、面板等 NodeBundle)独立渲染盖住所有 sprite。主菜单的 MenuRoot 是 NodeBundle 占 100%×100%,正好盖住整个光标 sprite。
解法:光标也用 UI 节点(ImageBundle),与菜单同在 UI 渲染层,靠 ZIndex::Global(999) 盖住其他 UI 节点。
// ✅ 正确:UI 节点同渲染层,ZIndex::Global 盖住菜单
commands.spawn((
ImageBundle {
style: Style {
position_type: PositionType::Absolute,
width: Val::Px(64.0),
height: Val::Px(64.0),
..default()
},
image: UiImage { texture: ..., ..default() },
z_index: ZIndex::Global(999), // Global 盖所有其他 UI 节点
..default()
},
CursorSprite,
));
// ❌ 错误:2D sprite 被 UI 层盖住
commands.spawn((
SpriteBundle {
texture: ...,
sprite: Sprite { custom_size: Some(Vec2::new(64.0, 64.0)), ..default() },
transform: Transform::from_xyz(0.0, 0.0, 100.0), // z=100 也被 UI 盖
..default()
},
CursorSprite,
));
坑 3:光标渲染位置与实际工作位置错位
现象:OPTIONS 按钮亮起(说明鼠标真在 OPTIONS 上),但光标 sprite 渲染在偏左上角的位置,与鼠标实际位置错位。
根因:用了 camera.viewport_to_world_2d() 把鼠标屏幕坐标转成世界坐标(相机 FixedVertical(600) 单位系,y 向上,视口中心为原点),再塞进 UI Style.left/top。但 UI 坐标系是屏幕像素(左上为原点 y 向下),两者完全不同单位系:
| 坐标系 | 原点 | y 方向 | 单位 |
|---|---|---|---|
| 世界坐标(viewport_to_world_2d 返回) | 视口中心 | 向上 | 相机单位(FixedVertical 600) |
| UI 坐标(Style.left/top) | 屏幕左上角 | 向下 | 屏幕像素 |
解法:UI 节点直接用 window.cursor_position()(屏幕像素坐标),跳过相机转换。
// ✅ 正确:UI 直接用屏幕像素坐标
let Some(cursor) = window.cursor_position() else { return; };
style.left = Val::Px(cursor.x - 32.0); // 减半个 sprite 宽高让中心对齐
style.top = Val::Px(cursor.y - 32.0);
// ❌ 错误:经相机转换成世界坐标再塞 UI 像素位置
if let Some(world) = camera.viewport_to_world_2d(cam_tf, cursor) {
style.left = Val::Px(world.x - 32.0); // 世界单位 ≠ 像素
style.top = Val::Px(-world.y - 32.0); // y 翻转也救不了单位系不匹配
}
坑 4:BackgroundColor tint 把透明像素也染红
现象:mouse.png 是纯白图 + 透明像素(光标图形在第四象限右下,其余三象限透明),用 BackgroundColor(Color::srgb(1.0, 0.1, 0.1)) tint 染红后,透明像素处也显红色——整个 64×64 节点盒子都显红底,透明像素不再透明。
根因:BackgroundColor 是 UI 节点的底色,会铺满整个节点盒子(64×64),与图像的透明像素无关。它不是图像 tint,是节点背景。
解法:用 UiImage.color 字段——这是与图像每个像素相乘的 tint(pub color: Color),透明像素(alpha=0)相乘仍透明,白像素染绿/红。
// ✅ 正确:UiImage.color 是图像 tint(与每像素相乘),透明保透明
ui_image.texture = asset_server.load("gfx/crosshair.png");
ui_image.color = Color::srgb(0.1, 1.0, 0.1); // 染绿,透明像素保透明
// ❌ 错误:BackgroundColor 铺底色,透明像素处也显色
ImageBundle {
image: UiImage { texture: ..., ..default() },
background_color: BackgroundColor(Color::srgb(1.0, 0.1, 0.1)), // 铺满 64×64 红底
..default()
}
UiImage 字段全貌(Bevy 0.14.2 源码确认):
pub struct UiImage {
pub color: Color, // tint,与每像素相乘,默认 WHITE 原色渲染
pub texture: Handle<Image>,
pub flip_x: bool,
pub flip_y: bool,
}
坑 5:grab_mode=Confined 把光标锁死中心像 FPS 准星
现象:设了 Cursor.grab_mode: CursorGrabMode::Confined 想让鼠标限在窗口内快速晃动不出边界。实测 macOS 上光标动不了,按钮也不亮起——光标被锁死在某个固定位置,像 FPS 准星那样定死。
根因:Bevy 0.14 的 CursorGrabMode 在 macOS(winit 后端)的 Confined 实现行为是锁中心,不是"限在窗口内但光标仍跟鼠标走"。跨平台行为不一致:
| 平台 | Confined 实际行为 |
|---|---|
| macOS(实测) | 锁光标在中心,光标不动 ❌ |
| Win10+ | 限在窗口内,光标跟鼠标走 ✅ |
| Linux | 取决于窗口管理器 |
CursorGrabMode 枚举:
pub enum CursorGrabMode {
None, // 鼠标可自由出窗口(默认)
Confined, // 限在窗口内 —— macOS 实测锁中心
Locked, // 锁定到固定位置 —— FPS 视角旋转用
}
解法:2D 游戏光标要跟鼠标走,macOS 无合用的窗口边缘锁定接口,不强上。删掉 grab_mode 设定,让鼠标可自由出窗口。
// ✅ 不设 grab_mode,鼠标可自由出窗口
cursor: Cursor {
visible: false, // 藏系统光标
icon: CursorIcon::Default,
..default() // grab_mode 默认 None
},
// ❌ macOS 上锁死光标
cursor: Cursor {
grab_mode: CursorGrabMode::Confined, // macOS 锁中心
..default()
},
教训:跨平台接口要实测每个目标平台的行为,不能信文档"理论支持"。
坑 6:spawn 时光标显在左上角 (0,0)
现象:游戏窗口刚开,spawn_cursor_sprite spawn 光标后 update_cursor_sprite 第一帧还没收到鼠标坐标,光标 sprite 显在默认位置 (0,0)(屏幕左上角)闪一下,才跟鼠标走。
根因:ImageBundle 的 visibility 默认 Visible,spawn 后立即可见,但此时还没设 Style.left/top(默认 Val::Auto),所以节点定位在左上角 (0,0)。第一帧 update_cursor_sprite 系统执行时,window.cursor_position() 可能还没更新(或为 None),导致闪一下左上角。
解法:spawn 时设 visibility: Visibility::Hidden,等第一帧 update_cursor_sprite 拿到有效鼠标坐标并设置 Style.left/top 后,再改为 Visibility::Visible。
// ✅ 正确:spawn 时先隐藏,第一帧更新坐标后再显示
commands.spawn((
ImageBundle {
style: Style {
position_type: PositionType::Absolute,
width: Val::Px(64.0),
height: Val::Px(64.0),
left: Val::Px(0.0), // 临时占位
top: Val::Px(0.0),
..default()
},
image: UiImage { texture: ..., ..default() },
z_index: ZIndex::Global(999),
visibility: Visibility::Hidden, // 先隐藏
..default()
},
CursorSprite,
));
// 在 update_cursor_sprite 系统中:
fn update_cursor_sprite(
mut query: Query<(&mut Style, &mut Visibility), With<CursorSprite>>,
window: Query<&Window>,
) {
let Some(cursor) = window.single().cursor_position() else { return; };
for (mut style, mut visibility) in query.iter_mut() {
style.left = Val::Px(cursor.x - 32.0);
style.top = Val::Px(cursor.y - 32.0);
*visibility = Visibility::Visible; // 坐标设好后再显示
}
}
// ❌ 错误:spawn 后立即可见,闪左上角
commands.spawn((
ImageBundle {
style: Style { ..default() }, // left/top 默认 Auto → (0,0)
image: UiImage { texture: ..., ..default() },
z_index: ZIndex::Global(999),
visibility: Visibility::Visible, // 立即可见
..default()
},
CursorSprite,
));
教训:UI 节点 spawn 时若依赖外部数据(如鼠标坐标)定位,应先隐藏,等数据就绪后再显示,避免视觉闪烁。
更多推荐



所有评论(0)