Article

Unity 入门实战 06:UI 架构与状态同步(战斗 HUD 不再乱套)

路线阶段:Unity 入门实战第 6 章。
本章目标:让 UI 成为“状态展示层”,而不是“逻辑控制层”,并确保战斗高频更新下稳定运行。

学习目标

完成本章后,你应该能做到:

  1. 拆分 HUD 与功能面板,建立统一 UI 管理入口。
  2. 用事件驱动替代 UI 每帧轮询,降低性能消耗。
  3. 保证技能冷却、血量、波次、目标信息同步正确。
  4. 做到场景切换与重开战斗后 UI 不残留脏状态。

为什么 UI 经常成为项目灾区

常见坏味道:

void Update()
{
    hpText.text = player.Hp.ToString();
    manaText.text = player.Mana.ToString();
    waveText.text = waveDirector.CurrentWave.ToString();
    skillQCd.fillAmount = player.Skills.GetCooldownRemain(1001);
    // ...几十行
}

问题:

  1. UI 直接依赖大量业务对象,耦合极高。
  2. 高刷文本与布局触发额外 CPU/GC。
  3. 状态变更时机不一致,出现“UI 先变/后变/不变”。
  4. 战斗重开后事件重复订阅,消息叠加。

UI 分层设计

推荐三层:

  1. UIView:纯展示组件(Text/Image/Button)。
  2. UIPresenter:订阅事件,转换展示数据。
  3. UIRootManager:管理界面栈与面板切换。

原则:

  • View 不写业务逻辑。
  • Presenter 不直接操作游戏对象行为。
  • UIManager 不知道战斗规则。

事件模型

本章先固定核心事件:

  1. PlayerHpChanged(current,max)
  2. PlayerManaChanged(current,max)
  3. SkillCooldownChanged(skillId,remain,total)
  4. WaveChanged(current,total)
  5. TargetChanged(targetId,name,hp,maxHp)
  6. BattleStateChanged(state)

HUD 组件

public sealed class BattleHudView : MonoBehaviour
{
    [SerializeField] private TMPro.TextMeshProUGUI _hpText;
    [SerializeField] private TMPro.TextMeshProUGUI _manaText;
    [SerializeField] private TMPro.TextMeshProUGUI _waveText;

    [SerializeField] private SkillSlotView _q;
    [SerializeField] private SkillSlotView _w;
    [SerializeField] private SkillSlotView _e;
    [SerializeField] private SkillSlotView _r;

    [SerializeField] private TargetPanelView _target;

    public void SetHp(int current, int max)
    {
        _hpText.text = current + " / " + max;
    }

    public void SetMana(int current, int max)
    {
        _manaText.text = current + " / " + max;
    }

    public void SetWave(int current, int total)
    {
        _waveText.text = "Wave " + current + " / " + total;
    }

    public void SetSkillCooldown(int skillId, float remain, float total)
    {
        if (skillId == 1001) _q.SetCooldown(remain, total);
        else if (skillId == 1002) _w.SetCooldown(remain, total);
        else if (skillId == 1003) _e.SetCooldown(remain, total);
        else if (skillId == 1004) _r.SetCooldown(remain, total);
    }

    public void SetTarget(string name, int hp, int maxHp)
    {
        _target.SetData(name, hp, maxHp);
    }

    public void ClearTarget()
    {
        _target.Clear();
    }
}

Presenter(事件驱动)

public sealed class BattleHudPresenter : IDisposable
{
    private readonly BattleHudView _view;
    private readonly EventBus _eventBus;
    private readonly EventSubscriptionGroup _subs;

    public BattleHudPresenter(BattleHudView view, EventBus eventBus)
    {
        _view = view;
        _eventBus = eventBus;
        _subs = new EventSubscriptionGroup(eventBus);
    }

    public void Bind()
    {
        _subs.Bind("PlayerHpChanged", OnHpChanged);
        _subs.Bind("PlayerManaChanged", OnManaChanged);
        _subs.Bind("SkillCooldownChanged", OnSkillCooldownChanged);
        _subs.Bind("WaveChanged", OnWaveChanged);
        _subs.Bind("TargetChanged", OnTargetChanged);
    }

    public void Dispose()
    {
        _subs.OnDisable();
        _subs.OnDestroy();
    }

    private void OnHpChanged(object payload)
    {
        var data = (Int2)payload;
        _view.SetHp(data.A, data.B);
    }

    private void OnManaChanged(object payload)
    {
        var data = (Int2)payload;
        _view.SetMana(data.A, data.B);
    }

    private void OnSkillCooldownChanged(object payload)
    {
        var data = (SkillCooldownPayload)payload;
        _view.SetSkillCooldown(data.SkillId, data.Remain, data.Total);
    }

    private void OnWaveChanged(object payload)
    {
        var data = (Int2)payload;
        _view.SetWave(data.A, data.B);
    }

    private void OnTargetChanged(object payload)
    {
        var data = (TargetPayload)payload;
        if (data.TargetId <= 0)
        {
            _view.ClearTarget();
            return;
        }

        _view.SetTarget(data.Name, data.Hp, data.MaxHp);
    }
}

public struct Int2
{
    public int A;
    public int B;

    public Int2(int a, int b)
    {
        A = a;
        B = b;
    }
}

UIRootManager(面板管理)

public enum UIPanelType
{
    None = 0,
    Pause = 1,
    Inventory = 2,
    Result = 3
}

public sealed class UIRootManager : MonoBehaviour
{
    [SerializeField] private GameObject _pausePanel;
    [SerializeField] private GameObject _inventoryPanel;
    [SerializeField] private GameObject _resultPanel;

    private readonly Stack<UIPanelType> _stack = new Stack<UIPanelType>(8);

    public void Open(UIPanelType type)
    {
        if (type == UIPanelType.None)
        {
            return;
        }

        SetVisible(type, true);
        _stack.Push(type);
    }

    public void CloseTop()
    {
        if (_stack.Count == 0)
        {
            return;
        }

        var top = _stack.Pop();
        SetVisible(top, false);
    }

    public void CloseAll()
    {
        while (_stack.Count > 0)
        {
            var top = _stack.Pop();
            SetVisible(top, false);
        }
    }

    private void SetVisible(UIPanelType type, bool visible)
    {
        if (type == UIPanelType.Pause) _pausePanel.SetActive(visible);
        else if (type == UIPanelType.Inventory) _inventoryPanel.SetActive(visible);
        else if (type == UIPanelType.Result) _resultPanel.SetActive(visible);
    }
}

高频状态同步优化

1. 仅在变化时推事件

例如 HP 更新:

public sealed class PlayerStatPublisher
{
    private readonly EventBus _eventBus;
    private int _lastHp;
    private int _lastMana;

    public PlayerStatPublisher(EventBus eventBus)
    {
        _eventBus = eventBus;
        _lastHp = int.MinValue;
        _lastMana = int.MinValue;
    }

    public void Tick(Actor player)
    {
        var hp = (int)player.Hp;
        if (hp != _lastHp)
        {
            _lastHp = hp;
            _eventBus.Publish("PlayerHpChanged", new Int2(hp, (int)player.MaxHp));
        }

        var mana = player.Mana;
        if (mana != _lastMana)
        {
            _lastMana = mana;
            _eventBus.Publish("PlayerManaChanged", new Int2(mana, player.MaxMana));
        }
    }
}

2. 冷却刷新节流

冷却显示不需要每帧刷新到小数点后两位。建议每 0.05 秒刷新一次 UI。

生命周期治理

UI 模块常见泄漏点:

  1. 重开战斗后重复订阅。
  2. 面板关闭但定时器没取消。
  3. 目标切换事件继续推到已销毁 View。

建议使用上一章的 EventSubscriptionGroup + SchedulerTokenGroup 管理 UI 资源,退出战斗时统一 OnDisable/OnDestroy

与前面系统联动

  1. 技能系统SkillCooldownChanged 事件来自技能组件。
  2. 波次系统WaveStarted/WaveCleared 转换成 WaveChanged 展示。
  3. 目标锁定TargetLockSystem 推送目标数据。
  4. 命令系统:施法失败原因通过事件弹到 HUD。

WebGL 兼容注意点

  1. 减少文本频繁拼接,必要时缓存格式化结果。
  2. 面板动画控制时避免复杂 Shader 特效。
  3. 控制 Canvas 数量,降低批次拆分。

验收清单

  1. 血蓝、冷却、波次、目标信息同步准确。
  2. 打开/关闭面板不影响战斗主循环逻辑。
  3. 重开战斗 10 次无重复事件触发。
  4. WebGL 构建下 UI 无明显卡顿与错位。

常见坑

坑 1:UI 直接读业务对象所有字段

导致 View 成为“半个逻辑层”。应通过 Presenter 和事件中转。

坑 2:所有 UI 都放一个 Canvas

高频变化元素会导致整 Canvas 重建。建议 HUD 与静态面板分离。

坑 3:失败提示每帧覆盖

用户看不到信息。应做短时消息队列或节流。

本月作业

实现“战斗结果面板”:

  1. 显示通关时间、总伤害、受击次数、击杀数。
  2. 数据来自回放统计而非临时变量。
  3. 支持一键重开并保证 UI 状态完全重置。

下一章进入 Unity 入门实战 07:存档与进度系统(关卡进度、配置版本与容错迁移)。