iT邦幫忙

2025 iThome 鐵人賽

DAY 23
1

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

這次主要處理的目標:

  • 建立 LevelDefinitionLevelState,規劃每一關的圖形、敵人數量、道具配置。
  • 新增 LevelPlugin,把關卡載入拆成排程 → 產生地板 → 最終收尾,三個 system,確保每個 frame 只做一件事。
  • 導入 LevelEntity 標記,讓地板、環境物件、敵人、傳送門都能在換關時一次清空。
  • 產生傳送門 sprite、半徑判定與 Space 鍵互動,讓玩家靠近門時就能前往下一關。

關卡資料模型

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 則把每關要產生的史萊姆、獨眼巨人、裝飾數量明確列出。LevelStateDefault 實作裡手刻了四個關卡,並提供 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
        }
    }
}

這套資料模型會在載入流程中重複使用,包含地板產生、敵人配置與傳送門目標。


LevelPlugin 拆成三段排程

new_demo/src/plugins/level.rsLevelPlugin 接管所有關卡事件。它在 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_layoutpending_finalize,控制正在建地板準備收尾。這樣即使玩家快速觸發傳送門,也不會在同一個 frame 裡重複清空/產生場景。


LevelEntity 標記讓重載更簡單

先前的房間地板、環境物件都是直接 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 清場與重建地板

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 重置玩家、道具、敵人

地板建好後才會進入 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);

步驟順序如下:

  1. 尋找門座標:掃描房間的 Door,挑最低的門當入口,再往下兩格當玩家出生點,寫入 EntranceLocation
  2. 重設玩家狀態:直接把玩家 Transform 搬到入口,並清空 Velocity / InputVector,避免上一關的移動慣性延續下去。
  3. 收集地板座標:把所有 RoomTile 存成陣列,以固定 seed (StdRng) 打散,方便之後穩定地挑選道具、敵人位置。
  4. 產生傳送門:呼叫 spawn_level_exit_portal,並記錄傳送門中心點給碰撞排除邏輯使用。
  5. 佈置環境spawn_environment_props_for_level 先排除入口、門口、傳送門附近的地板,再依序放樹、岩石、木箱。
  6. 產生敵人spawn_enemies_for_level 以 tile 為單位避免重疊,史萊姆/獨眼巨人各自帶不同的巡邏範圍與初始朝向。
  7. 發送事件:最後噴出 LevelLoadedEvent 讓寶箱系統跟上最新狀態。

寶箱邏輯改成監聽 LevelLoadedEvent (new_demo/src/systems/chest.rs),每關載入時挑六個地板放 demo chest,同樣帶 LevelEntity,換關就會被清掉。


傳送門 sprite 與互動半徑

我準備了新的圖片素材,來作為傳送門:

傳送門素材

傳送門的資源集中在 LevelExitAssets (new_demo/src/resources/level_exit_assets.rs),左右兩片門板的貼圖分別是 doors/next_left.pngdoors/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;

Space 鍵互動流程

輸入系統 (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


上一篇
世界邊界與環境整理
下一篇
巫師 Boss 的遠程攻擊
系列文
Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言