Files
zeling_v2/Assets/_Game/Scripts/UI/Navigation/UINavigator.cs
2026-06-07 11:49:55 +08:00

183 lines
8.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// <see cref="IUINavigator"/> 的实现:统一 UI 导航栈。常驻 Persistent 场景,
/// 经 ServiceLocator 暴露,主菜单与游戏内共用同一套栈语义。
///
/// <para>压栈:记录压栈前焦点;据新面板 <see cref="PushMode"/> 处理下方面板
/// Replace→停用Modal→保留可见但 <see cref="UIPanelBase.SetInteractableLayer"/>(false)
/// 使其退出导航图);激活新面板并延后一帧聚焦其首项。</para>
/// <para>出栈:关闭栈顶,按其压栈时的 mode 还原下方面板,恢复焦点到压栈前的选中项。</para>
/// <para>取消:本类是 EVT_UICancelPressed 的唯一消费者,仅在栈顶 <see cref="UIPanelBase.CanCancel"/>
/// 时出栈一层(逐层回退)。</para>
/// </summary>
[DefaultExecutionOrder(+40)] // 早于 UIManager(+50),确保委托方解析得到本服务
public class UINavigator : MonoBehaviour, IUINavigator
{
[Tooltip("UI 取消操作ESC / 手柄 B·Circle。本导航器为唯一订阅者按下时关闭栈顶一层。对应 EVT_UICancelPressed。")]
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
private readonly Stack<UIStackEntry> _stack = new();
private readonly CompositeDisposable _subs = new();
private Coroutine _focusRoutine;
public UIPanelBase Top => _stack.Count > 0 ? _stack.Peek().Panel : null;
public int Depth => _stack.Count;
public event Action StackChanged;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void OnEnable()
{
ServiceLocator.Register<IUINavigator>(this);
_onUICancelPressed?.Subscribe(HandleCancel).AddTo(_subs);
SceneManager.sceneUnloaded += OnSceneUnloaded;
}
private void OnDisable()
{
SceneManager.sceneUnloaded -= OnSceneUnloaded;
_subs.Clear();
ServiceLocator.Unregister<IUINavigator>(this);
}
// ── 压栈 ──────────────────────────────────────────────────────────────
public void Push(UIPanelBase panel, PushMode? mode = null)
{
if (panel == null) return;
PurgeDead();
PushMode m = mode ?? panel.DefaultMode;
// 处理下方面板Replace 停用、Modal 屏蔽交互(退出导航图,杜绝上下键穿透)。
if (_stack.Count > 0)
{
var below = _stack.Peek().Panel;
if (below != null)
{
below.OnFocusLost();
if (m == PushMode.Replace) below.gameObject.SetActive(false);
else below.SetInteractableLayer(false);
}
}
var entry = new UIStackEntry
{
Panel = panel,
Mode = m,
FocusToRestore = EventSystem.current != null ? EventSystem.current.currentSelectedGameObject : null,
OwningScene = panel.gameObject.scene,
};
_stack.Push(entry);
panel.gameObject.SetActive(true);
panel.SetInteractableLayer(true);
FocusNextFrame(panel.FirstSelectableGO);
StackChanged?.Invoke();
}
// ── 出栈 ──────────────────────────────────────────────────────────────
public void Pop()
{
PurgeDead();
if (_stack.Count == 0) return;
var top = _stack.Pop();
if (top.Panel != null) top.Panel.gameObject.SetActive(false);
UIPanelBase below = _stack.Count > 0 ? _stack.Peek().Panel : null;
if (below != null)
{
// 按 top 压栈时的 mode 还原下方面板。
if (top.Mode == PushMode.Replace) below.gameObject.SetActive(true);
else below.SetInteractableLayer(true);
below.OnFocusGained();
}
// 恢复焦点到压栈前的选中项(失效则回落到下层首项)。
GameObject restore = top.FocusToRestore != null && top.FocusToRestore.activeInHierarchy
? top.FocusToRestore
: below != null ? below.FirstSelectableGO : null;
FocusNextFrame(restore);
StackChanged?.Invoke();
}
public void PopToRoot()
{
while (_stack.Count > 0) Pop();
}
// ── 取消ESC / 手柄 B──────────────────────────────────────────────
private void HandleCancel()
{
PurgeDead();
if (_stack.Count == 0) return; // 栈空(如主菜单根):无操作
if (Top != null && !Top.CanCancel) return;
Pop(); // 仅关栈顶一层
}
// ── 结果面板 ──────────────────────────────────────────────────────────
public Task<T> PushForResultAsync<T>(UIResultPanel<T> panel, CancellationToken ct = default)
{
if (panel == null) return Task.FromResult<T>(default);
Task<T> task = panel.BeginResult(ct);
Push(panel, PushMode.Modal); // 结果对话框天然模态:下层保留可见但屏蔽交互
return task;
}
// ── 场景卸载清理 ──────────────────────────────────────────────────────
private void OnSceneUnloaded(Scene s)
{
if (_stack.Count == 0) return;
// Stack 无法删中间项:过滤后按原序重建(保留非本场景且未销毁的层)。
var kept = new List<UIStackEntry>();
foreach (var e in _stack) // 枚举顺序:栈顶→栈底
if (e.Panel != null && e.OwningScene != s) kept.Add(e);
if (kept.Count == _stack.Count) return; // 无变化
_stack.Clear();
for (int i = kept.Count - 1; i >= 0; i--) _stack.Push(kept[i]);
StackChanged?.Invoke();
}
private void PurgeDead()
{
bool changed = false;
while (_stack.Count > 0 && _stack.Peek().Panel == null) { _stack.Pop(); changed = true; }
if (changed) StackChanged?.Invoke();
}
// ── 焦点(延后一帧,避开 OnEnable / UISelectionRestorer 同帧竞争)────────
private void FocusNextFrame(GameObject target)
{
if (_focusRoutine != null) StopCoroutine(_focusRoutine);
if (!isActiveAndEnabled) return;
_focusRoutine = StartCoroutine(FocusRoutine(target));
}
private IEnumerator FocusRoutine(GameObject target)
{
yield return null;
_focusRoutine = null;
if (target == null || !target.activeInHierarchy) yield break;
if (EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(target);
}
}
}