路线阶段:Unity 入门实战第 6 章。
本章目标:让 UI 成为“状态展示层”,而不是“逻辑控制层”,并确保战斗高频更新下稳定运行。
学习目标
完成本章后,你应该能做到:
- 拆分 HUD 与功能面板,建立统一 UI 管理入口。
- 用事件驱动替代 UI 每帧轮询,降低性能消耗。
- 保证技能冷却、血量、波次、目标信息同步正确。
- 做到场景切换与重开战斗后 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);
// ...几十行
}
问题:
- UI 直接依赖大量业务对象,耦合极高。
- 高刷文本与布局触发额外 CPU/GC。
- 状态变更时机不一致,出现“UI 先变/后变/不变”。
- 战斗重开后事件重复订阅,消息叠加。
UI 分层设计
推荐三层:
UIView:纯展示组件(Text/Image/Button)。UIPresenter:订阅事件,转换展示数据。UIRootManager:管理界面栈与面板切换。
原则:
- View 不写业务逻辑。
- Presenter 不直接操作游戏对象行为。
- UIManager 不知道战斗规则。
事件模型
本章先固定核心事件:
PlayerHpChanged(current,max)PlayerManaChanged(current,max)SkillCooldownChanged(skillId,remain,total)WaveChanged(current,total)TargetChanged(targetId,name,hp,maxHp)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 模块常见泄漏点:
- 重开战斗后重复订阅。
- 面板关闭但定时器没取消。
- 目标切换事件继续推到已销毁 View。
建议使用上一章的 EventSubscriptionGroup + SchedulerTokenGroup 管理 UI 资源,退出战斗时统一 OnDisable/OnDestroy。
与前面系统联动
- 技能系统:
SkillCooldownChanged事件来自技能组件。 - 波次系统:
WaveStarted/WaveCleared转换成WaveChanged展示。 - 目标锁定:
TargetLockSystem推送目标数据。 - 命令系统:施法失败原因通过事件弹到 HUD。
WebGL 兼容注意点
- 减少文本频繁拼接,必要时缓存格式化结果。
- 面板动画控制时避免复杂 Shader 特效。
- 控制 Canvas 数量,降低批次拆分。
验收清单
- 血蓝、冷却、波次、目标信息同步准确。
- 打开/关闭面板不影响战斗主循环逻辑。
- 重开战斗 10 次无重复事件触发。
- WebGL 构建下 UI 无明显卡顿与错位。
常见坑
坑 1:UI 直接读业务对象所有字段
导致 View 成为“半个逻辑层”。应通过 Presenter 和事件中转。
坑 2:所有 UI 都放一个 Canvas
高频变化元素会导致整 Canvas 重建。建议 HUD 与静态面板分离。
坑 3:失败提示每帧覆盖
用户看不到信息。应做短时消息队列或节流。
本月作业
实现“战斗结果面板”:
- 显示通关时间、总伤害、受击次数、击杀数。
- 数据来自回放统计而非临时变量。
- 支持一键重开并保证 UI 状态完全重置。
下一章进入 Unity 入门实战 07:存档与进度系统(关卡进度、配置版本与容错迁移)。