地圖目前已經有做了一些規劃,看起來場景比較豐富,繼續把地圖擴張成多關卡流程。而為了能在同一輪遊戲裡反覆載入場景,必須得處理關卡資料、分段載入步驟、敵人/道具重置,以及玩家走到門前時的傳送判定。
這次主要處理的目標:
LevelDefinition
與 LevelState
,規劃每一關的圖形、敵人數量、道具配置。LevelPlugin
,把關卡載入拆成排程 → 產生地板 → 最終收尾,三個 system,確保每個 frame 只做一件事。LevelEntity
標記,讓地板、環境物件、敵人、傳送門都能在換關時一次清空。new_demo/src/resources/level.rs
新增了一整套關卡結構,核心是 LevelDefinition
:
#[derive(Debug, Clone)]
pub struct LevelDefinition {
pub index: usize,
pub name: &'static str,
pub layout: RoomLayout,
pub enemy_counts: EnemyCounts,
pub prop_plan: PropPlan,
pub seed: u64,
}
RoomLayout
支援矩形房間與複合房間(CompoundRoomType
),EnemyCounts
/ PropPlan
則把每關要產生的史萊姆、獨眼巨人、裝飾數量明確列出。LevelState
在 Default
實作裡手刻了四個關卡,並提供 definition(index)
與 next_index()
讓系統查詢:
impl LevelState {
pub fn definition(&self, index: usize) -> &LevelDefinition {
&self.definitions[index]
}
pub fn next_index(&self) -> Option<usize> {
if self.current_index + 1 < self.definitions.len() {
Some(self.current_index + 1)
} else {
None
}
}
}
這套資料模型會在載入流程中重複使用,包含地板產生、敵人配置與傳送門目標。
new_demo/src/plugins/level.rs
的 LevelPlugin
接管所有關卡事件。它在 Startup
載入門的素材,PostStartup
將第一關排進等候佇列,Update
/ PostUpdate
則串起三個系統:
impl Plugin for LevelPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LevelState>()
.init_resource::<LevelBuildContext>()
.add_event::<LevelAdvanceRequestEvent>()
.add_event::<LevelLoadedEvent>()
.add_systems(Startup, initialize_level_exit_assets)
.add_systems(PostStartup, schedule_initial_level)
.add_systems(Update, handle_level_requests)
.add_systems(Update, process_level_layout.after(handle_level_requests))
.add_systems(PostUpdate, finalize_level_load);
}
}
LevelBuildContext
手上只有兩個欄位:pending_layout
與 pending_finalize
,控制正在建地板或準備收尾。這樣即使玩家快速觸發傳送門,也不會在同一個 frame 裡重複清空/產生場景。
先前的房間地板、環境物件都是直接 spawn
,要重載就得逐一追蹤 entity。現在 new_demo/src/components/level.rs
多了一個簡單的 marker:
#[derive(Component, Default)]
pub struct LevelEntity;
只要與關卡有關的 entity(地板、門、敵人、寶箱、傳送門、裝飾)都掛上這個 component,換關時呼叫 clear_level_entities
即可全部清除:
fn clear_level_entities(commands: &mut Commands, entities: &Query<Entity, With<LevelEntity>>) {
for entity in entities.iter() {
commands.entity(entity).despawn();
}
}
這也是為什麼新的傳送門同樣會掛 LevelEntity
:離開關卡時一次清掉,避免殘留舊門。
process_level_layout
是第一個載入階段。它會把既有的 LevelEntity
清空,再依照 RoomLayout
產生對應的房型:
pub fn process_level_layout(
mut commands: Commands,
mut build_context: ResMut<LevelBuildContext>,
level_state: Res<LevelState>,
level_entities: Query<Entity, With<LevelEntity>>,
room_assets: Res<RoomAssets>,
) {
let Some(index) = build_context.pending_layout.take() else { return; };
clear_level_entities(&mut commands, &level_entities);
let definition = level_state.definition(index).clone();
spawn_layout_for_level(&mut commands, &room_assets, &definition);
build_context.pending_finalize = Some(index);
}
矩形房間會呼叫 generate_room_tiles
,複合房間(L / T / 十字)則改用 spawn_compound_room
,所有 tile 都自動帶上 LevelEntity
,下一輪才能被一次清空。
地板建好後才會進入 finalize_level_load
,這是整個載入流程的重點步驟:
pub fn finalize_level_load(
mut commands: Commands,
mut build_context: ResMut<LevelBuildContext>,
level_state: Res<LevelState>,
environment_assets: Res<EnvironmentAssets>,
level_exit_assets: Option<Res<LevelExitAssets>>,
asset_server: Res<AssetServer>,
door_query: Query<&Transform, (With<Door>, With<LevelEntity>)>,
tile_query: Query<(&Transform, &RoomTile), With<LevelEntity>>,
mut player_query: Query<(&mut Transform, Option<&mut Velocity>, Option<&mut InputVector>), (
With<Player>,
Without<PlayerDead>,
Without<LevelEntity>,
)>,
mut level_loaded_events: EventWriter<LevelLoadedEvent>,
) {
let Some(index) = build_context.pending_finalize.take() else { return; };
let definition = level_state.definition(index);
let mut rng = StdRng::seed_from_u64(definition.seed);
步驟順序如下:
Door
,挑最低的門當入口,再往下兩格當玩家出生點,寫入 EntranceLocation
。Transform
搬到入口,並清空 Velocity
/ InputVector
,避免上一關的移動慣性延續下去。RoomTile
存成陣列,以固定 seed (StdRng
) 打散,方便之後穩定地挑選道具、敵人位置。spawn_level_exit_portal
,並記錄傳送門中心點給碰撞排除邏輯使用。spawn_environment_props_for_level
先排除入口、門口、傳送門附近的地板,再依序放樹、岩石、木箱。spawn_enemies_for_level
以 tile 為單位避免重疊,史萊姆/獨眼巨人各自帶不同的巡邏範圍與初始朝向。LevelLoadedEvent
讓寶箱系統跟上最新狀態。寶箱邏輯改成監聽 LevelLoadedEvent
(new_demo/src/systems/chest.rs
),每關載入時挑六個地板放 demo chest,同樣帶 LevelEntity
,換關就會被清掉。
我準備了新的圖片素材,來作為傳送門:
傳送門的資源集中在 LevelExitAssets
(new_demo/src/resources/level_exit_assets.rs
),左右兩片門板的貼圖分別是 doors/next_left.png
、doors/next_right.png
。實際產生在 spawn_level_exit_portal
:
fn spawn_level_exit_portal(
commands: &mut Commands,
assets: &LevelExitAssets,
anchor: Vec3,
tile_size: f32,
target_level: usize,
) -> Option<Vec3> {
let position = Vec3::new(anchor.x, anchor.y + tile_size * 0.5, 11.0);
let portal = commands
.spawn((
LevelEntity,
LevelExitDoor::new(target_level),
Transform::from_translation(position),
GlobalTransform::default(),
Name::new(format!("LevelExitPortal{}", target_level + 1)),
))
.id();
let panel_offset = tile_size * 0.5;
let scale = Vec3::splat(PLAYER_SCALE);
commands.entity(portal).with_children(|parent| {
parent.spawn((
Sprite::from_image(assets.left_panel.clone()),
Transform::from_translation(Vec3::new(-panel_offset, 0.0, 0.0)).with_scale(scale),
Name::new("LevelExitPanelLeft"),
));
parent.spawn((
Sprite::from_image(assets.right_panel.clone()),
Transform::from_translation(Vec3::new(panel_offset, 0.0, 0.0)).with_scale(scale),
Name::new("LevelExitPanelRight"),
));
});
Some(position)
}
門的位置是靠 compute_portal_anchor
找出室內地板最上方那一排的中心,避免門漂浮在空中。玩家互動的半徑則定義在 new_demo/src/constants.rs
:
pub const LEVEL_EXIT_INTERACTION_RADIUS: f32 = ROOM_TILE_SIZE * PLAYER_SCALE * 2.0;
輸入系統 (new_demo/src/systems/input.rs
) 把 Space 鍵的行為拆成互動門 → 開寶箱 → 傳送門 → 攻擊。傳送門檢查看起來像這樣:
for (exit, exit_transform) in &exit_query {
let exit_position = exit_transform.translation.truncate();
let distance = player_position.distance(exit_position);
if distance <= LEVEL_EXIT_INTERACTION_RADIUS {
level_exit_events.write(LevelAdvanceRequestEvent {
target_level: exit.target_level,
});
info!(
"🔁 傳送至下一關:{} (距離 {:.1}, 玩家 {:?}, 門 {:?})",
exit.target_level + 1,
distance,
player_position,
exit_position
);
return;
} else {
info!(
"🚶 距離下一關入口 {:.1} (需求 {:.1}) 玩家 {:?} 門 {:?}",
distance, LEVEL_EXIT_INTERACTION_RADIUS, player_position, exit_position
);
}
}
離門太遠時會印出距離與需求,方便調整半徑;成功時就送出 LevelAdvanceRequestEvent
,接下來的載入流程就由前面那些系統接手。
角色只要在傳送門面前,按下空白鍵之後,就跟之前開門的操作一樣,可以傳送到新關卡。
現在的流程可以穩定地把玩家送進多個關卡,地板與環境會重建、敵人與寶箱會重新產生、傳送門也會跟著換位。之後會再加上 Boss 在門前的位置守著,等到打完之後才能打開傳送門。
今日程式碼同步至 repo