原本的房間系統雖然能隨機產生,不過玩家一旦走出門就會掉進無窮的空地,周圍既沒有地板,也缺少能阻擋視線的物件。
這篇將會把整個世界重新整理,先規劃出合理大小的戶外邊界,再鋪上環境素材、整理碰撞邏輯,最後補上一套安全的敵人出生 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
的啟動流程改成:
initialize_room_assets
initialize_environment_assets
spawn_world_floor_and_bounds
spawn_room
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
,導致外面站著一堆「雕像版史萊姆」,想說是什麼問題找不到,但也提醒自己,有關環境的素材最好獨立資料夾管理,避免它們跟遊戲物件混用。
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
確保牆面取代戶外地板。這樣角色就不會被地板本身擋住,也不會穿過樹木或巨石。
縮小地圖後,有時 find_floor_spawn
取到的室內列只有一格寬,敵人一出生就踩在門邊。為了避免這種狀況,spawn_slime_internal
/spawn_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。
把地圖範圍規劃好後,玩家走到邊界也能感覺到結束,而不是無限延伸,環境物件則讓畫面不再空蕩,整個地圖算是比較像樣了。
今日程式碼同步至 repo