現在的史萊姆雖然會追擊玩家,但在體驗上還沒有真正的威脅感。這是因為目前玩家角色還沒有真的受到影響,就算被追到也不會有任何傷害,所以在這篇預計目標是當玩家角色被攻擊時會被扣除血量。
建立一套完整的傷害系統,不只是讓敵人能夠攻擊玩家,更要提供即時的視覺回饋,讓玩家清楚知道自己受到了攻擊。同時,這套系統也需要跟之前一樣可以靈活地擴充,未來不管是加入新的敵人類型、不同的攻擊方式,還是更複雜的戰鬥機制,都能輕鬆做到。
在設計史萊姆的攻擊方式時,需要考慮一些狀況。首先是攻擊類型的選擇:史萊姆應該是近戰攻擊還是遠程攻擊?
考慮到史萊姆在大多數遊戲中都是低階的近戰怪物,我選擇了接觸傷害的模式,雖然也可以做不同的攻擊型態,但是需要為後續的遠程敵人留下差異化空間,所以還是做了這個選擇。
而接觸傷害的實作方式選擇了比較簡單的偵測距離方案:計算敵人中心點到玩家中心點的距離,當距離小於攻擊半徑時觸發傷害。
另一個重要設計是冷卻機制。如果沒有冷卻時間,史萊姆一旦接觸到玩家角色就會在單幀內造成大量傷害,導致玩家瞬間死亡。加入冷卻機制後,變成了有節奏的持續攻擊,玩家有機會反應和逃跑。
在視覺回饋的設計上,我選擇了事件驅動的架構。當傷害發生時,敵人系統發送 PlayerDamagedEvent
,玩家系統監聽這個事件並觸發視覺效果。這種解耦設計讓不同系統之間的依賴關係更清晰,也是為日後加入音效、螢幕震動等其他回饋效果,留下一個擴充空間。
如何在 ECS 架構下建立一套靈活的傷害系統,主要是將攻擊能力抽象成元件,讓任何實體都能通過加入 EnemyAttack
元件來獲得攻擊能力,而不需要修改現有的 AI 邏輯。
首先是基礎的常數定義,將所有數值集中管理:
// src/constants.rs
pub const SLIME_ATTACK_DAMAGE: i32 = 5;
pub const SLIME_ATTACK_RADIUS: f32 = 28.0;
pub const SLIME_ATTACK_COOLDOWN: f32 = 1.0;
pub const PLAYER_DAMAGE_FLASH_COUNT: u8 = 4;
pub const PLAYER_DAMAGE_FLASH_INTERVAL: f32 = 0.08;
pub const PLAYER_DAMAGE_FLASH_COLOR: [f32; 4] = [1.0, 0.4, 0.4, 1.0];
EnemyAttack
元件的設計展示了數據驅動的概念。傷害值、攻擊半徑、冷卻時間都作為數據資料儲存在元件中,系統只負責邏輯處理。這樣的好處是調整遊戲平衡時只需要修改常數,而不用深入系統邏輯。
#[derive(Component)]
pub struct EnemyAttack {
pub damage: i32,
pub radius: f32,
pub cooldown: Timer,
}
我讓計時器從已完成狀態開始,這樣史萊姆一出生就能立刻攻擊,而不需要等待第一次冷卻:
let mut timer = Timer::from_seconds(SLIME_ATTACK_COOLDOWN, TimerMode::Repeating);
timer.set_elapsed(timer.duration()); // 設置為已完成狀態
攻擊檢測系統的核心是距離計算。Bevy 的 Vec2::distance()
方法內部使用了歐幾里得距離,效能優於手動計算。查詢過濾器的使用也很關鍵:With<Slime>
確保只有史萊姆會執行這套攻擊邏輯,未來加入其他敵人類型時不會互相干擾。
let distance = attacker_transform.translation.truncate().distance(player_position);
if distance <= attack.radius && attack.cooldown.finished() {
// 執行攻擊邏輯
}
事件系統的應用是當傷害發生時,敵人系統不直接處理視覺效果,而是發送 PlayerDamagedEvent
事件。這種解耦設計遵循了單一職責原則:
敵人系統只負責計算傷害,視覺效果由玩家系統處理。
這種架構非常好擴充,如果未來要加入傷害數字彈出、螢幕震動、音效播放等效果,只需要增加更多的事件監聽者,而不需要修改現有的攻擊邏輯。這也是事件驅動架構的精妙之處,讓不同系統之間保持鬆散耦合。
攻擊檢測的完整系統如下:
pub fn enemy_contact_attack_system(
time: Res<Time>,
mut player_query: Query<(&Transform, &mut Health), With<Player>>,
mut attacker_query: Query<(&Transform, &mut EnemyAttack), With<Slime>>,
mut damage_events: EventWriter<PlayerDamagedEvent>,
) {
let mut player_iter = player_query.iter_mut();
let Some((player_transform, mut health)) = player_iter.next() else {
return;
};
let player_position = player_transform.translation.truncate();
for (attacker_transform, mut attack) in &mut attacker_query {
attack.cooldown.tick(time.delta());
let distance = attacker_transform
.translation
.truncate()
.distance(player_position);
if distance <= attack.radius && attack.cooldown.finished() {
let new_health = (health.current - attack.damage).max(0);
if new_health != health.current {
health.current = new_health;
info!(
"史萊姆攻擊造成 {} 傷害,玩家剩餘 HP: {}",
attack.damage, health.current
);
damage_events.write(PlayerDamagedEvent {
damage: attack.damage,
remaining_health: health.current,
});
}
attack.cooldown.reset();
}
}
}
視覺回饋系統的部分也是使用在 Bevy 事件系統。DamageFlash
元件採用了狀態標記的設計模式,它本身不包含複雜邏輯,只是標記玩家目前處於受傷閃爍狀態,具體的閃爍行為由專門的系統處理。
#[derive(Component)]
pub struct DamageFlash {
pub timer: Timer,
pub flash_count: u8,
pub max_flashes: u8,
pub is_red: bool,
}
閃爍效果的部分則需要注意順序,在 PostUpdate
階段處理視覺效果,確保所有遊戲邏輯都已完成,否則可能會造成錯誤。
另外事件的使用時機也提一下。我在 PlayerDamagedEvent
中不只包含傷害值,還包含剩餘血量。比如當血量降到某個閾值時觸發特殊效果,或是根據剩餘血量調整閃爍顏色的深淺。
#[derive(Event, Clone, Copy)]
pub struct PlayerDamagedEvent {
pub damage: i32,
pub remaining_health: i32,
}
pub fn trigger_player_damage_flash_system(
mut commands: Commands,
mut events: EventReader<PlayerDamagedEvent>,
mut player_query: Query<
(Entity, &mut Sprite, Option<Mut<DamageFlash>>),
With<Player>,
>,
) {
for event in events.read() {
for (entity, mut sprite, flash_opt) in &mut player_query {
sprite.color = Color::srgba(
PLAYER_DAMAGE_FLASH_COLOR[0],
PLAYER_DAMAGE_FLASH_COLOR[1],
PLAYER_DAMAGE_FLASH_COLOR[2],
PLAYER_DAMAGE_FLASH_COLOR[3],
);
if let Some(mut flash) = flash_opt {
flash.timer.reset();
flash.flash_count = 0;
flash.is_red = true;
} else {
commands.entity(entity).insert(DamageFlash {
timer: Timer::from_seconds(PLAYER_DAMAGE_FLASH_INTERVAL, TimerMode::Repeating),
flash_count: 0,
max_flashes: PLAYER_DAMAGE_FLASH_COUNT,
is_red: true,
});
}
}
}
}
最後是 Plugin 的整合:
// src/plugins/player.rs
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
app.add_event::<PlayerDamagedEvent>()
.add_systems(Startup, setup)
.add_systems(PostStartup, spawn_player)
.add_systems(Update, (movement_system, health_system))
.add_systems(
PostUpdate,
(
trigger_player_damage_flash_system,
player_damage_flash_tick_system,
),
);
}
}
// src/plugins/enemy.rs
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,
enemy_contact_attack_system,
),
);
}
}
實際測試後,傷害系統帶來的體驗非常理想。原本玩家可以無視史萊姆的存在,現在每次接近都可能會付出代價。史萊姆每秒 5 點傷害的設定目前應該是剛好,即不會讓玩家瞬間死亡,但也足夠構成威脅。
閃爍效果的時間設計有稍微調整過。0.08 秒的間隔讓閃爍看起來讓玩家有被傷害的感覺,4 次閃爍的總持續時間約 0.32 秒,剛好讓玩家注意到受傷但不會過度干擾遊戲畫面。這些都是經過反覆調整才能達到目前覺得理想的效果。
而冷卻機制的存在讓戰鬥變得有節奏感。玩家可以利用史萊姆的攻擊間隔進行打帶跑戰術,接近史萊姆受到一次攻擊後迅速後退,等待適當時機再次接近,這種互動模式比單純的追逐遊戲讓體驗上更加有趣。
現在已經有了基本的傷害系統,接下來可以考慮增加更豐富的戰鬥機制:像是玩家的反擊能力、不同類型的傷害、狀態效果等等。
EnemyAttack
元件可以加到任何實體上,不管是史萊姆、獨眼巨人,還是未來的其他敵人類型。這種設計讓程式碼的重複使用性大大提升,也降低了維護成本。
遊戲平衡調整的部分,所有的數值都集中在 constants.rs
中,想要調整難度時只需要修改幾個常數即可。這種數值驅動的設計理念在遊戲開發中滿重要的,尤其是在需要頻繁調整的 Rogue-lite 遊戲中。
今天的程式碼分享在 repo