iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Rust

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

讓史萊姆的普攻造成實際傷害

  • 分享至 

  • xImage
  •  

現在的史萊姆雖然會追擊玩家,但在體驗上還沒有真正的威脅感。這是因為目前玩家角色還沒有真的受到影響,就算被追到也不會有任何傷害,所以在這篇預計目標是當玩家角色被攻擊時會被扣除血量。

建立一套完整的傷害系統,不只是讓敵人能夠攻擊玩家,更要提供即時的視覺回饋,讓玩家清楚知道自己受到了攻擊。同時,這套系統也需要跟之前一樣可以靈活地擴充,未來不管是加入新的敵人類型、不同的攻擊方式,還是更複雜的戰鬥機制,都能輕鬆做到。

史萊姆是怎麼樣攻擊

在設計史萊姆的攻擊方式時,需要考慮一些狀況。首先是攻擊類型的選擇:史萊姆應該是近戰攻擊還是遠程攻擊?

考慮到史萊姆在大多數遊戲中都是低階的近戰怪物,我選擇了接觸傷害的模式,雖然也可以做不同的攻擊型態,但是需要為後續的遠程敵人留下差異化空間,所以還是做了這個選擇。

而接觸傷害的實作方式選擇了比較簡單的偵測距離方案:計算敵人中心點到玩家中心點的距離,當距離小於攻擊半徑時觸發傷害。

另一個重要設計是冷卻機制。如果沒有冷卻時間,史萊姆一旦接觸到玩家角色就會在單幀內造成大量傷害,導致玩家瞬間死亡。加入冷卻機制後,變成了有節奏的持續攻擊,玩家有機會反應和逃跑。

受到攻擊時的冷卻機制

在視覺回饋的設計上,我選擇了事件驅動的架構。當傷害發生時,敵人系統發送 PlayerDamagedEvent,玩家系統監聽這個事件並觸發視覺效果。這種解耦設計讓不同系統之間的依賴關係更清晰,也是為日後加入音效、螢幕震動等其他回饋效果,留下一個擴充空間。

ECS 架構下的傷害系統

如何在 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,
                ),
            );
    }
}

實際 Demo

demo

實際測試後,傷害系統帶來的體驗非常理想。原本玩家可以無視史萊姆的存在,現在每次接近都可能會付出代價。史萊姆每秒 5 點傷害的設定目前應該是剛好,即不會讓玩家瞬間死亡,但也足夠構成威脅。

閃爍效果的時間設計有稍微調整過。0.08 秒的間隔讓閃爍看起來讓玩家有被傷害的感覺,4 次閃爍的總持續時間約 0.32 秒,剛好讓玩家注意到受傷但不會過度干擾遊戲畫面。這些都是經過反覆調整才能達到目前覺得理想的效果。

而冷卻機制的存在讓戰鬥變得有節奏感。玩家可以利用史萊姆的攻擊間隔進行打帶跑戰術,接近史萊姆受到一次攻擊後迅速後退,等待適當時機再次接近,這種互動模式比單純的追逐遊戲讓體驗上更加有趣。

現在已經有了基本的傷害系統,接下來可以考慮增加更豐富的戰鬥機制:像是玩家的反擊能力、不同類型的傷害、狀態效果等等。

小結

EnemyAttack 元件可以加到任何實體上,不管是史萊姆、獨眼巨人,還是未來的其他敵人類型。這種設計讓程式碼的重複使用性大大提升,也降低了維護成本。

遊戲平衡調整的部分,所有的數值都集中在 constants.rs 中,想要調整難度時只需要修改幾個常數即可。這種數值驅動的設計理念在遊戲開發中滿重要的,尤其是在需要頻繁調整的 Rogue-lite 遊戲中。

今天的程式碼分享在 repo


上一篇
如何讓史萊姆動起來(敵人的 AI 行為)
系列文
Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言