🚨 重要提醒

本项目(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。主菜单的 MenuRootNodeBundle 占 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)(屏幕左上角)闪一下,才跟鼠标走。

根因:ImageBundlevisibility 默认 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 时若依赖外部数据(如鼠标坐标)定位,应先隐藏,等数据就绪后再显示,避免视觉闪烁。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐