地圖目前已經有做了一些規劃,看起來場景比較豐富,繼續把地圖擴張成多關卡流程。而為了能在同一輪遊戲裡反覆載入場景,必須得處理關卡資料、分段載入步驟、敵人/道具重置,以及玩家走到門前時的傳送判定。
這次主要處理的目標:
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