iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Rust

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

如何讓史萊姆動起來(敵人的 AI 行為)

  • 分享至 

  • xImage
  •  

是時候加入其他角色了,一個有趣的遊戲怎麼可以少了挑戰性呢?玩家應該要保持警覺,思考下一步該怎麼走。所以本篇就要為我們的地下城新增第一批「不速之客」—— 擁有基本 AI 的敵人。

從玩家體驗的角度來看,敵人不應該只是移動的沙包,而是要有自己的行為模式。我們在之前使用的素材中,先拿出兩個角色:

史萊姆跟獨眼巨人

主要挑戰的目標是建立一套可以擴充的敵人 AI 架構,而且要確保它們不會傻傻地撞牆或走出房間邊界。

State Machine vs Behavior Tree

在思考敵人行為時,面臨了幾個重要的決定。首先是架構的選擇:應該用簡單的狀態機(State Machine),還是更複雜的行為樹(Behavior Tree)?

經過考慮之後,選擇了狀態機架構,主要有幾個原因:

首先,對於 Rogue-lite 遊戲來說,敵人的行為模式相對固定且可預測,玩家需要能夠學習和掌握每種敵人的行為模式;其次,狀態機在 Bevy ECS 中實作起來比較直覺,每個狀態可以對應到不同的元件和系統。

最後,狀態機在 debug 和調整比較容易,當行為不如預期時,比較容易找出問題出在哪個狀態轉換上。

另一個要思考的是如何處理敵人的「地盤意識」。在很多遊戲中,敵人會無腦追擊玩家到天涯海角,這樣雖然簡單,但自己覺得不太合理。

所以我想了一套「巡邏原點 + 警戒半徑 + 鬆綁距離」的機制:每個敵人都有自己的勢力範圍,離開太遠就會自動回到原本的地盤,這樣避免了玩家被追到地圖邊緣的困擾,也讓每個敵人都有自己的「個性」。

由於玩家和敵人都有 Transform 元件,如果不小心處理,很容易在查詢時發生借用衝突(borrow conflict)。使用 Without<T> 過濾器是解決這個問題比較好的做法,它確保查詢只會回傳不包含特定元件的實體。

// src/constants.rs
pub const SLIME_SCALE: f32 = 4.0;
pub const SLIME_PATROL_RANGE: f32 = 120.0;
pub const SLIME_PATROL_SPEED: f32 = 60.0;
pub const SLIME_CHASE_SPEED: f32 = 90.0;
pub const SLIME_ALERT_RADIUS: f32 = 200.0;
pub const SLIME_LEASH_RADIUS: f32 = 260.0;

pub const CYCLOPS_SCALE: f32 = 4.5;
pub const CYCLOPS_PATROL_RANGE: f32 = 140.0;
pub const CYCLOPS_PATROL_SPEED: f32 = 55.0;
pub const CYCLOPS_CHASE_SPEED: f32 = 85.0;
pub const CYCLOPS_ALERT_RADIUS: f32 = 220.0;
pub const CYCLOPS_LEASH_RADIUS: f32 = 220.0;
pub const CYCLOPS_WINDUP_SECONDS: f32 = 0.6;
pub const CYCLOPS_CHARGE_SECONDS: f32 = 0.45;
pub const CYCLOPS_CHARGE_MULTIPLIER: f32 = 2.2;
pub const CYCLOPS_COOLDOWN_SECONDS: f32 = 1.2;

// src/components/enemy.rs
#[derive(Component)]
pub struct Slime;

#[derive(Component)]
pub struct Cyclops;

#[derive(Component)]
pub struct EnemyAIState {
    pub state: EnemyBehaviorState,
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum EnemyBehaviorState {
    Patrolling,
    Chasing,
    WindUp,
    Charging,
}

#[derive(Component)]
pub struct EnemyPatrol {
    pub origin: Vec3,
    pub range: f32,
    pub direction: f32,
}

#[derive(Component)]
pub struct EnemyAlert {
    pub trigger_radius: f32,
    pub leash_radius: f32,
}

#[derive(Component)]
pub struct EnemySpeeds {
    pub patrol: f32,
    pub chase: f32,
}

#[derive(Component)]
pub struct CyclopsCharge {
    pub windup: Timer,
    pub charge: Timer,
    pub cooldown: Timer,
    pub facing: Vec2,
    pub ready: bool,
}

ECS 架構下的 AI 系統

敵人 AI 的核心在於元件的組合,每個元件都負責特定的功能:

  • EnemyAIState 管理目前行為狀態。
  • EnemyPatrol 記錄巡邏路線資訊。
  • EnemyAlert 定義警戒和鬆綁範圍。
  • EnemySpeeds 儲存不同狀態下的移動速度。

這種設計的好處是高度可組合性。比如史萊姆只需要基本的巡邏和追擊,而獨眼巨人則額外添加了 CyclopsCharge 元件來處理加速行為。未來如果要加入其他類型,像是遠程攻擊敵人,只需要新增對應的元件就好,不需要修改現有的邏輯。

最有趣的部分是動態計算巡邏範圍。當敵人出現時,系統會掃描現在房間的所有地板,找出同一橫列的左右邊界,然後自動計算出合理的巡邏中心點和範圍。這樣做的用意是,不管房間是方形、L 形還是更複雜的形狀,敵人都不會走出邊界或卡在牆角。

let spawn_details = find_floor_spawn(&floor_query, FloorSpawnPreference::LeftMost);
let (spawn_position, patrol_origin, patrol_range, initial_direction) = spawn_details
    .map(|info| info.into_values(FloorSpawnPreference::LeftMost))
    .unwrap_or_else(|| default_spawn_values());

在狀態轉換邏輯上,作了一個簡單的有限狀態機。史萊姆在巡邏狀態時會計算與玩家的距離,一旦進入警戒半徑就切換到追擊狀態;追擊時如果玩家逃出鬆綁半徑,又會回到巡邏狀態。這種雙重閾值設計避免了敵人在邊界附近頻繁切換狀態的問題。

獨眼巨人的模式比較複雜,一共有四個狀態:巡邏、追擊、蓄力、衝刺。當進入追擊狀態並且冷卻時間結束後,它會進入蓄力狀態,記錄玩家的位置方向,然後朝那個方向高速衝刺。這種方式增加了一點策略性,玩家必須在獨眼巨人蓄力時及時移動位置。

// src/systems/enemy.rs
pub fn spawn_slime(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    floor_query: Query<(&Transform, &RoomTile)>,
    existing_slimes: Query<Entity, With<Slime>>,
) {
    if !existing_slimes.is_empty() {
        return;
    }

    let spawn_details = find_floor_spawn(&floor_query, FloorSpawnPreference::LeftMost);
    let (spawn_position, patrol_origin, patrol_range, initial_direction) = spawn_details
        .map(|info| info.into_values(FloorSpawnPreference::LeftMost))
        .unwrap_or_else(|| (
            Vec3::new(0.0, 0.0, 9.0),
            Vec3::new(0.0, 0.0, 9.0),
            SLIME_PATROL_RANGE,
            1.0,
        ));

    commands.spawn((
        Enemy,
        Slime,
        Sprite::from_image(asset_server.load("characters/enemies/slime.png")),
        Transform::from_translation(spawn_position).with_scale(Vec3::splat(SLIME_SCALE)),
        EnemyAIState { state: EnemyBehaviorState::Patrolling },
        EnemyPatrol {
            origin: patrol_origin,
            range: patrol_range,
            direction: initial_direction,
        },
        EnemyAlert {
            trigger_radius: SLIME_ALERT_RADIUS,
            leash_radius: SLIME_LEASH_RADIUS,
        },
        EnemySpeeds {
            patrol: SLIME_PATROL_SPEED,
            chase: SLIME_CHASE_SPEED,
        },
    ));
}

pub fn slime_ai_system(
    time: Res<Time>,
    player_query: Query<&Transform, (With<Player>, Without<Slime>)>,
    mut slime_query: Query<
        (
            &mut Transform,
            &mut EnemyAIState,
            &mut EnemyPatrol,
            &EnemyAlert,
            &EnemySpeeds,
        ),
        (With<Slime>, Without<Player>),
    >,
) {
    let player_position = player_query.iter().next().map(|t| t.translation);

    for (mut transform, mut ai_state, mut patrol, alert, speeds) in &mut slime_query {
        if let Some(player_pos) = player_position {
            let to_player = player_pos - transform.translation;
            let distance_to_player = to_player.truncate().length();

            // ...狀態切換與追擊邏輯...
        }

        // 巡邏邏輯 …
    }
}

 pub fn spawn_cyclops(
     mut commands: Commands,
     asset_server: Res<AssetServer>,
     floor_query: Query<(&Transform, &RoomTile)>,
     existing_cyclops: Query<Entity, With<Cyclops>>,
 ) {
     // ...根據房間寬度決定出生點與巡邏上限...

     commands.spawn((
         Enemy,
         Cyclops,
         Sprite::from_image(asset_server.load("characters/enemies/cyclops.png")),
         Transform::from_translation(spawn_position).with_scale(Vec3::splat(CYCLOPS_SCALE)),
         EnemyAIState { state: EnemyBehaviorState::Patrolling },
         EnemyPatrol { origin: patrol_origin, range: patrol_range, direction: initial_direction },
         EnemyAlert { trigger_radius: CYCLOPS_ALERT_RADIUS, leash_radius: CYCLOPS_LEASH_RADIUS },
         EnemySpeeds { patrol: CYCLOPS_PATROL_SPEED, chase: CYCLOPS_CHASE_SPEED },
         CyclopsCharge {
             windup: Timer::from_seconds(CYCLOPS_WINDUP_SECONDS, TimerMode::Once),
             charge: Timer::from_seconds(CYCLOPS_CHARGE_SECONDS, TimerMode::Once),
             cooldown: Timer::from_seconds(CYCLOPS_COOLDOWN_SECONDS, TimerMode::Once),
             facing: Vec2::X,
             ready: true,
         },
     ));
 }

 pub fn cyclops_ai_system(
     time: Res<Time>,
     player_query: Query<&Transform, (With<Player>, Without<Cyclops>)>,
     mut cyclops_query: Query<
         (
             &mut Transform,
             &mut EnemyAIState,
             &mut EnemyPatrol,
             &EnemyAlert,
             &EnemySpeeds,
             &mut CyclopsCharge,
         ),
         (With<Cyclops>, Without<Player>),
     >,
 ) {
     // 風壓 → 衝刺 → 冷卻的邏輯迴圈
 }

元件整合方面,我將敵人相關的系統封裝成 EnemyPlugin。在 PostStartup 階段出現敵人,確保地圖已經讀取完全,在 Update 階段持續執行 AI 行為邏輯,這種分離讓系統之間的依賴關係更加清楚,主要也是方便 debug 和測試。

impl Plugin for EnemyPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(PostStartup, (spawn_slime, spawn_cyclops))
            .add_systems(Update, (slime_ai_system, cyclops_ai_system));
    }
}

實際運作

實際demo

當敵人出現之後,整個遊戲完成度又上升了。原本單調的地圖變得生動起來,史萊姆像個稱職的守衛在自己的位置上來回巡邏,它的移動模式很有規律,玩家可以觀察並預測行為。

當玩家靠近史萊姆時,可以明顯感受到在瞬間 —— 它會立刻改變方向,直接朝玩家衝過來,速度也比巡邏時快了不少。這種從悠閒到緊張的轉換給玩家帶來了一些真實的威脅感。

獨眼巨人的表現也很有趣。當進入蓄力狀態時,會停在原地一小段時間(0.6秒),給了玩家一個反應時間,然後它會朝著玩家出現的方向高速衝刺,速度是平時的2.2倍。為了跟史萊姆做出區別,讓獨眼巨人的行走方向只能是水平而不包含垂直。

更重要的是,兩種敵人都會被限制在房間內,不會追出房間或卡在牆裡,玩家可以利用房間結構來決定戰術。

小結

最大的踩雷經驗是 Bevy 的查詢系統借用檢查。剛開始以為可以在同一個系統裡同時查詢玩家和敵人的 Transform,結果編譯器出現錯誤。後來知道應該使用 Without<T> 過濾器後,這個問題迎刃而解。總之在 ECS 系統中設計查詢時,一定要仔細考慮所有權問題。

另一個收穫是對遊戲設計的深入思考。原本我以為敵人 AI 行為只要會追玩家就夠了。但實際測試後發現,沒有限制的敵人會讓遊戲變得混亂。所以加入地盤意識和不同的行為模式後,每種生物都有自己的「個性」,感覺也比較有趣。

接下來可以考慮加入更多種類的敵人:比如遠程攻擊的敵人,或是需要特定方式才能擊敗的精英怪。每種新敵人都可以沿用現有的元件系統,只需要再加入新的行為元件就好。

今天的程式碼分享在 repo


上一篇
如何進出不同空間?
系列文
Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言