iT邦幫忙

2025 iThome 鐵人賽

DAY 22
1
Rust

Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記系列 第 22

世界邊界與環境整理

  • 分享至 

  • xImage
  •  

原本的房間系統雖然能隨機產生,不過玩家一旦走出門就會掉進無窮的空地,周圍既沒有地板,也缺少能阻擋視線的物件。

這篇將會把整個世界重新整理,先規劃出合理大小的戶外邊界,再鋪上環境素材、整理碰撞邏輯,最後補上一套安全的敵人出生 fallback,讓地圖看起來比較豐富一點。

這次處理的重點:

  • 制定新的地圖半徑(原本的一半大小)與邊界常數,搭配 WorldBounds 進行 clamp。
  • 啟動時鋪滿 floor_outdoor.png,並載入樹、岩石、小石子路三種環境物件;素材路徑統一放在 assets/environment/
  • enforce_world_bounds_system 改用 ParamSet 拆解查詢,搭配牆壁/環境碰撞,防止玩家、敵人或瞄準框衝出邊界。
  • 敵人生成會先找室內地板,若遇到狹窄走廊(或完全沒地板)則落在門內兩格,避免卡在戶外。

地圖邊界常數

new_demo/src/constants.rs 的地圖半徑改成 12×9 格,剛好是原本尺寸的一半,換算成世界單位大約是 1536 × 1152。為了避免邊緣裁切,仍保留一格 WORLD_BOUNDARY_PADDING_TILES 供 clamp 使用:

pub const WORLD_HALF_WIDTH_TILES: i32 = 12;
pub const WORLD_HALF_HEIGHT_TILES: i32 = 9;
pub const WORLD_BOUNDARY_PADDING_TILES: i32 = 1;
pub const WORLD_OUTDOOR_FLOOR_Z: f32 = Z_LAYER_GRID - 0.1;
pub const ENVIRONMENT_PROP_Z: f32 = Z_LAYER_GRID + 5.0;
pub const ENVIRONMENT_PROP_SCALE: f32 = 4.0;

常數調整後,WorldBounds::new 會得到 x ∈ [-832, 832]y ∈ [-640, 640] 的 clamp 範圍,剛好能把活動區包在一個適中的矩形裡。


戶外鋪地與環境素材

WorldPlugin 的啟動流程改成:

  1. initialize_room_assets
  2. initialize_environment_assets
  3. spawn_world_floor_and_bounds
  4. spawn_room
  5. spawn_environment_props

這樣可以確保戶外地板與 WorldBounds 先完成,室內房間再蓋上去,最後才放視覺裝飾。環境素材集中在 assets/environment/,專門用 EnvironmentAssets 載入:

#[derive(Resource, Debug, Clone)]
pub struct EnvironmentAssets {
    pub tree: Handle<Image>,
    pub rock: Handle<Image>,
    pub crate_prop: Handle<Image>,
}

impl EnvironmentAssets {
    pub fn load_all(asset_server: &AssetServer) -> Self {
        Self {
            tree: asset_server.load("environment/tree.png"),
            rock: asset_server.load("environment/rock.png"),
            crate_prop: asset_server.load("environment/rock_floor.png"),
        }
    }
}

圖片素材要記得使用正確,像我一開始就把史萊姆素材誤當成 tree.png,導致外面站著一堆「雕像版史萊姆」,想說是什麼問題找不到,但也提醒自己,有關環境的素材最好獨立資料夾管理,避免它們跟遊戲物件混用。


邊界 clamp 與牆壁碰撞

enforce_world_bounds_system 會在 movement_system 之後執行,並改用 ParamSet 把玩家、敵人、瞄準框拆成三種互斥查詢,避免 B0001

pub fn enforce_world_bounds_system(
    world_bounds: Option<Res<WorldBounds>>,
    mut queries: ParamSet<(
        Query<
            &mut Transform,
            (
                With<Player>,
                Without<PlayerDead>,
                Without<Enemy>,
                Without<AttackReticle>,
            ),
        >,
        Query<&mut Transform, (With<Enemy>, Without<Player>, Without<AttackReticle>)>,
        Query<&mut Transform, (With<AttackReticle>, Without<Player>, Without<Enemy>)>,
    )>,
) {
    let Some(bounds) = world_bounds else {
        return;
    };

    if let Ok(mut transform) = queries.p0().get_single_mut() {
        transform.translation = bounds.clamp_translation(transform.translation);
    }

    for mut transform in queries.p1().iter_mut() {
        transform.translation = bounds.clamp_translation(transform.translation);
    }

    for mut transform in queries.p2().iter_mut() {
        transform.translation = bounds.clamp_translation(transform.translation);
    }
}

牆壁碰撞 (wall_collision_system, enemy_wall_collision_system) 則接著跑,除了原本的門板判斷外,再加入 EnvironmentProp 的碰撞測試,並用 tile_priority 確保牆面取代戶外地板。這樣角色就不會被地板本身擋住,也不會穿過樹木或巨石。


敵人出生 fallback

縮小地圖後,有時 find_floor_spawn 取到的室內列只有一格寬,敵人一出生就踩在門邊。為了避免這種狀況,spawn_slime_internalspawn_cyclops_internal 會先量測該列的左右邊界;只要寬度小於兩格,就把敵人放回入口內側兩格:

let tile_span = ROOM_TILE_SIZE * PLAYER_SCALE;
let fallback_position = entrance_location
    .map(|entrance| Vec3::new(entrance.position.x, entrance.position.y + tile_span * 2.0, 9.0))
    .unwrap_or_else(|| Vec3::new(0.0, 0.0, 9.0));

let (spawn_position, patrol_origin, patrol_range, initial_direction, used_fallback) =
    if let Some(details) = find_floor_spawn(floor_query, FloorSpawnPreference::LeftMost) {
        let corridor_width = (details.max_x - details.min_x).abs();
        if corridor_width < tile_span * 2.0 {
            (fallback_position, fallback_position, SLIME_PATROL_RANGE, 1.0, true)
        } else {
            let values = details.into_values(FloorSpawnPreference::LeftMost);
            (values.0, values.1, values.2, values.3, false)
        }
    } else {
        (fallback_position, fallback_position, SLIME_PATROL_RANGE, 1.0, true)
    };

if used_fallback {
    warn!("使用預設史萊姆出生點,fallback_position={:?}", patrol_origin);
} else {
    info!("Slime spawn floor position: {:?}, range={}", patrol_origin, patrol_range);
}

相同策略也套用在獨眼巨人身上;重生時 (reset_enemies_on_player_respawn) 也會沿用這套邏輯,避免死亡後回到奇怪的位置。開 RUST_LOG=info 測試時,可以直接從 log 判斷究竟是找到室內地板,還是啟動 fallback。

demo


小結

把地圖範圍規劃好後,玩家走到邊界也能感覺到結束,而不是無限延伸,環境物件則讓畫面不再空蕩,整個地圖算是比較像樣了。

今日程式碼同步至 repo


上一篇
攻擊瞄準框與方向鎖定
下一篇
關卡系統與傳送門
系列文
Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言