iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0

血量條 UI 展示

上一篇把敵人攻擊系統打通後,今天的重點放在畫面呈現上,為玩家與敵人顯示血量條。這次拆成兩個面向處理 —— 玩家 HUD 掛在 UI 系統上,敵人血條則是場景中的懸浮元件,兩者都共享 Health 元件資料,確保數值更新不會彼此脫節。

視覺設計與資料準備

先在 constants.rs 裡整理所有血量條相關的數據。

  • 玩家血條的位置/尺寸
  • 敵人的寬度與高度
  • 為敵人補上的初始 HP

這些資料都集中在常數檔案中,方便後續調整:

// src/constants.rs
pub const PLAYER_HEALTH_BAR_WIDTH: f32 = 220.0;
pub const PLAYER_HEALTH_BAR_HEIGHT: f32 = 22.0;
pub const PLAYER_HEALTH_BAR_MARGIN: f32 = 18.0;

pub const SLIME_HEALTH: i32 = 35;
pub const CYCLOPS_HEALTH: i32 = 80;

pub const ENEMY_HEALTH_BAR_WIDTH: f32 = 50.0;
pub const ENEMY_HEALTH_BAR_HEIGHT: f32 = 6.0;
pub const ENEMY_HEALTH_BAR_OFFSET_Y: f32 = 48.0;

同時把敵人產生流程補上 Health::new(...),等於讓敵人也正式進入共用的血量管理:

// src/systems/enemy.rs
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)),
    Health::new(SLIME_HEALTH),
    EnemyAIState { state: EnemyBehaviorState::Patrolling },
    // ...省略巡邏/警戒/攻擊設定
));

HUD - 玩家角色血條的 UI

玩家角色的血條放在畫面左上角,採用 Bevy 的 Node 介面,利用父節點當外框、子節點填滿百分比,並透過 PlayerHealthUiFill 元件快速取得實例:

// src/systems/ui.rs
pub fn setup_player_health_ui(mut commands: Commands) {
    commands
        .spawn((
            PlayerHealthUiRoot,
            Node {
                width: Val::Px(PLAYER_HEALTH_BAR_WIDTH),
                height: Val::Px(PLAYER_HEALTH_BAR_HEIGHT),
                position_type: PositionType::Absolute,
                top: Val::Px(PLAYER_HEALTH_BAR_MARGIN),
                left: Val::Px(PLAYER_HEALTH_BAR_MARGIN),
                border: UiRect::all(Val::Px(2.0)),
                ..Default::default()
            },
            BackgroundColor(Color::srgba(0.08, 0.08, 0.1, 0.9)),
            BorderColor(Color::srgba(0.02, 0.02, 0.02, 1.0)),
        ))
        .with_children(|parent| {
            parent.spawn((
                PlayerHealthUiFill,
                Node {
                    width: Val::Percent(100.0),
                    height: Val::Percent(100.0),
                    ..Default::default()
                },
                BackgroundColor(Color::srgba(0.84, 0.16, 0.18, 1.0)),
            ));
        });
}

pub fn update_player_health_ui(
    player_query: Query<&Health, With<Player>>,
    mut fill_query: Query<&mut Node, With<PlayerHealthUiFill>>,
) {
    let Some(health) = player_query.iter().next() else { return; };
    let Some(mut node) = fill_query.iter_mut().next() else { return; };

    if health.max <= 0 {
        node.width = Val::Percent(0.0);
        return;
    }

    let ratio = (health.current as f32).clamp(0.0, health.max as f32) / health.max as f32;
    node.width = Val::Percent((ratio * 100.0).clamp(0.0, 100.0));
}

這套邏輯很單純,先找玩家的 Health,再把子節點的寬度調成百分比。UI 事件和遊戲邏輯依舊完全解耦,update_player_health_ui 只依賴查詢,不必直接碰玩家的 Sprite 或 Transform。

懸浮血條 - 讓敵人帶著自己的生命值

敵人的血條是場景中的實體。為了避免複用太多泛型,另外定義了幾個標記元件:

// src/components/ui.rs
#[derive(Component)]
pub struct HealthBarTarget {
    pub target: Entity,
}

#[derive(Component)]
pub struct HealthBarFollow {
    pub target: Entity,
    pub offset: Vec3,
}

#[derive(Component)]
pub struct EnemyHealthBarRoot;

#[derive(Component)]
pub struct EnemyHealthBarFill;

spawn_enemy_health_bars 會在敵人第一次出現時產生血條,並將 HealthBarFollow/HealthBarTarget 綁回原先的敵人實體:

// src/systems/ui.rs
pub fn spawn_enemy_health_bars(
    mut commands: Commands,
    query: Query<(Entity, &Transform, &Health), (With<Enemy>, Added<Enemy>)>,
) {
    for (enemy_entity, transform, health) in &query {
        if health.max <= 0 {
            continue;
        }

        let offset = Vec3::new(0.0, ENEMY_HEALTH_BAR_OFFSET_Y, 6.0);
        let base_translation = transform.translation + offset;

        commands
            .spawn((
                EnemyHealthBarRoot,
                HealthBarFollow::new(enemy_entity, offset),
                Transform::from_translation(base_translation),
            ))
            .with_children(|parent| {
                parent.spawn(Sprite::from_color(
                    Color::srgba(0.08, 0.08, 0.08, 0.85),
                    Vec2::new(ENEMY_HEALTH_BAR_WIDTH, ENEMY_HEALTH_BAR_HEIGHT),
                ));

        let mut fill_sprite = Sprite::from_color(
            Color::srgba(0.86, 0.18, 0.22, 0.95),
            Vec2::new(ENEMY_HEALTH_BAR_WIDTH, ENEMY_HEALTH_BAR_HEIGHT),
        );
        fill_sprite.anchor = Anchor::CenterLeft;

        parent.spawn((
            EnemyHealthBarFill,
            HealthBarTarget::new(enemy_entity),
            fill_sprite,
            Transform::from_translation(Vec3::new(-ENEMY_HEALTH_BAR_WIDTH / 2.0, 0.0, 1.0)),
        ));
            });
    }
}

後續兩個系統分別處理位置同步與比例縮放:

pub fn update_enemy_health_bar_positions(
    mut commands: Commands,
    owner_query: Query<&GlobalTransform>,
    mut bar_query: Query<(Entity, &HealthBarFollow, &mut Transform), With<EnemyHealthBarRoot>>,
) {
    for (entity, follow, mut transform) in &mut bar_query {
        match owner_query.get(follow.target) {
            Ok(owner_transform) => {
                let mut translation = owner_transform.translation();
                translation += follow.offset;
                transform.translation = translation;
            }
            Err(_) => {
                commands.entity(entity).despawn();
            }
        }
    }
}

pub fn update_enemy_health_bar_fill(
    health_query: Query<&Health>,
    mut fill_query: Query<(&HealthBarTarget, &mut Transform), With<EnemyHealthBarFill>>,
) {
    for (target, mut transform) in &mut fill_query {
        let Ok(health) = health_query.get(target.target) else {
            continue;
        };

        if health.max <= 0 {
            transform.scale.x = 0.0;
            continue;
        }

        let ratio = (health.current as f32).clamp(0.0, health.max as f32) / health.max as f32;
        transform.scale.x = ratio.clamp(0.0, 1.0);
    }
}

這樣血條就能跟著敵人移動,也能隨著 Health 數據同步變化。若敵人被移除,找不到 GlobalTransform 的分支會直接把血條清掉,避免場景殘留節點。

UiPlugin 封裝與排程安排

我還為血量條專門開了一個 UiPlugin,集中注入所有 UI 相關的系統,並把它加到 main.rs 的插件列表中:

// src/plugins/ui.rs
impl Plugin for UiPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, setup_player_health_ui)
            .add_systems(Update, (spawn_enemy_health_bars, update_player_health_ui))
            .add_systems(
                PostUpdate,
                (
                    update_enemy_health_bar_positions,
                    update_enemy_health_bar_fill,
                ),
            );
    }
}

排程順序是刻意安排的:

  • Startup 建立 HUD 節點,避免讀入場景時看到空白畫面。
  • Update 每幀偵測新敵人與玩家血量變化。
  • PostUpdate 再推一次敵人血條的位置與比例,確保所有 Transform 更新都已完成。

UiPlugin 被加進 App::add_plugins(...) 的 tuple 中後,整體邏輯就與玩家/敵人系統平行運作,維持模組化的架構整潔度。

小結

demo

這次抽象出 HealthBarTargetHealthBarFollowEnemyHealthBarFill 等標記元件後,未來要做 BOSS 指令角色或特殊單位的血量呈現時可以直接重複使用。也因為玩家與敵人共用 Health,不論是扣血、加血還是加護盾,只要更新數值就能自動反映在 UI 上。

下一步計畫是把敵人受到攻擊的流程補上,並讓這套血量條系統立刻顯示出敵人的受傷狀態,完成雙向戰鬥的視覺回饋。

今天的程式碼分享在 repo


上一篇
讓史萊姆的普攻造成實際傷害
下一篇
玩家近戰攻擊命中與門的互動優化
系列文
Bevy Rogue-lite 勇者冒險篇 × Rust 遊戲開發筆記18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言