是時候加入其他角色了,一個有趣的遊戲怎麼可以少了挑戰性呢?玩家應該要保持警覺,思考下一步該怎麼走。所以本篇就要為我們的地下城新增第一批「不速之客」—— 擁有基本 AI 的敵人。
從玩家體驗的角度來看,敵人不應該只是移動的沙包,而是要有自己的行為模式。我們在之前使用的素材中,先拿出兩個角色:
主要挑戰的目標是建立一套可以擴充的敵人 AI 架構,而且要確保它們不會傻傻地撞牆或走出房間邊界。
在思考敵人行為時,面臨了幾個重要的決定。首先是架構的選擇:應該用簡單的狀態機(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,
}
敵人 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));
}
}
當敵人出現之後,整個遊戲完成度又上升了。原本單調的地圖變得生動起來,史萊姆像個稱職的守衛在自己的位置上來回巡邏,它的移動模式很有規律,玩家可以觀察並預測行為。
當玩家靠近史萊姆時,可以明顯感受到在瞬間 —— 它會立刻改變方向,直接朝玩家衝過來,速度也比巡邏時快了不少。這種從悠閒到緊張的轉換給玩家帶來了一些真實的威脅感。
獨眼巨人的表現也很有趣。當進入蓄力狀態時,會停在原地一小段時間(0.6秒),給了玩家一個反應時間,然後它會朝著玩家出現的方向高速衝刺,速度是平時的2.2倍。為了跟史萊姆做出區別,讓獨眼巨人的行走方向只能是水平而不包含垂直。
更重要的是,兩種敵人都會被限制在房間內,不會追出房間或卡在牆裡,玩家可以利用房間結構來決定戰術。
最大的踩雷經驗是 Bevy 的查詢系統借用檢查。剛開始以為可以在同一個系統裡同時查詢玩家和敵人的 Transform
,結果編譯器出現錯誤。後來知道應該使用 Without<T>
過濾器後,這個問題迎刃而解。總之在 ECS 系統中設計查詢時,一定要仔細考慮所有權問題。
另一個收穫是對遊戲設計的深入思考。原本我以為敵人 AI 行為只要會追玩家就夠了。但實際測試後發現,沒有限制的敵人會讓遊戲變得混亂。所以加入地盤意識和不同的行為模式後,每種生物都有自己的「個性」,感覺也比較有趣。
接下來可以考慮加入更多種類的敵人:比如遠程攻擊的敵人,或是需要特定方式才能擊敗的精英怪。每種新敵人都可以沿用現有的元件系統,只需要再加入新的行為元件就好。
今天的程式碼分享在 repo