UI系统
This commit is contained in:
@@ -36,6 +36,23 @@ namespace BaseGames.UI
|
||||
[Tooltip("打开时是否自动把焦点设到首项。")]
|
||||
[SerializeField] protected bool _selectFirstOnEnable = true;
|
||||
|
||||
[Header("导航栈")]
|
||||
[Tooltip("被压栈时,正下方面板的处理方式:Replace 停用下层(整屏切换);Modal 保留下层可见但屏蔽其交互(对话框)。")]
|
||||
[SerializeField] protected PushMode _defaultMode = PushMode.Replace;
|
||||
|
||||
[Tooltip("是否允许 ESC / 手柄 B 取消本面板(栈顶时)。确认破坏性操作的根面板可关闭。")]
|
||||
[SerializeField] protected bool _canCancel = true;
|
||||
|
||||
/// <summary>被压栈方式(<see cref="IUINavigator.Push"/> 未显式指定 mode 时采用)。</summary>
|
||||
public PushMode DefaultMode => _defaultMode;
|
||||
|
||||
/// <summary>栈顶时 ESC / 手柄 B 是否可取消本面板。</summary>
|
||||
public bool CanCancel => _canCancel;
|
||||
|
||||
/// <summary>键盘 / 手柄默认焦点项(<see cref="_firstSelected"/> 优先,否则 <see cref="ResolveFirstSelected"/>)。供导航器出/入栈聚焦。</summary>
|
||||
public GameObject FirstSelectableGO
|
||||
=> _firstSelected != null ? _firstSelected.gameObject : ResolveFirstSelected();
|
||||
|
||||
/// <summary>事件订阅容器,OnDisable 自动清理。子类订阅用 <c>channel.Subscribe(..).AddTo(_subs)</c>。</summary>
|
||||
protected readonly CompositeDisposable _subs = new();
|
||||
|
||||
@@ -44,6 +61,7 @@ namespace BaseGames.UI
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
protected virtual void OnEnable()
|
||||
{
|
||||
if (_canvasGroup == null) _canvasGroup = GetComponent<CanvasGroup>(); // 惰性解析(兼容运行时适配/未连线)
|
||||
OnPanelOpen();
|
||||
if (_canvasGroup != null && _fadeInDuration > 0f) PlayFadeIn();
|
||||
if (_selectFirstOnEnable) FocusFirst();
|
||||
@@ -62,9 +80,29 @@ namespace BaseGames.UI
|
||||
/// <summary>面板关闭时调用(OnDisable,_subs 已清理之后)。子类在此释放非 _subs 资源。</summary>
|
||||
protected virtual void OnPanelClose() { }
|
||||
|
||||
// ── 导航栈交互屏蔽 ────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 切换本面板"是否参与交互/导航"。导航器把模态对话框压在本面板之上时,
|
||||
/// 以此屏蔽本面板(<see cref="CanvasGroup"/> interactable+blocksRaycasts=false)——
|
||||
/// 子控件 <c>Selectable.IsInteractable()</c> 随之为 false,自动退出 Unity 导航图,
|
||||
/// 杜绝方向键穿透到下层(无 CanvasGroup 时为空操作)。
|
||||
/// </summary>
|
||||
public void SetInteractableLayer(bool on)
|
||||
{
|
||||
if (_canvasGroup == null) return;
|
||||
_canvasGroup.interactable = on;
|
||||
_canvasGroup.blocksRaycasts = on;
|
||||
}
|
||||
|
||||
// ── 焦点 ──────────────────────────────────────────────────────────────
|
||||
/// <summary>关闭子面板、本面板恢复为栈顶时调用(<see cref="IUIManager.CloseTopPanel"/> 触发)。</summary>
|
||||
public virtual void OnFocusRestored() => FocusFirst();
|
||||
/// <summary>本面板重新成为栈顶(上层出栈)时调用:默认聚焦首项。</summary>
|
||||
public virtual void OnFocusGained() => FocusFirst();
|
||||
|
||||
/// <summary>本面板被上层压栈覆盖时调用(可选钩子,默认无操作)。</summary>
|
||||
public virtual void OnFocusLost() { }
|
||||
|
||||
/// <summary>兼容旧 <see cref="IFocusable"/> 路径:等价于 <see cref="OnFocusGained"/>。</summary>
|
||||
public virtual void OnFocusRestored() => OnFocusGained();
|
||||
|
||||
/// <summary>将 EventSystem 焦点设到首项(优先 <see cref="_firstSelected"/>,否则 <see cref="ResolveFirstSelected"/>)。</summary>
|
||||
protected void FocusFirst()
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 滑条控件封装:Slider + 实时数值标签 + 一行式 <see cref="Bind"/>,
|
||||
/// 消除 <see cref="SettingsPanelController"/> 里"移除监听→设值→加监听→更新标签"的重复样板。
|
||||
/// 消除设置面板里"移除监听→设值→加监听→更新标签"的重复样板。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class UISlider : MonoBehaviour
|
||||
|
||||
@@ -31,99 +31,5 @@ namespace BaseGames.UI
|
||||
// 此处保留供将来添加设备切换时的其他 UI 响应(提示动画、音效反馈等)。
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 单个按键图标 Image 组件。
|
||||
///
|
||||
/// 支持两种查询模式:
|
||||
/// • ByActionName(推荐):填写 ActionName(如 "Interact"),
|
||||
/// 由 IInputIconService 自动解析当前设备 + 改键后的实际绑定路径 → 图标。
|
||||
/// • ByBindingPath(兼容/装饰用):直接填写固定路径(如 "<Keyboard>/space"),
|
||||
/// 适合教程截图等不跟随改键变化的场景。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Image))]
|
||||
public class InputIconImage : MonoBehaviour
|
||||
{
|
||||
public enum LookupMode { ByActionName, ByBindingPath }
|
||||
|
||||
[SerializeField] private LookupMode _mode = LookupMode.ByActionName;
|
||||
|
||||
[Tooltip("Action 名称,如 Interact / Jump / Attack(仅 ByActionName 模式使用)")]
|
||||
[SerializeField] private string _actionName;
|
||||
|
||||
[Tooltip("固定绑定路径,如 <Keyboard>/space(仅 ByBindingPath 模式使用)")]
|
||||
[SerializeField] private string _bindingPath;
|
||||
|
||||
private Image _image;
|
||||
private IInputIconService _iconService;
|
||||
|
||||
// ── 静态注册表:替换 FindObjectsByType,O(1) 注册/注销,O(n) 广播 ────────
|
||||
private static readonly List<InputIconImage> _registry = new();
|
||||
|
||||
/// <summary>通知注册表内所有已启用实例刷新图标(设备切换时调用)。</summary>
|
||||
internal static void RefreshAll()
|
||||
{
|
||||
for (int i = _registry.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_registry[i] != null) _registry[i].Refresh();
|
||||
else _registry.RemoveAt(i); // 清理已销毁的残留引用
|
||||
}
|
||||
}
|
||||
|
||||
private void Awake() => _image = GetComponent<Image>();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged += Refresh;
|
||||
_registry.Add(this);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged -= Refresh;
|
||||
_registry.Remove(this);
|
||||
}
|
||||
|
||||
/// <summary>刷新图标显示。设备切换或改键后由 InputDeviceIconSwitcher / InputIconService 调用。</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (_image == null) return;
|
||||
|
||||
// 若组件在 IInputIconService 注册前 Enable,此处补重试并补订阅
|
||||
if (_iconService == null)
|
||||
{
|
||||
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged += Refresh;
|
||||
}
|
||||
|
||||
Sprite sprite = null;
|
||||
|
||||
if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))
|
||||
{
|
||||
sprite = _iconService?.GetActionIcon(_actionName);
|
||||
}
|
||||
else if (_mode == LookupMode.ByBindingPath && !string.IsNullOrEmpty(_bindingPath))
|
||||
{
|
||||
// 使用固定路径在当前图标集查找(不随改键变化),适合装饰性按键说明
|
||||
sprite = _iconService?.GetPathIcon(_bindingPath);
|
||||
}
|
||||
|
||||
if (sprite != null)
|
||||
{
|
||||
_image.sprite = sprite;
|
||||
_image.enabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_image.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
104
Assets/_Game/Scripts/UI/InputIconImage.cs
Normal file
104
Assets/_Game/Scripts/UI/InputIconImage.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个按键图标 Image 组件。
|
||||
///
|
||||
/// 支持两种查询模式:
|
||||
/// • ByActionName(推荐):填写 ActionName(如 "Interact"),
|
||||
/// 由 IInputIconService 自动解析当前设备 + 改键后的实际绑定路径 → 图标。
|
||||
/// • ByBindingPath(兼容/装饰用):直接填写固定路径(如 "<Keyboard>/space"),
|
||||
/// 适合教程截图等不跟随改键变化的场景。
|
||||
///
|
||||
/// ⚠️ 必须独立成文件(类名 = 文件名):作为可序列化的 MonoBehaviour,
|
||||
/// 若与其他类同处一个 .cs 文件(二级类),Unity 无法为其写出有效的 m_Script 引用,
|
||||
/// 挂载后存盘 / 重载会变成"缺失脚本"。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Image))]
|
||||
public class InputIconImage : MonoBehaviour
|
||||
{
|
||||
public enum LookupMode { ByActionName, ByBindingPath }
|
||||
|
||||
[SerializeField] private LookupMode _mode = LookupMode.ByActionName;
|
||||
|
||||
[Tooltip("Action 名称,如 Interact / Jump / Attack(仅 ByActionName 模式使用)")]
|
||||
[SerializeField] private string _actionName;
|
||||
|
||||
[Tooltip("固定绑定路径,如 <Keyboard>/space(仅 ByBindingPath 模式使用)")]
|
||||
[SerializeField] private string _bindingPath;
|
||||
|
||||
private Image _image;
|
||||
private IInputIconService _iconService;
|
||||
|
||||
// ── 静态注册表:替换 FindObjectsByType,O(1) 注册/注销,O(n) 广播 ────────
|
||||
private static readonly List<InputIconImage> _registry = new();
|
||||
|
||||
/// <summary>通知注册表内所有已启用实例刷新图标(设备切换时调用)。</summary>
|
||||
internal static void RefreshAll()
|
||||
{
|
||||
for (int i = _registry.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_registry[i] != null) _registry[i].Refresh();
|
||||
else _registry.RemoveAt(i); // 清理已销毁的残留引用
|
||||
}
|
||||
}
|
||||
|
||||
private void Awake() => _image = GetComponent<Image>();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged += Refresh;
|
||||
_registry.Add(this);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged -= Refresh;
|
||||
_registry.Remove(this);
|
||||
}
|
||||
|
||||
/// <summary>刷新图标显示。设备切换或改键后由 InputDeviceIconSwitcher / InputIconService 调用。</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (_image == null) return;
|
||||
|
||||
// 若组件在 IInputIconService 注册前 Enable,此处补重试并补订阅
|
||||
if (_iconService == null)
|
||||
{
|
||||
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged += Refresh;
|
||||
}
|
||||
|
||||
Sprite sprite = null;
|
||||
|
||||
if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))
|
||||
{
|
||||
sprite = _iconService?.GetActionIcon(_actionName);
|
||||
}
|
||||
else if (_mode == LookupMode.ByBindingPath && !string.IsNullOrEmpty(_bindingPath))
|
||||
{
|
||||
// 使用固定路径在当前图标集查找(不随改键变化),适合装饰性按键说明
|
||||
sprite = _iconService?.GetPathIcon(_bindingPath);
|
||||
}
|
||||
|
||||
if (sprite != null)
|
||||
{
|
||||
_image.sprite = sprite;
|
||||
_image.enabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_image.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93f9600681435a74187c249850a0f71c
|
||||
guid: b8d9e520f0d671e458003b2974ccb45e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
41
Assets/_Game/Scripts/UI/LoadingScreenConfigSO.cs
Normal file
41
Assets/_Game/Scripts/UI/LoadingScreenConfigSO.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载界面数据配置(策划编辑)。承载随机提示文案、标题与时长/手感参数。
|
||||
/// <para>
|
||||
/// 职责边界:本表只放<b>数据</b>。视觉样式(背景图、进度条 sprite、配色、布局、装饰)全部在
|
||||
/// <c>UI_LoadingScreen</c> 预制件里由美术在 Prefab Mode 编辑,配色随 <c>UI_Theme_Default</c> 主题;
|
||||
/// 本表<b>不</b>放图片/颜色字段。
|
||||
/// </para>
|
||||
/// <see cref="LoadingScreenManager"/> 运行时优先读取本表;未指定时回退到组件上的序列化默认值。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/UI/Loading Config", fileName = "UI_LoadingConfig")]
|
||||
public class LoadingScreenConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("内容(本地化 Key,UI 表)")]
|
||||
[Tooltip("随机加载提示的本地化 Key 列表(如 LOADING_TIP_EXPLORE)。每次加载随机取一条;留空则不显示提示。")]
|
||||
[SerializeField] private string[] _tipKeys;
|
||||
|
||||
[Tooltip("加载界面标题的本地化 Key(如 LOADING_TITLE)。留空则不显示标题。")]
|
||||
[SerializeField] private string _titleKey;
|
||||
|
||||
[Header("时长 / 手感")]
|
||||
[Tooltip("加载画面最短显示时长(秒),避免一闪而过。")]
|
||||
[SerializeField] private float _minDisplayTime = 0.5f;
|
||||
|
||||
[Tooltip("预计加载时长(秒)。进度条按此时间平滑爬升至约 90%,真正加载完成时再冲满。\n" +
|
||||
"建议设为该游戏典型场景的加载耗时:偏长则爬得慢、偏短则更快到 90%。")]
|
||||
[SerializeField] private float _expectedLoadTime = 2.5f;
|
||||
|
||||
[Tooltip("进度条填充缓动速度(每秒可填充比例),用于平滑收尾。")]
|
||||
[SerializeField] private float _fillLerpSpeed = 1.6f;
|
||||
|
||||
public string[] TipKeys => _tipKeys;
|
||||
public string TitleKey => _titleKey;
|
||||
public float MinDisplayTime => _minDisplayTime;
|
||||
public float ExpectedLoadTime => _expectedLoadTime;
|
||||
public float FillLerpSpeed => _fillLerpSpeed;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e1f947f274273f4c9a5a310fc40a625
|
||||
guid: 814076691b7b57e4eaf67f32530baf5f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -12,26 +12,64 @@ namespace BaseGames.UI
|
||||
/// </summary>
|
||||
public class LoadingScreenManager : MonoBehaviour
|
||||
{
|
||||
[Tooltip("数据配置(策划编辑):随机提示、标题、时长/手感。\n" +
|
||||
"非空时优先于下方序列化默认值;为空则回退默认值(向后兼容)。\n" +
|
||||
"视觉样式(图片/配色/布局)在 UI_LoadingScreen 预制件里由美术编辑,不在此表。")]
|
||||
[SerializeField] private LoadingScreenConfigSO _config;
|
||||
|
||||
[Header("视图引用(由 UI_LoadingScreen 预制件绑定)")]
|
||||
[SerializeField] private GameObject _loadingRoot;
|
||||
[SerializeField] private Image _progressFill;
|
||||
[SerializeField] private TMP_Text _titleText;
|
||||
[SerializeField] private TMP_Text _tipText;
|
||||
[SerializeField] private Image[] _backgroundArts;
|
||||
|
||||
[Header("默认值(无 _config 时回退使用)")]
|
||||
[SerializeField] private string[] _tipMessages; // 本地化 key(对应 "UI" 表中的条目,如 "tip_explore")
|
||||
[SerializeField] private float _minDisplayTime = 0.5f;
|
||||
|
||||
[Tooltip("进度条填充缓动速度(每秒可填充的比例),用于把显示值平滑推向目标、避免瞬跳;越大越快、越小越平滑。")]
|
||||
[SerializeField] private float _fillLerpSpeed = 1.6f;
|
||||
|
||||
[Tooltip("预计加载时长(秒),用于‘感知进度’:进度条按时间平滑爬升至约 90%,真正加载完成时再冲满。\n" +
|
||||
"原因:Addressables 对单个场景的真实进度高度前置(毫秒内跳到 ~90% 再长时间停滞),\n" +
|
||||
"直接驱动会让进度条‘瞬间近满→卡死’;按时间爬升能保证进度条始终可见地推进。\n" +
|
||||
"建议设为该游戏场景的典型加载耗时;偏长则爬得慢、偏短则更快到 90%。")]
|
||||
[SerializeField] private float _expectedLoadTime = 2.5f;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private VoidEventChannelSO _onLoadingStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onLoadingComplete;
|
||||
[SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated;
|
||||
|
||||
// 未完成时进度条按时间爬升的上限(约 90%),余下 10% 留给"加载完成"那一冲。
|
||||
private const float kCreepCap = 0.9f;
|
||||
|
||||
private float _shownAt;
|
||||
private float _targetProgress; // 真实进度 [0,1],仅用于判定"是否已完成"(达到 1 冲满)
|
||||
private float _displayedProgress; // 进度条当前显示值(感知进度,缓动推进)
|
||||
private bool _isShowing;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// 配置取值:_config 优先,否则用组件上的序列化默认值(向后兼容)。
|
||||
private float MinDisplay => _config != null ? _config.MinDisplayTime : _minDisplayTime;
|
||||
private float ExpectedLoad => _config != null ? _config.ExpectedLoadTime : _expectedLoadTime;
|
||||
private float FillSpeed => _config != null ? _config.FillLerpSpeed : _fillLerpSpeed;
|
||||
private string[] TipKeys => (_config != null && _config.TipKeys != null && _config.TipKeys.Length > 0)
|
||||
? _config.TipKeys : _tipMessages;
|
||||
private string TitleKey => _config != null ? _config.TitleKey : null;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onLoadingStarted?.Subscribe(Show).AddTo(_subs);
|
||||
_onLoadingComplete?.Subscribe(Hide).AddTo(_subs);
|
||||
_onLoadingProgressUpdated?.Subscribe(SetProgress).AddTo(_subs);
|
||||
|
||||
// 关键:本组件的宿主 Canvas 必须保持 active,OnEnable 才会执行并订阅上面的事件
|
||||
// (管理器不能挂在被自己关闭的 GameObject 上,否则永不订阅、加载画面永不显示)。
|
||||
// 因此这里只把内层遮罩根节点初始隐藏,显隐由 Show()/Hide() 切换 _loadingRoot。
|
||||
_isShowing = false;
|
||||
if (_loadingRoot != null) _loadingRoot.SetActive(false);
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
@@ -43,6 +81,9 @@ namespace BaseGames.UI
|
||||
public void Show()
|
||||
{
|
||||
_shownAt = Time.unscaledTime;
|
||||
_targetProgress = 0f;
|
||||
_displayedProgress = 0f;
|
||||
_isShowing = true;
|
||||
if (_loadingRoot != null) _loadingRoot.SetActive(true);
|
||||
if (_progressFill != null) _progressFill.fillAmount = 0f;
|
||||
|
||||
@@ -53,41 +94,76 @@ namespace BaseGames.UI
|
||||
_backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
|
||||
}
|
||||
|
||||
// 标题(config 提供 key;留空则隐藏 Title 节点)
|
||||
if (_titleText != null)
|
||||
{
|
||||
string titleKey = TitleKey;
|
||||
bool hasTitle = !string.IsNullOrEmpty(titleKey);
|
||||
_titleText.gameObject.SetActive(hasTitle);
|
||||
if (hasTitle)
|
||||
_titleText.text = LocalizationManager.Get(titleKey, LocalizationTable.UI);
|
||||
}
|
||||
|
||||
// 随机提示(通过 LocalizationManager 解析 key)
|
||||
if (_tipText != null && _tipMessages != null && _tipMessages.Length > 0)
|
||||
_tipText.text = LocalizationManager.Get(_tipMessages[Random.Range(0, _tipMessages.Length)], LocalizationTable.UI);
|
||||
var tipKeys = TipKeys;
|
||||
if (_tipText != null && tipKeys != null && tipKeys.Length > 0)
|
||||
_tipText.text = LocalizationManager.Get(tipKeys[Random.Range(0, tipKeys.Length)], LocalizationTable.UI);
|
||||
}
|
||||
|
||||
public void Hide() => StartCoroutine(HideAfterMinTime());
|
||||
|
||||
public void SetProgress(float value)
|
||||
{
|
||||
if (_progressFill != null)
|
||||
_progressFill.fillAmount = Mathf.Clamp01(value);
|
||||
}
|
||||
/// <summary>接收真实加载进度。注意:进度条显示采用基于时间的"感知进度"(见 <see cref="Update"/>),
|
||||
/// 这里的真实值仅用于判定"是否已完成"(达到 1 时冲满)——因为 Addressables 单场景的真实进度
|
||||
/// 高度前置、不适合直接驱动进度条。</summary>
|
||||
public void SetProgress(float value) => _targetProgress = Mathf.Clamp01(value);
|
||||
|
||||
// ── 内部 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// 缓存等待对象以避免典型路径(剩余时间 == _minDisplayTime)下的重复分配。
|
||||
private WaitForSecondsRealtime _cachedFullWait;
|
||||
// 进度条采用"感知进度":未完成时按时间预算(_expectedLoadTime)平滑爬升至 kCreepCap(~90%),
|
||||
// 加载完成(_targetProgress 达到 1,由 SceneLoader 末帧或 Hide() 置位)时冲满到 1。
|
||||
// 不直接跟随 Addressables 的真实 PercentComplete —— 它对单场景高度前置(毫秒内近满后长时间停滞),
|
||||
// 直接映射会让进度条"瞬间近满→卡死"。使用 unscaledDeltaTime,不受 timeScale 影响。
|
||||
private void Update()
|
||||
{
|
||||
if (!_isShowing || _progressFill == null) return;
|
||||
|
||||
float effectiveTarget;
|
||||
if (_targetProgress >= 1f)
|
||||
{
|
||||
effectiveTarget = 1f; // 加载完成 → 冲满
|
||||
}
|
||||
else
|
||||
{
|
||||
float elapsed = Time.unscaledTime - _shownAt;
|
||||
float expected = ExpectedLoad;
|
||||
effectiveTarget = expected > 0f
|
||||
? Mathf.Clamp01(elapsed / expected) * kCreepCap // 按时间爬升至 ~90%
|
||||
: kCreepCap;
|
||||
}
|
||||
|
||||
_displayedProgress = Mathf.MoveTowards(
|
||||
_displayedProgress, effectiveTarget, FillSpeed * Time.unscaledDeltaTime);
|
||||
_progressFill.fillAmount = _displayedProgress;
|
||||
}
|
||||
|
||||
private IEnumerator HideAfterMinTime()
|
||||
{
|
||||
float elapsed = Time.unscaledTime - _shownAt;
|
||||
float remaining = _minDisplayTime - elapsed;
|
||||
if (remaining > 0f)
|
||||
// 收尾:把目标推到满,让缓动平滑填满进度条后再隐藏。
|
||||
_targetProgress = 1f;
|
||||
|
||||
// 同时满足两个条件才隐藏:① 至少显示 MinDisplay ② 进度条视觉已填满。
|
||||
// 安全上限避免极端卡顿(缓动追不上 / 进度条缺失)时永不隐藏。
|
||||
float minDisplay = MinDisplay;
|
||||
float safetyDeadline = Time.unscaledTime + minDisplay + 1.5f;
|
||||
while (Time.unscaledTime < safetyDeadline)
|
||||
{
|
||||
// 完整剩余 ≈ 显示时间时复用缓存对象;否则按需新建(罕见路径)。
|
||||
if (Mathf.Approximately(remaining, _minDisplayTime))
|
||||
{
|
||||
_cachedFullWait ??= new WaitForSecondsRealtime(_minDisplayTime);
|
||||
yield return _cachedFullWait;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new WaitForSecondsRealtime(remaining);
|
||||
}
|
||||
bool minTimeElapsed = Time.unscaledTime - _shownAt >= minDisplay;
|
||||
bool barFilled = _progressFill == null || _displayedProgress >= 0.999f;
|
||||
if (minTimeElapsed && barFilled) break;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_isShowing = false;
|
||||
if (_loadingRoot != null) _loadingRoot.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.UI;
|
||||
using BaseGames.UI.Menus;
|
||||
|
||||
namespace BaseGames.UI.MainMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// 数据驱动主菜单控制器(<see cref="MainMenuController"/> 的表驱动版,非破坏性并存)。
|
||||
/// 数据驱动主菜单控制器(表驱动按钮列表 + 子面板编排)。
|
||||
///
|
||||
/// 按钮列表据 <see cref="MainMenuConfigSO"/> 生成(标签/图标/顺序/动作纯数据,策划改表零代码);
|
||||
/// 子面板开关、存档槽流程、入场动画、状态锁定等编排仍在本控制器(场景耦合,不下放配置表)。
|
||||
/// 动作派发:内置 NewGame/Continue/OpenSettings/OpenCredits/LoadScene/Quit + 事件频道 RaiseEvent。
|
||||
/// 按钮列表据 <see cref="MainMenuConfigSO"/> 生成(标签/图标/顺序/动作纯数据,策划改表零代码)。
|
||||
/// 子面板(存档槽 / 设置 / 制作人员)经统一 <see cref="IUINavigator"/> 压栈:栈式回退、ESC 逐层关闭、
|
||||
/// 焦点恢复均由导航器负责,本控制器不再自管取消键与面板显隐。主按钮组作为"栈底上下文"
|
||||
/// (不入栈),据 <see cref="IUINavigator.Depth"/> 在有子面板打开时屏蔽自身交互。
|
||||
/// </summary>
|
||||
public class DataDrivenMainMenuController : MonoBehaviour
|
||||
{
|
||||
@@ -24,20 +26,21 @@ namespace BaseGames.UI.MainMenu
|
||||
[SerializeField] private Transform _container;
|
||||
[SerializeField] private MainMenuButtonView _buttonPrefab;
|
||||
|
||||
[Header("主按钮组(入场动画)")]
|
||||
[Header("主按钮组(入场动画 / 栈底屏蔽)")]
|
||||
[SerializeField] private CanvasGroup _mainButtonsGroup;
|
||||
[SerializeField] private RectTransform _mainButtonsRect;
|
||||
|
||||
[Header("子面板")]
|
||||
[SerializeField] private GameObject _saveSlotPanel;
|
||||
[SerializeField] private BaseGames.UI.Menus.SaveSlotController _saveSlotController;
|
||||
[SerializeField] private GameObject _settingsPanel;
|
||||
[SerializeField] private GameObject _creditsPanel;
|
||||
[Header("子面板(导航器压栈对象,整面板根须挂 UIPanelBase)")]
|
||||
[SerializeField] private SaveSlotController _saveSlotPanel;
|
||||
[Tooltip("设置面板根(UISimplePanel;内含数据驱动设置实例)。")]
|
||||
[SerializeField] private UIPanelBase _settingsPanel;
|
||||
[Tooltip("制作人员面板根(UISimplePanel)。")]
|
||||
[SerializeField] private UIPanelBase _creditsPanel;
|
||||
|
||||
[Header("子面板关闭按钮(可选)")]
|
||||
[SerializeField] private Button _btnCloseSaveSlot;
|
||||
[SerializeField] private Button _btnCloseSettings;
|
||||
[SerializeField] private Button _btnCloseCredits;
|
||||
[Header("子面板关闭按钮(可选,等价于 ESC:出栈一层)")]
|
||||
[SerializeField] private UnityEngine.UI.Button _btnCloseSaveSlot;
|
||||
[SerializeField] private UnityEngine.UI.Button _btnCloseSettings;
|
||||
[SerializeField] private UnityEngine.UI.Button _btnCloseCredits;
|
||||
|
||||
[Header("入场动画")]
|
||||
[SerializeField] private float _entrySlideOffset = 80f;
|
||||
@@ -57,24 +60,19 @@ namespace BaseGames.UI.MainMenu
|
||||
private readonly List<(MainMenuConfigSO.Item item, MainMenuButtonView view)> _buttons = new();
|
||||
private Vector2 _buttonsPanelOriginalPos;
|
||||
private MainMenuButtonView _firstButton;
|
||||
private IUINavigator _nav;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
if (_buttonPrefab != null) _buttonPrefab.gameObject.SetActive(false);
|
||||
|
||||
_btnCloseSaveSlot?.onClick.AddListener(() => CloseSubPanel(_saveSlotPanel));
|
||||
_btnCloseSettings?.onClick.AddListener(() => CloseSubPanel(_settingsPanel));
|
||||
_btnCloseCredits? .onClick.AddListener(() => CloseSubPanel(_creditsPanel));
|
||||
|
||||
if (_mainButtonsRect != null)
|
||||
_buttonsPanelOriginalPos = _mainButtonsRect.anchoredPosition;
|
||||
|
||||
SetPanel(_saveSlotPanel, false);
|
||||
SetPanel(_settingsPanel, false);
|
||||
SetPanel(_creditsPanel, false);
|
||||
SetButtonsGroupVisible(false);
|
||||
_btnCloseSaveSlot?.onClick.AddListener(() => Nav?.Pop());
|
||||
_btnCloseSettings?.onClick.AddListener(() => Nav?.Pop());
|
||||
_btnCloseCredits? .onClick.AddListener(() => Nav?.Pop());
|
||||
|
||||
SetButtonsGroupVisible(false);
|
||||
BuildMenu();
|
||||
}
|
||||
|
||||
@@ -82,9 +80,16 @@ namespace BaseGames.UI.MainMenu
|
||||
{
|
||||
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
|
||||
_onSlotConfirmed? .Subscribe(HandleSlotConfirmed).AddTo(_subs);
|
||||
|
||||
_nav = ServiceLocator.GetOrDefault<IUINavigator>();
|
||||
if (_nav != null) _nav.StackChanged += HandleStackChanged;
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
if (_nav != null) _nav.StackChanged -= HandleStackChanged;
|
||||
}
|
||||
|
||||
private void Start() => StartCoroutine(PlayEntryAnimation());
|
||||
|
||||
@@ -92,10 +97,7 @@ namespace BaseGames.UI.MainMenu
|
||||
/// <summary>据配置重建主菜单按钮列表(public 以便编辑器/测试验证)。</summary>
|
||||
public void BuildMenu()
|
||||
{
|
||||
foreach (var (_, view) in _buttons) if (view != null) Destroy(view.gameObject);
|
||||
_buttons.Clear();
|
||||
_firstButton = null;
|
||||
|
||||
ClearMenu();
|
||||
if (_config == null || _container == null || _buttonPrefab == null) return;
|
||||
|
||||
foreach (var item in _config.Items)
|
||||
@@ -111,6 +113,20 @@ namespace BaseGames.UI.MainMenu
|
||||
RefreshConditional();
|
||||
}
|
||||
|
||||
/// <summary>清空菜单按钮(容器全部子节点)。编辑器预览与运行时重建共用。</summary>
|
||||
public void ClearMenu()
|
||||
{
|
||||
_buttons.Clear();
|
||||
_firstButton = null;
|
||||
if (_container == null) return;
|
||||
for (int i = _container.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var child = _container.GetChild(i).gameObject;
|
||||
if (Application.isPlaying) Destroy(child);
|
||||
else DestroyImmediate(child);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>根据存档存在性刷新 requiresSave 按钮的可用性(如"继续")。</summary>
|
||||
public void RefreshConditional()
|
||||
{
|
||||
@@ -126,27 +142,22 @@ namespace BaseGames.UI.MainMenu
|
||||
switch (item.action)
|
||||
{
|
||||
case MainMenuAction.NewGame:
|
||||
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.NewGame);
|
||||
OpenSubPanel(_saveSlotPanel);
|
||||
if (_saveSlotPanel != null) { _saveSlotPanel.SetMode(SaveSlotPanelMode.NewGame); Nav?.Push(_saveSlotPanel); }
|
||||
break;
|
||||
case MainMenuAction.Continue:
|
||||
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.Continue);
|
||||
OpenSubPanel(_saveSlotPanel);
|
||||
if (_saveSlotPanel != null) { _saveSlotPanel.SetMode(SaveSlotPanelMode.Continue); Nav?.Push(_saveSlotPanel); }
|
||||
break;
|
||||
case MainMenuAction.OpenSettings:
|
||||
OpenSubPanel(_settingsPanel);
|
||||
if (_settingsPanel != null) Nav?.Push(_settingsPanel);
|
||||
break;
|
||||
case MainMenuAction.OpenCredits:
|
||||
OpenSubPanel(_creditsPanel);
|
||||
if (_btnCloseCredits != null)
|
||||
EventSystem.current?.SetSelectedGameObject(_btnCloseCredits.gameObject);
|
||||
if (_creditsPanel != null) Nav?.Push(_creditsPanel);
|
||||
break;
|
||||
case MainMenuAction.LoadScene:
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = string.IsNullOrEmpty(item.sceneKey) ? _firstGameSceneKey : item.sceneKey,
|
||||
TransitionType = TransitionType.Scene,
|
||||
ShowLoadingScreen = true,
|
||||
});
|
||||
break;
|
||||
case MainMenuAction.Quit:
|
||||
@@ -158,32 +169,25 @@ namespace BaseGames.UI.MainMenu
|
||||
}
|
||||
}
|
||||
|
||||
// ── 子面板编排 ────────────────────────────────────────────────────────
|
||||
private void OpenSubPanel(GameObject panel)
|
||||
{
|
||||
SetMainButtonsInteractable(false);
|
||||
SetPanel(panel, true);
|
||||
}
|
||||
// ── 栈底按钮组屏蔽(有子面板打开时禁用主按钮,避免方向键穿透)──────────
|
||||
private IUINavigator Nav => _nav ??= ServiceLocator.GetOrDefault<IUINavigator>();
|
||||
|
||||
private void CloseSubPanel(GameObject panel)
|
||||
private void HandleStackChanged()
|
||||
{
|
||||
SetPanel(panel, false);
|
||||
SetMainButtonsInteractable(true);
|
||||
if (_firstButton != null)
|
||||
bool anyOpen = Nav != null && Nav.Depth > 0;
|
||||
if (_mainButtonsGroup != null)
|
||||
{
|
||||
_mainButtonsGroup.interactable = !anyOpen;
|
||||
_mainButtonsGroup.blocksRaycasts = !anyOpen;
|
||||
}
|
||||
if (!anyOpen && _firstButton != null)
|
||||
EventSystem.current?.SetSelectedGameObject(_firstButton.Button.gameObject);
|
||||
}
|
||||
|
||||
private void SetMainButtonsInteractable(bool on)
|
||||
{
|
||||
if (_mainButtonsGroup == null) return;
|
||||
_mainButtonsGroup.interactable = on;
|
||||
_mainButtonsGroup.blocksRaycasts = on;
|
||||
}
|
||||
|
||||
// ── 存档槽确认(与 MainMenuController 一致)────────────────────────────
|
||||
// ── 存档槽确认 ────────────────────────────────────────────────────────
|
||||
private void HandleSlotConfirmed(int _)
|
||||
{
|
||||
SetPanel(_saveSlotPanel, false);
|
||||
Nav?.PopToRoot(); // 关闭存档槽(及其上任何子对话框),准备进场景
|
||||
|
||||
var svc = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
string checkpointScene = svc?.LastCheckpointScene;
|
||||
@@ -194,14 +198,13 @@ namespace BaseGames.UI.MainMenu
|
||||
SceneName = hasCheckpoint ? checkpointScene : _firstGameSceneKey,
|
||||
EntryTransitionId = hasCheckpoint ? svc.LastCheckpointSpawnId : null,
|
||||
TransitionType = TransitionType.Scene,
|
||||
ShowLoadingScreen = true,
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleGameStateChanged(GameStateId state)
|
||||
{
|
||||
bool isMainMenu = state == GameStates.MainMenu;
|
||||
if (_mainButtonsGroup != null)
|
||||
if (_mainButtonsGroup != null && Nav != null && Nav.Depth == 0)
|
||||
{
|
||||
_mainButtonsGroup.interactable = isMainMenu;
|
||||
_mainButtonsGroup.blocksRaycasts = isMainMenu;
|
||||
@@ -243,11 +246,6 @@ namespace BaseGames.UI.MainMenu
|
||||
return s != null && (s.HasSave(0) || s.HasSave(1) || s.HasSave(2));
|
||||
}
|
||||
|
||||
private static void SetPanel(GameObject panel, bool active)
|
||||
{
|
||||
if (panel != null) panel.SetActive(active);
|
||||
}
|
||||
|
||||
private void SetButtonsGroupVisible(bool visible)
|
||||
{
|
||||
if (_mainButtonsGroup == null) return;
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI.MainMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// 主菜单 UI 控制器(挂载在 Scene_MainMenu 的根 Canvas 上)。
|
||||
///
|
||||
/// 面板结构(按 Inspector 绑定):
|
||||
/// ├── MainButtonsPanel — 主按钮组(新游戏 / 继续 / 设置 / 制作团队 / 退出)
|
||||
/// ├── SaveSlotPanel — 存档槽选择(新游戏 & 继续共用)
|
||||
/// ├── SettingsPanel — 设置面板
|
||||
/// └── CreditsPanel — 制作团队面板
|
||||
///
|
||||
/// 入场动画:主按钮组从下方滑入(代码驱动,无需 Animator)。
|
||||
///
|
||||
/// 流程:
|
||||
/// 玩家选择存档槽(SaveSlotController 发布 _onSlotConfirmed)
|
||||
/// → 关闭存档槽面板 → 发布 SceneLoadRequest(目标游戏场景)
|
||||
/// → GameManager 响应,进入 LoadingScene 状态,显示加载画面,最终切换到 Gameplay。
|
||||
/// </summary>
|
||||
public class MainMenuController : MonoBehaviour
|
||||
{
|
||||
// ── 面板引用 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("面板")]
|
||||
[SerializeField] private CanvasGroup _mainButtonsGroup;
|
||||
[SerializeField] private RectTransform _mainButtonsRect; // 用于滑入动画
|
||||
[SerializeField] private GameObject _saveSlotPanel;
|
||||
[Tooltip("存档槽面板控制器。打开前调用 SetMode 区分新游戏 / 继续语境。")]
|
||||
[SerializeField] private BaseGames.UI.Menus.SaveSlotController _saveSlotController;
|
||||
[SerializeField] private GameObject _settingsPanel;
|
||||
[SerializeField] private GameObject _creditsPanel;
|
||||
|
||||
// ── 按钮引用 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("主菜单按钮")]
|
||||
[SerializeField] private Button _btnNewGame;
|
||||
[SerializeField] private Button _btnContinue;
|
||||
[SerializeField] private Button _btnSettings;
|
||||
[SerializeField] private Button _btnCredits;
|
||||
[SerializeField] private Button _btnQuit;
|
||||
|
||||
// ── 按钮(子面板关闭)────────────────────────────────────────────────
|
||||
|
||||
[Header("子面板关闭按钮(可选)")]
|
||||
[SerializeField] private Button _btnCloseSaveSlot;
|
||||
[SerializeField] private Button _btnCloseSettings;
|
||||
[SerializeField] private Button _btnCloseCredits;
|
||||
|
||||
// ── 入场动画参数 ──────────────────────────────────────────────────────
|
||||
|
||||
[Header("入场动画")]
|
||||
[Tooltip("按钮组初始偏移(像素,向下)")]
|
||||
[SerializeField] private float _entrySlideOffset = 80f;
|
||||
[Tooltip("入场动画持续时间(秒)")]
|
||||
[SerializeField] private float _entryDuration = 0.55f;
|
||||
|
||||
// ── 游戏场景 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("场景")]
|
||||
[Tooltip("新游戏 / 继续后进入的第一个游戏场景(Addressable Key)")]
|
||||
[SerializeField] private string _firstGameSceneKey = AddressKeys.SceneGameChapter1;
|
||||
|
||||
// ── Event Channels ────────────────────────────────────────────────────
|
||||
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[Tooltip("SaveSlotController 完成选槽后发布(携带槽索引)")]
|
||||
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
private Vector2 _buttonsPanelOriginalPos;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 按钮绑定
|
||||
_btnNewGame? .onClick.AddListener(OnNewGameClicked);
|
||||
_btnContinue?.onClick.AddListener(OnContinueClicked);
|
||||
_btnSettings?.onClick.AddListener(OnSettingsClicked);
|
||||
_btnCredits? .onClick.AddListener(OnCreditsClicked);
|
||||
_btnQuit? .onClick.AddListener(Application.Quit);
|
||||
|
||||
_btnCloseSaveSlot?.onClick.AddListener(() => CloseSubPanel(_saveSlotPanel, _btnNewGame));
|
||||
_btnCloseSettings?.onClick.AddListener(() => CloseSubPanel(_settingsPanel, _btnSettings));
|
||||
_btnCloseCredits? .onClick.AddListener(() => CloseSubPanel(_creditsPanel, _btnCredits));
|
||||
|
||||
// 记录按钮组原始位置(供动画使用)
|
||||
if (_mainButtonsRect != null)
|
||||
_buttonsPanelOriginalPos = _mainButtonsRect.anchoredPosition;
|
||||
|
||||
// 初始状态:隐藏子面板,主按钮组不可见(等待入场动画)
|
||||
SetPanel(_saveSlotPanel, false);
|
||||
SetPanel(_settingsPanel, false);
|
||||
SetPanel(_creditsPanel, false);
|
||||
SetButtonsGroupVisible(false);
|
||||
|
||||
// 刷新"继续"按钮可用性(需要至少一个有效存档)
|
||||
RefreshContinueButton();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
|
||||
_onSlotConfirmed? .Subscribe(HandleSlotConfirmed).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 场景初始化完成后播放入场动画
|
||||
StartCoroutine(PlayEntryAnimation());
|
||||
}
|
||||
|
||||
// ── 入场动画 ─────────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator PlayEntryAnimation()
|
||||
{
|
||||
if (_mainButtonsGroup == null) yield break;
|
||||
|
||||
Vector2 startPos = _buttonsPanelOriginalPos - new Vector2(0f, _entrySlideOffset);
|
||||
if (_mainButtonsRect != null)
|
||||
_mainButtonsRect.anchoredPosition = startPos;
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < _entryDuration)
|
||||
{
|
||||
float t = Mathf.SmoothStep(0f, 1f, elapsed / _entryDuration);
|
||||
_mainButtonsGroup.alpha = t;
|
||||
if (_mainButtonsRect != null)
|
||||
_mainButtonsRect.anchoredPosition = Vector2.Lerp(startPos, _buttonsPanelOriginalPos, t);
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_mainButtonsGroup.alpha = 1f;
|
||||
if (_mainButtonsRect != null)
|
||||
_mainButtonsRect.anchoredPosition = _buttonsPanelOriginalPos;
|
||||
|
||||
_mainButtonsGroup.interactable = true;
|
||||
_mainButtonsGroup.blocksRaycasts = true;
|
||||
|
||||
// 手柄导航:入场动画完成后将焦点置于第一个按钮
|
||||
EventSystem.current?.SetSelectedGameObject(_btnNewGame?.gameObject);
|
||||
}
|
||||
|
||||
// ── 按钮回调 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void OnNewGameClicked()
|
||||
{
|
||||
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.NewGame);
|
||||
OpenSubPanel(_saveSlotPanel);
|
||||
}
|
||||
private void OnContinueClicked()
|
||||
{
|
||||
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.Continue);
|
||||
OpenSubPanel(_saveSlotPanel);
|
||||
}
|
||||
private void OnSettingsClicked() => OpenSubPanel(_settingsPanel); // SettingsPanelController 自行设焦点
|
||||
private void OnCreditsClicked()
|
||||
{
|
||||
OpenSubPanel(_creditsPanel);
|
||||
// Credits 面板无独立控制器,打开时把焦点交给返回按钮(键盘 / 手柄可直接退出)
|
||||
if (_btnCloseCredits != null)
|
||||
EventSystem.current?.SetSelectedGameObject(_btnCloseCredits.gameObject);
|
||||
}
|
||||
|
||||
/// <summary>打开子面板:禁用主按钮组交互,避免键盘/手柄导航"穿透"到背后的主菜单按钮。</summary>
|
||||
private void OpenSubPanel(GameObject panel)
|
||||
{
|
||||
SetMainButtonsInteractable(false);
|
||||
SetPanel(panel, true);
|
||||
}
|
||||
|
||||
/// <summary>关闭子面板:恢复主按钮组交互,并把焦点恢复到对应主菜单按钮(导航连续性)。</summary>
|
||||
private void CloseSubPanel(GameObject panel, Button focusAfter)
|
||||
{
|
||||
SetPanel(panel, false);
|
||||
SetMainButtonsInteractable(true);
|
||||
if (focusAfter != null)
|
||||
EventSystem.current?.SetSelectedGameObject(focusAfter.gameObject);
|
||||
}
|
||||
|
||||
private void SetMainButtonsInteractable(bool on)
|
||||
{
|
||||
if (_mainButtonsGroup == null) return;
|
||||
_mainButtonsGroup.interactable = on;
|
||||
_mainButtonsGroup.blocksRaycasts = on;
|
||||
}
|
||||
|
||||
// ── 存档槽确认 ───────────────────────────────────────────────────────
|
||||
|
||||
private void HandleSlotConfirmed(int _)
|
||||
{
|
||||
SetPanel(_saveSlotPanel, false);
|
||||
|
||||
// 继续游戏:存档已记录检查点场景时加载该场景并落在存档点出生位;
|
||||
// 否则(新游戏 / 存档尚无检查点)加载首关。
|
||||
var svc = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
string checkpointScene = svc?.LastCheckpointScene;
|
||||
bool hasCheckpoint = !string.IsNullOrEmpty(checkpointScene);
|
||||
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = hasCheckpoint ? checkpointScene : _firstGameSceneKey,
|
||||
EntryTransitionId = hasCheckpoint ? svc.LastCheckpointSpawnId : null,
|
||||
TransitionType = TransitionType.Scene,
|
||||
ShowLoadingScreen = true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 游戏状态响应 ─────────────────────────────────────────────────────
|
||||
|
||||
private void HandleGameStateChanged(GameStateId state)
|
||||
{
|
||||
bool isMainMenu = state == GameStates.MainMenu;
|
||||
// 离开 MainMenu(加载游戏中)时锁定所有交互,防止重复点击
|
||||
if (_mainButtonsGroup != null)
|
||||
{
|
||||
_mainButtonsGroup.interactable = isMainMenu;
|
||||
_mainButtonsGroup.blocksRaycasts = isMainMenu;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具方法 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void RefreshContinueButton()
|
||||
{
|
||||
if (_btnContinue == null) return;
|
||||
|
||||
var saveService = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
bool hasAny = saveService != null
|
||||
&& (saveService.HasSave(0) || saveService.HasSave(1) || saveService.HasSave(2));
|
||||
_btnContinue.interactable = hasAny;
|
||||
}
|
||||
|
||||
private static void SetPanel(GameObject panel, bool active)
|
||||
{
|
||||
if (panel != null) panel.SetActive(active);
|
||||
}
|
||||
|
||||
private void SetButtonsGroupVisible(bool visible)
|
||||
{
|
||||
if (_mainButtonsGroup == null) return;
|
||||
_mainButtonsGroup.alpha = visible ? 1f : 0f;
|
||||
_mainButtonsGroup.interactable = visible;
|
||||
_mainButtonsGroup.blocksRaycasts = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
Assets/_Game/Scripts/UI/MainMenu/NewGameModeConfigSO.cs
Normal file
43
Assets/_Game/Scripts/UI/MainMenu/NewGameModeConfigSO.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI.MainMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// 新游戏难度选择数据表(策划编辑)。按顺序列出可选难度;
|
||||
/// <see cref="NewGameModeController"/> 据此生成按钮,点击返回对应 <see cref="DifficultyLevel"/>。
|
||||
/// 策划可增删 / 重排 / 改标签 / 改说明,无需改代码。样式改 UI_NewGameModePanel / UI_MainMenu_Button 预制件。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/UI/New Game Mode Config", fileName = "UI_NewGameModeConfig")]
|
||||
public class NewGameModeConfigSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct Item
|
||||
{
|
||||
[Tooltip("此项对应的难度等级。")]
|
||||
public DifficultyLevel level;
|
||||
|
||||
[Tooltip("难度名标签本地化 Key(UI 表,如 MODE_NORMAL)。")]
|
||||
public string labelKey;
|
||||
|
||||
[Tooltip("难度说明本地化 Key(选中该项时显示,可空,如 MODE_STEELSOUL_DESC)。")]
|
||||
public string descKey;
|
||||
|
||||
[Tooltip("难度图标(可空)。")]
|
||||
public Sprite icon;
|
||||
}
|
||||
|
||||
[Tooltip("面板标题本地化 Key。")]
|
||||
[SerializeField] private string _titleKey = "MODE_SELECT_TITLE";
|
||||
|
||||
[Tooltip("返回按钮本地化 Key。")]
|
||||
[SerializeField] private string _backLabelKey = "BTN_BACK";
|
||||
|
||||
[SerializeField] private Item[] _items;
|
||||
|
||||
public string TitleKey => _titleKey;
|
||||
public string BackLabelKey => _backLabelKey;
|
||||
public Item[] Items => _items;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8adf13ec10899df439ee33bc9dcbcdeb
|
||||
guid: ff0448c32aab82546bd71b33da6b2c9a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
@@ -9,99 +11,100 @@ using BaseGames.Localization;
|
||||
namespace BaseGames.UI.MainMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// 新游戏模式选择面板(普通 / 钢铁之魂):开新档前选择难度模式。
|
||||
///
|
||||
/// 设计:
|
||||
/// · 自包含、场景无关——本地 SetActive 显隐 + 回调,不走 UIManager 面板栈,
|
||||
/// 与 MainMenuController 现有的子面板管理方式一致。
|
||||
/// · 选定后通过 onModeChosen 回调把 DifficultyLevel 交还给调用方(SaveSlotController),
|
||||
/// 由调用方负责 CreateSlot(slot, steelSoul) + IDifficultyService.BeginNewGame(level)。
|
||||
/// · 钢铁之魂为破坏性/高难选项,默认焦点置于普通,并显示一段警示文案。
|
||||
/// 新游戏模式(难度)选择面板,<b>数据驱动</b>:据 <see cref="NewGameModeConfigSO"/> 生成难度按钮,
|
||||
/// 经 <see cref="IUINavigator"/> 模态压栈,返回 <see cref="DifficultyLevel"/>;
|
||||
/// 点返回 / 按 ESC 取消则返回 null(由 <see cref="UIResultPanel{T}"/> 兜底)。
|
||||
/// 选中某项时在共享说明区显示其 descKey(手柄/鼠标导航通用)。
|
||||
/// 策划改 UI_NewGameModeConfig 即可增删/重排难度、改标签/说明;样式改 UI_NewGameModePanel / UI_MainMenu_Button 预制件。
|
||||
/// </summary>
|
||||
public class NewGameModeController : MonoBehaviour
|
||||
public class NewGameModeController : UIResultPanel<DifficultyLevel?>
|
||||
{
|
||||
[Header("根节点(显隐用,留空则用本 GameObject)")]
|
||||
[SerializeField] private GameObject _root;
|
||||
[Header("数据表 / 选项列表")]
|
||||
[SerializeField] private NewGameModeConfigSO _config;
|
||||
[Tooltip("难度按钮的父节点(通常挂 VerticalLayoutGroup)。")]
|
||||
[SerializeField] private Transform _container;
|
||||
[SerializeField] private MainMenuButtonView _buttonPrefab;
|
||||
|
||||
[Header("按钮")]
|
||||
[SerializeField] private Button _btnNormal;
|
||||
[SerializeField] private Button _btnSteelSoul;
|
||||
[SerializeField] private Button _btnBack;
|
||||
[Header("引用")]
|
||||
[SerializeField] private LocalizedText _titleText;
|
||||
[Tooltip("当前选中难度的说明文本(随选中项切换)。")]
|
||||
[SerializeField] private TMP_Text _descText;
|
||||
[SerializeField] private Button _btnBack;
|
||||
|
||||
[Header("钢铁之魂说明")]
|
||||
[Tooltip("选中钢铁之魂时显示的警示文案(一命模式,死亡即清档)。走本地化键 MODE_STEELSOUL_DESC。")]
|
||||
[SerializeField] private TMP_Text _steelSoulDescText;
|
||||
[SerializeField] private string _steelSoulDescKey = "MODE_STEELSOUL_DESC";
|
||||
// 取消 / ESC / 返回默认结果:未选择。
|
||||
protected override DifficultyLevel? CancelResult => null;
|
||||
|
||||
private Action<DifficultyLevel> _onModeChosen;
|
||||
private Action _onBack;
|
||||
private readonly List<(MainMenuButtonView view, string descKey)> _options = new();
|
||||
private MainMenuButtonView _firstButton;
|
||||
private GameObject _lastSelected;
|
||||
|
||||
private void Awake()
|
||||
private void Awake() => _btnBack?.onClick.AddListener(() => Complete(null));
|
||||
|
||||
protected override void OnPanelOpen() => BuildMenu();
|
||||
|
||||
/// <summary>默认焦点:第一项(通常普通难度),避免误选高难项。</summary>
|
||||
protected override GameObject ResolveFirstSelected()
|
||||
=> _firstButton != null ? _firstButton.Button.gameObject
|
||||
: _btnBack != null ? _btnBack.gameObject : null;
|
||||
|
||||
/// <summary>据配置重建难度按钮(public 以便编辑器预览/测试)。</summary>
|
||||
public void BuildMenu()
|
||||
{
|
||||
_btnNormal? .onClick.AddListener(() => Choose(DifficultyLevel.Normal));
|
||||
_btnSteelSoul?.onClick.AddListener(() => Choose(DifficultyLevel.SteelSoul));
|
||||
_btnBack? .onClick.AddListener(HandleBack);
|
||||
SetVisible(false);
|
||||
}
|
||||
ClearMenu();
|
||||
if (_titleText != null && _config != null) _titleText.SetKey(_config.TitleKey);
|
||||
if (_config == null || _container == null || _buttonPrefab == null) return;
|
||||
|
||||
/// <summary>
|
||||
/// 弹出模式选择。
|
||||
/// </summary>
|
||||
/// <param name="onModeChosen">玩家选定模式后回调(面板已自动关闭),携带难度档位。</param>
|
||||
/// <param name="onBack">点击返回 / 取消后回调(可选)。</param>
|
||||
public void Show(Action<DifficultyLevel> onModeChosen, Action onBack = null)
|
||||
{
|
||||
_onModeChosen = onModeChosen;
|
||||
_onBack = onBack;
|
||||
|
||||
if (_steelSoulDescText != null && !string.IsNullOrEmpty(_steelSoulDescKey))
|
||||
foreach (var item in _config.Items)
|
||||
{
|
||||
string s = LocalizationManager.Get(_steelSoulDescKey, LocalizationTable.UI);
|
||||
_steelSoulDescText.text = string.IsNullOrEmpty(s) ? _steelSoulDescKey : s;
|
||||
var view = Instantiate(_buttonPrefab, _container);
|
||||
view.gameObject.SetActive(true);
|
||||
var level = item.level;
|
||||
view.Bind(item.labelKey, item.icon, () => Complete(level));
|
||||
_options.Add((view, item.descKey));
|
||||
if (_firstButton == null) _firstButton = view;
|
||||
}
|
||||
|
||||
SetVisible(true);
|
||||
|
||||
// 默认焦点置于普通模式(避免误选一命模式)
|
||||
EventSystem.current?.SetSelectedGameObject(_btnNormal != null
|
||||
? _btnNormal.gameObject
|
||||
: _btnSteelSoul?.gameObject);
|
||||
if (_descText != null) _descText.text = string.Empty;
|
||||
_lastSelected = null;
|
||||
}
|
||||
|
||||
/// <summary>外部强制关闭,不触发回调。</summary>
|
||||
public void Close()
|
||||
private void ClearMenu()
|
||||
{
|
||||
_onModeChosen = null;
|
||||
_onBack = null;
|
||||
SetVisible(false);
|
||||
_options.Clear();
|
||||
_firstButton = null;
|
||||
if (_container == null) return;
|
||||
for (int i = _container.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var c = _container.GetChild(i).gameObject;
|
||||
if (Application.isPlaying) Destroy(c); else DestroyImmediate(c);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 回调 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void Choose(DifficultyLevel level)
|
||||
// 显示当前选中难度的说明(轮询 EventSystem 选中项;手柄/鼠标导航通用,按钮无需额外组件)。
|
||||
private void Update()
|
||||
{
|
||||
var cb = _onModeChosen;
|
||||
SetVisible(false);
|
||||
_onModeChosen = null;
|
||||
_onBack = null;
|
||||
cb?.Invoke(level);
|
||||
if (_descText == null || EventSystem.current == null) return;
|
||||
var sel = EventSystem.current.currentSelectedGameObject;
|
||||
if (sel == _lastSelected) return;
|
||||
_lastSelected = sel;
|
||||
|
||||
string descKey = null;
|
||||
foreach (var (view, dk) in _options)
|
||||
if (view != null && view.Button != null && view.Button.gameObject == sel) { descKey = dk; break; }
|
||||
_descText.text = string.IsNullOrEmpty(descKey)
|
||||
? string.Empty
|
||||
: LocalizationManager.Get(descKey, LocalizationTable.UI);
|
||||
}
|
||||
|
||||
private void HandleBack()
|
||||
/// <summary>弹出难度选择并等待结果(DifficultyLevel / null=取消)。由导航器压栈管理。</summary>
|
||||
public Task<DifficultyLevel?> ShowAsync(CancellationToken ct = default)
|
||||
{
|
||||
var cb = _onBack;
|
||||
SetVisible(false);
|
||||
_onModeChosen = null;
|
||||
_onBack = null;
|
||||
cb?.Invoke();
|
||||
}
|
||||
|
||||
// ── 工具 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void SetVisible(bool visible)
|
||||
{
|
||||
var go = _root != null ? _root : gameObject;
|
||||
go.SetActive(visible);
|
||||
var nav = GetService<IUINavigator>();
|
||||
if (nav == null)
|
||||
{
|
||||
Debug.LogError("[NewGameMode] 未找到 IUINavigator 服务,无法弹出模式选择。", this);
|
||||
return Task.FromResult<DifficultyLevel?>(null);
|
||||
}
|
||||
return nav.PushForResultAsync<DifficultyLevel?>(this, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using TMPro;
|
||||
using BaseGames.UI;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.UI.Menus
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用模态确认对话框(是/否):用于删除 / 覆盖 / 退出等确认场景。
|
||||
/// 通用模态确认对话框(是/否):用于删除 / 覆盖 / 退出 / 传送等确认场景。
|
||||
///
|
||||
/// 设计:
|
||||
/// · 自包含、场景无关——通过本地 SetActive 显隐 + 回调 API 工作,不依赖 UIManager 面板栈,
|
||||
/// 因此既能用于主菜单场景(不走 UIManager),也能在游戏内复用。
|
||||
/// · 标题 / 正文 / 按钮文案均走本地化键(LocalizationManager.Get);传 null 则保留 Inspector 原文。
|
||||
/// · 默认焦点置于"取消"按钮,防止手柄连按误触确认(破坏性操作安全默认)。
|
||||
/// <para>双调用模式(同类不同实例各用其一,互不冲突):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>导航器 async(主菜单)</b>:<see cref="ShowAsync"/> 经 <see cref="IUINavigator"/> 压栈,
|
||||
/// 栈式回退、ESC 取消、焦点恢复统一由导航器负责。</item>
|
||||
/// <item><b>回调 legacy(游戏内地图传送等尚未接入导航器的场景)</b>:<see cref="Show"/> 本地 SetActive
|
||||
/// 显隐 + onConfirm/onCancel 回调,不依赖导航器。</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 用法:
|
||||
/// _confirmDialog.Show("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY",
|
||||
/// onConfirm: () => DoDelete(),
|
||||
/// onCancel: () => {});
|
||||
/// 标题 / 正文 / 按钮文案走本地化键(<see cref="LocalizationManager"/>);传 null 保留 Inspector 原文。
|
||||
/// 默认焦点置于"取消",防手柄/键盘连按误触破坏性确认。
|
||||
/// </summary>
|
||||
public class ConfirmDialogController : MonoBehaviour
|
||||
public class ConfirmDialogController : UIResultPanel<bool>
|
||||
{
|
||||
[Header("根节点(显隐用,留空则用本 GameObject)")]
|
||||
[SerializeField] private GameObject _root;
|
||||
|
||||
[Header("文本")]
|
||||
[SerializeField] private TMP_Text _titleText;
|
||||
[SerializeField] private TMP_Text _bodyText;
|
||||
@@ -38,78 +37,89 @@ namespace BaseGames.UI.Menus
|
||||
[SerializeField] private Button _btnConfirm;
|
||||
[SerializeField] private Button _btnCancel;
|
||||
|
||||
private Action _onConfirm;
|
||||
private Action _onCancel;
|
||||
// 取消 / ESC / 销毁默认结果:否。
|
||||
protected override bool CancelResult => false;
|
||||
|
||||
// legacy 回调模式状态
|
||||
private Action _legacyConfirm;
|
||||
private Action _legacyCancel;
|
||||
private bool _legacyMode;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_btnConfirm?.onClick.AddListener(HandleConfirm);
|
||||
_btnCancel? .onClick.AddListener(HandleCancel);
|
||||
SetVisible(false);
|
||||
_btnConfirm?.onClick.AddListener(() => OnButton(true));
|
||||
_btnCancel? .onClick.AddListener(() => OnButton(false));
|
||||
// 不在此 SetActive(false):面板初始由场景/脚手架序列化为隐藏,激活完全交给导航器
|
||||
// (对象初始 inactive 时 Awake 会被推迟到首次激活才执行,若在此关闭会立刻自我隐藏)。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 弹出确认框。
|
||||
/// </summary>
|
||||
/// <param name="titleKey">标题本地化键;null 保留原文。</param>
|
||||
/// <param name="bodyKey">正文本地化键;null 保留原文。</param>
|
||||
/// <param name="onConfirm">点击确认后回调(确认框已自动关闭)。</param>
|
||||
/// <param name="onCancel">点击取消后回调(可选)。</param>
|
||||
/// <param name="confirmKey">确认按钮文案本地化键(可选)。</param>
|
||||
/// <param name="cancelKey">取消按钮文案本地化键(可选)。</param>
|
||||
/// <summary>默认焦点:取消按钮(破坏性操作安全默认)。</summary>
|
||||
protected override GameObject ResolveFirstSelected()
|
||||
=> _btnCancel != null ? _btnCancel.gameObject
|
||||
: _btnConfirm != null ? _btnConfirm.gameObject : null;
|
||||
|
||||
// ── 导航器 async 路径(主菜单)────────────────────────────────────────
|
||||
/// <summary>弹出确认框并等待结果(true=确认 / false=取消)。由导航器压栈管理。</summary>
|
||||
public Task<bool> ShowAsync(string titleKey, string bodyKey, CancellationToken ct = default,
|
||||
string confirmKey = null, string cancelKey = null)
|
||||
{
|
||||
_legacyMode = false;
|
||||
ApplyText(titleKey, bodyKey, confirmKey, cancelKey);
|
||||
|
||||
var nav = GetService<IUINavigator>();
|
||||
if (nav == null)
|
||||
{
|
||||
Debug.LogError("[ConfirmDialog] 未找到 IUINavigator 服务,无法以 async 模式弹出。", this);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
return nav.PushForResultAsync<bool>(this, ct);
|
||||
}
|
||||
|
||||
// ── legacy 回调路径(游戏内尚未接入导航器的调用方)──────────────────
|
||||
/// <summary>弹出确认框(回调式,本地显隐,不走导航器)。</summary>
|
||||
public void Show(string titleKey, string bodyKey, Action onConfirm, Action onCancel = null,
|
||||
string confirmKey = null, string cancelKey = null)
|
||||
{
|
||||
_onConfirm = onConfirm;
|
||||
_onCancel = onCancel;
|
||||
|
||||
if (_titleText != null && titleKey != null) _titleText.text = Loc(titleKey);
|
||||
if (_bodyText != null && bodyKey != null) _bodyText.text = Loc(bodyKey);
|
||||
if (_confirmLabel != null && confirmKey != null) _confirmLabel.text = Loc(confirmKey);
|
||||
if (_cancelLabel != null && cancelKey != null) _cancelLabel.text = Loc(cancelKey);
|
||||
|
||||
SetVisible(true);
|
||||
|
||||
// 安全默认:焦点置于取消,避免手柄/键盘连按直接确认破坏性操作
|
||||
EventSystem.current?.SetSelectedGameObject(_btnCancel != null
|
||||
? _btnCancel.gameObject
|
||||
: _btnConfirm?.gameObject);
|
||||
_legacyMode = true;
|
||||
_legacyConfirm = onConfirm;
|
||||
_legacyCancel = onCancel;
|
||||
ApplyText(titleKey, bodyKey, confirmKey, cancelKey);
|
||||
gameObject.SetActive(true); // OnEnable 经 UIPanelBase 自动聚焦取消按钮
|
||||
}
|
||||
|
||||
/// <summary>外部强制关闭(如父面板被关闭时)。不触发任何回调。</summary>
|
||||
/// <summary>外部强制关闭(仅 legacy 模式有效)。不触发任何回调。</summary>
|
||||
public void Close()
|
||||
{
|
||||
_onConfirm = null;
|
||||
_onCancel = null;
|
||||
SetVisible(false);
|
||||
if (!_legacyMode) return;
|
||||
_legacyConfirm = null;
|
||||
_legacyCancel = null;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 按钮回调 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void HandleConfirm()
|
||||
// ── 按钮 ──────────────────────────────────────────────────────────────
|
||||
private void OnButton(bool confirmed)
|
||||
{
|
||||
var cb = _onConfirm;
|
||||
SetVisible(false);
|
||||
_onConfirm = null;
|
||||
_onCancel = null;
|
||||
cb?.Invoke();
|
||||
}
|
||||
|
||||
private void HandleCancel()
|
||||
{
|
||||
var cb = _onCancel;
|
||||
SetVisible(false);
|
||||
_onConfirm = null;
|
||||
_onCancel = null;
|
||||
cb?.Invoke();
|
||||
if (_legacyMode)
|
||||
{
|
||||
var cb = confirmed ? _legacyConfirm : _legacyCancel;
|
||||
_legacyConfirm = null;
|
||||
_legacyCancel = null;
|
||||
gameObject.SetActive(false);
|
||||
cb?.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
Complete(confirmed); // 设置结果 + 由导航器出栈
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void SetVisible(bool visible)
|
||||
private void ApplyText(string titleKey, string bodyKey, string confirmKey, string cancelKey)
|
||||
{
|
||||
var go = _root != null ? _root : gameObject;
|
||||
go.SetActive(visible);
|
||||
if (_titleText != null && titleKey != null) _titleText.text = Loc(titleKey);
|
||||
if (_bodyText != null && bodyKey != null) _bodyText.text = Loc(bodyKey);
|
||||
if (_confirmLabel != null && confirmKey != null) _confirmLabel.text = Loc(confirmKey);
|
||||
if (_cancelLabel != null && cancelKey != null) _cancelLabel.text = Loc(cancelKey);
|
||||
}
|
||||
|
||||
private static string Loc(string key)
|
||||
|
||||
102
Assets/_Game/Scripts/UI/Menus/DataDrivenPauseMenuController.cs
Normal file
102
Assets/_Game/Scripts/UI/Menus/DataDrivenPauseMenuController.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.UI.MainMenu; // 复用通用菜单按钮视图 MainMenuButtonView
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 数据驱动暂停菜单(仿 <see cref="MainMenu.DataDrivenMainMenuController"/>)。
|
||||
/// 据 <see cref="PauseMenuConfigSO"/> 在运行时生成按钮、派发动作;生命周期/焦点/淡入由 <see cref="UIPanelBase"/> 统一处理。
|
||||
/// 策划改 UI_PauseMenuConfig 即可增删/重排/改标签/改动作,零代码;样式改 UI_PauseScreen / UI_MainMenu_Button 预制件。
|
||||
/// </summary>
|
||||
public class DataDrivenPauseMenuController : UIPanelBase
|
||||
{
|
||||
[Header("数据表 / 按钮列表")]
|
||||
[SerializeField] private PauseMenuConfigSO _config;
|
||||
[Tooltip("按钮的父节点(通常挂 VerticalLayoutGroup)。")]
|
||||
[SerializeField] private Transform _container;
|
||||
[SerializeField] private MainMenuButtonView _buttonPrefab;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private VoidEventChannelSO _onResumeRequested;
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
private IUIManager _uiManager;
|
||||
private readonly List<MainMenuButtonView> _buttons = new();
|
||||
private MainMenuButtonView _firstButton;
|
||||
|
||||
// 暂停面板由 UIManager 开启,此时 ServiceLocator 已就绪
|
||||
protected override void OnPanelOpen()
|
||||
{
|
||||
_uiManager = GetService<IUIManager>();
|
||||
BuildMenu();
|
||||
}
|
||||
|
||||
protected override void OnPanelClose() => _uiManager = null;
|
||||
|
||||
/// <summary>默认焦点 / 焦点恢复回到首个按钮。</summary>
|
||||
protected override GameObject ResolveFirstSelected()
|
||||
=> _firstButton != null ? _firstButton.Button.gameObject : null;
|
||||
|
||||
/// <summary>据配置重建按钮列表(public 以便编辑器预览/测试)。</summary>
|
||||
public void BuildMenu()
|
||||
{
|
||||
ClearMenu();
|
||||
if (_config == null || _container == null || _buttonPrefab == null) return;
|
||||
|
||||
foreach (var item in _config.Items)
|
||||
{
|
||||
var view = Instantiate(_buttonPrefab, _container);
|
||||
view.gameObject.SetActive(true);
|
||||
var captured = item;
|
||||
view.Bind(item.labelKey, item.icon, () => Dispatch(captured));
|
||||
_buttons.Add(view);
|
||||
if (_firstButton == null) _firstButton = view;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearMenu()
|
||||
{
|
||||
_buttons.Clear();
|
||||
_firstButton = null;
|
||||
if (_container == null) return;
|
||||
for (int i = _container.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var child = _container.GetChild(i).gameObject;
|
||||
if (Application.isPlaying) Destroy(child);
|
||||
else DestroyImmediate(child);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 动作派发 ──────────────────────────────────────────────────────────
|
||||
private void Dispatch(PauseMenuConfigSO.Item item)
|
||||
{
|
||||
switch (item.action)
|
||||
{
|
||||
case PauseMenuAction.Resume:
|
||||
_onResumeRequested?.Raise();
|
||||
_uiManager?.CloseTopPanel();
|
||||
break;
|
||||
case PauseMenuAction.OpenSettings:
|
||||
_uiManager?.OpenPanel(PanelId.Settings);
|
||||
break;
|
||||
case PauseMenuAction.ReturnToMainMenu:
|
||||
_uiManager?.CloseTopPanel();
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = string.IsNullOrEmpty(item.sceneKey) ? AddressKeys.SceneMainMenu : item.sceneKey,
|
||||
TransitionType = TransitionType.Scene,
|
||||
});
|
||||
break;
|
||||
case PauseMenuAction.Quit:
|
||||
Application.Quit();
|
||||
break;
|
||||
case PauseMenuAction.RaiseEvent:
|
||||
item.eventChannel?.Raise();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70ebf028cc731414caf75f2c7f4c28b4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
48
Assets/_Game/Scripts/UI/Menus/PauseMenuConfigSO.cs
Normal file
48
Assets/_Game/Scripts/UI/Menus/PauseMenuConfigSO.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>暂停菜单项动作类型。常用动作内置;任意自定义走事件频道。</summary>
|
||||
public enum PauseMenuAction
|
||||
{
|
||||
Resume, // 继续游戏(关闭暂停面板)
|
||||
OpenSettings, // 打开设置面板
|
||||
ReturnToMainMenu, // 返回主菜单(场景加载)
|
||||
Quit, // 退出游戏
|
||||
RaiseEvent, // 触发 eventChannel(万能扩展)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 暂停菜单数据驱动表(策划编辑)。按顺序列出暂停菜单项;
|
||||
/// <see cref="DataDrivenPauseMenuController"/> 据此生成按钮并派发动作。
|
||||
/// 策划可增删 / 重排 / 改标签图标 / 改动作,无需改代码。样式改 UI_PauseScreen / UI_MainMenu_Button 预制件。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/UI/Pause Menu Config", fileName = "UI_PauseMenuConfig")]
|
||||
public class PauseMenuConfigSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct Item
|
||||
{
|
||||
[Tooltip("按钮标签本地化 Key(UI 表)。")]
|
||||
public string labelKey;
|
||||
|
||||
[Tooltip("按钮图标(可空)。")]
|
||||
public Sprite icon;
|
||||
|
||||
[Tooltip("点击动作。")]
|
||||
public PauseMenuAction action;
|
||||
|
||||
[Tooltip("ReturnToMainMenu 的目标场景 Addressable Key(留空用默认主菜单)。")]
|
||||
public string sceneKey;
|
||||
|
||||
[Tooltip("RaiseEvent 动作触发的事件频道。")]
|
||||
public VoidEventChannelSO eventChannel;
|
||||
}
|
||||
|
||||
[SerializeField] private Item[] _items;
|
||||
|
||||
public Item[] Items => _items;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Menus/PauseMenuConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Menus/PauseMenuConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7dd5202b8fce1d40b073624a3b22953
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,68 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 暂停菜单控制器(架构 10_UIModule §5)。
|
||||
/// 挂载在 Canvas_Menu → PauseMenuPanel GameObject 上。
|
||||
/// 按钮绑定在 Awake 中完成;生命周期 / 焦点由 <see cref="UIPanelBase"/> 统一处理。
|
||||
/// </summary>
|
||||
public class PauseMenuController : UIPanelBase
|
||||
{
|
||||
// UIManager 通过 ServiceLocator 解析,开启时自动获取,无需 Inspector 直接绑定具体类型
|
||||
private IUIManager _uiManager;
|
||||
|
||||
[Header("按钮引用")]
|
||||
[SerializeField] private Button _btnResume;
|
||||
[SerializeField] private Button _btnSettings;
|
||||
[SerializeField] private Button _btnMainMenu;
|
||||
[SerializeField] private Button _btnQuit;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private VoidEventChannelSO _onResumeRequested;
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_btnResume?.onClick.AddListener(Resume);
|
||||
_btnSettings?.onClick.AddListener(OpenSettings);
|
||||
_btnMainMenu?.onClick.AddListener(GoToMainMenu);
|
||||
_btnQuit?.onClick.AddListener(Application.Quit);
|
||||
}
|
||||
|
||||
// 暂停面板由 UIManager 开启,此时 ServiceLocator 已就绪
|
||||
protected override void OnPanelOpen() => _uiManager = GetService<IUIManager>();
|
||||
protected override void OnPanelClose() => _uiManager = null;
|
||||
|
||||
/// <summary>默认焦点 / 焦点恢复回到"继续"按钮(基类 FocusFirst 调用)。</summary>
|
||||
protected override GameObject ResolveFirstSelected()
|
||||
=> _btnResume != null ? _btnResume.gameObject : null;
|
||||
|
||||
// ── 按钮回调 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Resume()
|
||||
{
|
||||
_onResumeRequested?.Raise();
|
||||
_uiManager?.CloseTopPanel();
|
||||
}
|
||||
|
||||
private void OpenSettings()
|
||||
{
|
||||
_uiManager?.OpenPanel(PanelId.Settings);
|
||||
}
|
||||
|
||||
private void GoToMainMenu()
|
||||
{
|
||||
_uiManager?.CloseTopPanel();
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = AddressKeys.SceneMainMenu,
|
||||
TransitionType = TransitionType.Scene,
|
||||
ShowLoadingScreen = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,10 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using TMPro;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
using BaseGames.Localization;
|
||||
using BaseGames.World.Map;
|
||||
using BaseGames.UI;
|
||||
using BaseGames.UI.MainMenu;
|
||||
|
||||
namespace BaseGames.UI.Menus
|
||||
@@ -26,19 +23,21 @@ namespace BaseGames.UI.Menus
|
||||
/// <summary>
|
||||
/// 驱动主菜单存档槽选择面板(新游戏 / 继续 / 删除)。
|
||||
///
|
||||
/// <para>本面板经 <see cref="IUINavigator"/> 压栈(由主菜单控制器 Push)。覆盖确认 / 模式选择
|
||||
/// 这两个子对话框走导航器结果面板(<see cref="ConfirmDialogController.ShowAsync"/> /
|
||||
/// <see cref="NewGameModeController.ShowAsync"/>)——线性 <c>await</c>,无回调嵌套,ESC 逐层回退。</para>
|
||||
///
|
||||
/// 前端选档流程:
|
||||
/// · 新游戏开档前先选模式(普通 / 钢铁之魂),占用槽位需覆盖确认。
|
||||
/// · 删除强制走通用确认对话框(无静默删除旁路)。
|
||||
/// · 卡片展示游玩时长 / 区域 / 货币 / 生命 / 钢魂徽章。
|
||||
///
|
||||
/// 模式由 MainMenuController 在打开面板前通过 <see cref="SetMode"/> 指定。
|
||||
/// ConfirmDialog 与 NewGameMode 面板经 Inspector 注入(同处 MainMenu 场景)。
|
||||
/// 模式由主菜单控制器在压栈前经 <see cref="SetMode"/> 指定。
|
||||
/// </summary>
|
||||
public class SaveSlotController : MonoBehaviour, IFocusable
|
||||
public class SaveSlotController : UIPanelBase
|
||||
{
|
||||
[SerializeField] private SaveSlotUI[] _slotUIs;
|
||||
|
||||
[Header("子面板(Inspector 注入,同处 MainMenu 场景)")]
|
||||
[Header("子对话框(Inspector 注入,同处 MainMenu 场景,走导航器结果面板)")]
|
||||
[Tooltip("通用确认对话框,用于覆盖 / 删除确认。为空时:覆盖退化为直接建档,删除被忽略(绝不静默删除)。")]
|
||||
[SerializeField] private ConfirmDialogController _confirmDialog;
|
||||
[Tooltip("新游戏模式选择面板。为空时新游戏退化为普通模式。")]
|
||||
@@ -48,7 +47,7 @@ namespace BaseGames.UI.Menus
|
||||
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
|
||||
|
||||
[Header("焦点")]
|
||||
[Tooltip("面板恢复为栈顶时自动聚焦的默认按钮,通常为第一个存档槽的选择按钮。")]
|
||||
[Tooltip("面板默认聚焦的按钮,通常为第一个存档槽的选择按钮。")]
|
||||
[SerializeField] private Button _defaultFocusButton;
|
||||
|
||||
private SaveSlotPanelMode _mode = SaveSlotPanelMode.NewGame;
|
||||
@@ -60,40 +59,24 @@ namespace BaseGames.UI.Menus
|
||||
if (_slotUIs[i] != null) _slotUIs[i].Init(i, this);
|
||||
}
|
||||
|
||||
/// <summary>由 MainMenuController 在 SetActive(true) 之前调用,决定本次打开语境。</summary>
|
||||
/// <summary>由主菜单控制器在压栈前调用,决定本次打开语境。</summary>
|
||||
public void SetMode(SaveSlotPanelMode mode) => _mode = mode;
|
||||
|
||||
// ── IFocusable ────────────────────────────────────────────────────────
|
||||
public void OnFocusRestored() => StartCoroutine(RestoreFocusNextFrame());
|
||||
/// <summary>默认 / 恢复焦点回到首个存档槽按钮。</summary>
|
||||
protected override GameObject ResolveFirstSelected()
|
||||
=> _defaultFocusButton != null ? _defaultFocusButton.gameObject : null;
|
||||
|
||||
private System.Collections.IEnumerator RestoreFocusNextFrame()
|
||||
{
|
||||
yield return null;
|
||||
if (UnityEngine.EventSystems.EventSystem.current != null && _defaultFocusButton != null)
|
||||
UnityEngine.EventSystems.EventSystem.current.SetSelectedGameObject(_defaultFocusButton.gameObject);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
// ── 生命周期(UIPanelBase 驱动)──────────────────────────────────────
|
||||
protected override void OnPanelOpen()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
var ct = _cts.Token;
|
||||
var task = RefreshAsync(ct);
|
||||
task.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted && !(t.Exception?.InnerException is OperationCanceledException))
|
||||
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
// 面板打开时设置初始焦点(键盘 / 手柄导航入口)
|
||||
StartCoroutine(RestoreFocusNextFrame());
|
||||
RunGuarded(RefreshAsync(_cts.Token));
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
protected override void OnPanelClose()
|
||||
{
|
||||
// 关闭子对话框,避免下次打开残留
|
||||
_confirmDialog?.Close();
|
||||
_modeSelect?.Close();
|
||||
|
||||
// 注意:不再级联 Close 子对话框(旧实现于此强关 confirm/mode,是"ESC 一次关多层"的根因)。
|
||||
// 子对话框生命周期完全交给导航器;若本面板随场景卸载,其 await 由结果面板兜底收口。
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
@@ -119,47 +102,42 @@ namespace BaseGames.UI.Menus
|
||||
}
|
||||
|
||||
// ── 选槽 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>选中指定槽位。行为取决于当前模式与槽位是否有档。由 SaveSlotUI 内部按钮调用。</summary>
|
||||
public void OnSlotSelected(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
|
||||
RunGuarded(HandleSlotSelectedAsync(slotIndex));
|
||||
}
|
||||
|
||||
private async Task HandleSlotSelectedAsync(int slotIndex)
|
||||
{
|
||||
var svc = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
if (svc == null) return;
|
||||
|
||||
bool hasData = svc.HasSave(slotIndex);
|
||||
var ct = _cts?.Token ?? CancellationToken.None;
|
||||
|
||||
if (_mode == SaveSlotPanelMode.Continue)
|
||||
{
|
||||
if (!hasData) return; // 继续模式:空槽不可选
|
||||
_ = ContinueSlotAsync(slotIndex);
|
||||
if (!svc.HasSave(slotIndex)) return; // 继续模式:空槽不可选
|
||||
await ContinueSlotAsync(slotIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 新游戏模式 ──
|
||||
if (hasData)
|
||||
if (svc.HasSave(slotIndex) && _confirmDialog != null)
|
||||
{
|
||||
// 占用槽位:先覆盖确认
|
||||
if (_confirmDialog != null)
|
||||
_confirmDialog.Show("CONFIRM_OVERWRITE_TITLE", "CONFIRM_OVERWRITE_BODY",
|
||||
onConfirm: () => BeginNewGameFlow(slotIndex));
|
||||
else
|
||||
BeginNewGameFlow(slotIndex); // 无对话框时退化为直接建档
|
||||
bool ok = await _confirmDialog.ShowAsync("CONFIRM_OVERWRITE_TITLE", "CONFIRM_OVERWRITE_BODY", ct);
|
||||
if (!ok) return; // 取消覆盖
|
||||
}
|
||||
else
|
||||
{
|
||||
BeginNewGameFlow(slotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>开新档流程:选模式 → 建档。</summary>
|
||||
private void BeginNewGameFlow(int slotIndex)
|
||||
{
|
||||
DifficultyLevel level = DifficultyLevel.Normal;
|
||||
if (_modeSelect != null)
|
||||
_modeSelect.Show(level => _ = StartNewGameAsync(slotIndex, level));
|
||||
else
|
||||
_ = StartNewGameAsync(slotIndex, DifficultyLevel.Normal); // 无模式面板时退化为普通
|
||||
{
|
||||
DifficultyLevel? chosen = await _modeSelect.ShowAsync(ct);
|
||||
if (chosen == null) return; // 取消模式选择
|
||||
level = chosen.Value;
|
||||
}
|
||||
|
||||
await StartNewGameAsync(slotIndex, level);
|
||||
}
|
||||
|
||||
private async Task StartNewGameAsync(int slotIndex, DifficultyLevel level)
|
||||
@@ -190,7 +168,6 @@ namespace BaseGames.UI.Menus
|
||||
}
|
||||
|
||||
// ── 删除(强制确认)────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>请求删除指定槽位。强制经通用确认对话框;无对话框时忽略,绝不静默删除。</summary>
|
||||
public void OnSlotDeleteRequested(int slotIndex)
|
||||
{
|
||||
@@ -201,9 +178,15 @@ namespace BaseGames.UI.Menus
|
||||
Debug.LogWarning("[SaveSlotController] 未配置 ConfirmDialog,删除请求被忽略(防止静默删除)。");
|
||||
return;
|
||||
}
|
||||
RunGuarded(DeleteFlowAsync(slotIndex));
|
||||
}
|
||||
|
||||
_confirmDialog.Show("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY",
|
||||
onConfirm: () => _ = DeleteAndRefreshAsync(slotIndex));
|
||||
private async Task DeleteFlowAsync(int slotIndex)
|
||||
{
|
||||
var ct = _cts?.Token ?? CancellationToken.None;
|
||||
bool ok = await _confirmDialog.ShowAsync("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY", ct);
|
||||
if (!ok) return;
|
||||
await DeleteAndRefreshAsync(slotIndex);
|
||||
}
|
||||
|
||||
private async Task DeleteAndRefreshAsync(int slotIndex)
|
||||
@@ -213,5 +196,16 @@ namespace BaseGames.UI.Menus
|
||||
await svc.DeleteSlotAsync(slotIndex);
|
||||
await RefreshAsync(_cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
// ── 工具 ──────────────────────────────────────────────────────────────
|
||||
/// <summary>fire-and-forget Task 的统一异常护栏(吞掉取消,记录其余)。</summary>
|
||||
private void RunGuarded(Task task)
|
||||
{
|
||||
task.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted && !(t.Exception?.InnerException is OperationCanceledException))
|
||||
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 设置面板控制器(架构 10_UIModule §7)。
|
||||
/// 驱动 SettingsManager 的音量与画面设置,并从当前配置初始化控件值。
|
||||
/// 生命周期 / 焦点由 <see cref="UIPanelBase"/> 统一处理。
|
||||
/// </summary>
|
||||
public class SettingsPanelController : UIPanelBase
|
||||
{
|
||||
// ISettingsService 通过 ServiceLocator 获取,无需 Inspector 直接注入具体类,
|
||||
// 支持测试场景替换 Mock 实现。
|
||||
private ISettingsService _settings;
|
||||
|
||||
[Header("音量滑条")]
|
||||
[SerializeField] private Slider _masterVolume;
|
||||
[SerializeField] private Slider _bgmVolume;
|
||||
[SerializeField] private Slider _sfxVolume;
|
||||
[SerializeField] private Slider _ambientVolume;
|
||||
|
||||
[Header("画面")]
|
||||
[SerializeField] private Toggle _vSyncToggle;
|
||||
[SerializeField] private TMP_Dropdown _fpsDropdown; // 30 / 60 / 120 / 无限
|
||||
|
||||
[Header("可访问性")]
|
||||
[SerializeField] private Slider _uiScaleSlider; // 0.8 ~ 1.5
|
||||
[SerializeField] private TMP_Text _uiScaleValueText; // 实时显示 "100%"
|
||||
[SerializeField] private TMP_Dropdown _colorblindDropdown; // None / Prot / Deut / Trit
|
||||
[SerializeField] private Toggle _screenShakeToggle;
|
||||
|
||||
[Header("语言")]
|
||||
[SerializeField] private TMP_Dropdown _languageDropdown; // 中文 / English / 日本語 / 한국어
|
||||
|
||||
[Header("按键重绑定")]
|
||||
[SerializeField] private GameObject _rebindPanelRoot; // RebindPanel GameObject
|
||||
|
||||
private static readonly int[] FpsOptions = { 30, 60, 120, -1 };
|
||||
|
||||
// 语言下拉项顺序(与脚手架填充的显示项一一对应)
|
||||
private static readonly Language[] LanguageOptions =
|
||||
{ Language.ChineseSimplified, Language.English, Language.Japanese, Language.Korean };
|
||||
|
||||
protected override void OnPanelOpen()
|
||||
{
|
||||
_settings = ServiceLocator.GetOrDefault<ISettingsService>();
|
||||
if (_settings == null) return;
|
||||
var data = _settings.Current;
|
||||
|
||||
// 初始化控件值(先移除监听再设置值再添加,防止面板重开时重复注册)
|
||||
InitSlider(_masterVolume, data.MasterVolume, v => _settings.SetMasterVolume(v));
|
||||
InitSlider(_bgmVolume, data.BGMVolume, v => _settings.SetBGMVolume(v));
|
||||
InitSlider(_sfxVolume, data.SFXVolume, v => _settings.SetSFXVolume(v));
|
||||
InitSlider(_ambientVolume,data.AmbientVolume, v => _settings.SetAmbientVolume(v));
|
||||
|
||||
if (_vSyncToggle != null)
|
||||
{
|
||||
_vSyncToggle.onValueChanged.RemoveAllListeners();
|
||||
_vSyncToggle.isOn = data.VSync;
|
||||
_vSyncToggle.onValueChanged.AddListener(v => _settings.SetVSync(v));
|
||||
}
|
||||
|
||||
if (_fpsDropdown != null)
|
||||
{
|
||||
_fpsDropdown.onValueChanged.RemoveAllListeners();
|
||||
int idx = System.Array.IndexOf(FpsOptions, data.TargetFPS);
|
||||
_fpsDropdown.value = idx >= 0 ? idx : 1; // default 60
|
||||
_fpsDropdown.onValueChanged.AddListener(i =>
|
||||
_settings.SetTargetFrameRate(FpsOptions[Mathf.Clamp(i, 0, FpsOptions.Length - 1)]));
|
||||
}
|
||||
|
||||
// ── 可访问性 ──────────────────────────────────────────────────────
|
||||
if (_uiScaleSlider != null)
|
||||
{
|
||||
_uiScaleSlider.onValueChanged.RemoveAllListeners();
|
||||
_uiScaleSlider.minValue = 0.8f;
|
||||
_uiScaleSlider.maxValue = 1.5f;
|
||||
_uiScaleSlider.value = Mathf.Clamp(data.UIScale, _uiScaleSlider.minValue, _uiScaleSlider.maxValue);
|
||||
UpdateUIScaleLabel(_uiScaleSlider.value);
|
||||
_uiScaleSlider.onValueChanged.AddListener(v =>
|
||||
{
|
||||
_settings.SetUIScale(v);
|
||||
UpdateUIScaleLabel(v);
|
||||
});
|
||||
}
|
||||
|
||||
if (_colorblindDropdown != null)
|
||||
{
|
||||
_colorblindDropdown.onValueChanged.RemoveAllListeners();
|
||||
_colorblindDropdown.value = (int)data.ColorblindMode;
|
||||
_colorblindDropdown.onValueChanged.AddListener(i =>
|
||||
_settings.SetColorblindMode((ColorblindMode)Mathf.Clamp(i, 0, 3)));
|
||||
}
|
||||
|
||||
if (_screenShakeToggle != null)
|
||||
{
|
||||
_screenShakeToggle.onValueChanged.RemoveAllListeners();
|
||||
_screenShakeToggle.isOn = data.ScreenShakeEnabled;
|
||||
_screenShakeToggle.onValueChanged.AddListener(v => _settings.SetScreenShakeEnabled(v));
|
||||
}
|
||||
|
||||
// ── 语言 ──────────────────────────────────────────────────────────
|
||||
if (_languageDropdown != null)
|
||||
{
|
||||
_languageDropdown.onValueChanged.RemoveAllListeners();
|
||||
var loc = ServiceLocator.GetOrDefault<ILocalizationService>();
|
||||
int idx = loc != null ? System.Array.IndexOf(LanguageOptions, loc.CurrentLanguage) : 0;
|
||||
_languageDropdown.value = idx >= 0 ? idx : 0;
|
||||
_languageDropdown.RefreshShownValue();
|
||||
_languageDropdown.onValueChanged.AddListener(i =>
|
||||
ServiceLocator.GetOrDefault<ILocalizationService>()?
|
||||
.SetLanguage(LanguageOptions[Mathf.Clamp(i, 0, LanguageOptions.Length - 1)]));
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUIScaleLabel(float v)
|
||||
{
|
||||
if (_uiScaleValueText != null)
|
||||
_uiScaleValueText.text = Mathf.RoundToInt(v * 100f) + "%";
|
||||
}
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static void InitSlider(Slider slider, float value, UnityEngine.Events.UnityAction<float> onChange)
|
||||
{
|
||||
if (slider == null) return;
|
||||
slider.onValueChanged.RemoveAllListeners();
|
||||
slider.value = value;
|
||||
slider.onValueChanged.AddListener(onChange);
|
||||
}
|
||||
|
||||
// ── 焦点 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>默认焦点 / 焦点恢复回到主音量滑条(基类 FocusFirst 调用)。</summary>
|
||||
protected override GameObject ResolveFirstSelected()
|
||||
=> _masterVolume != null ? _masterVolume.gameObject : null;
|
||||
}
|
||||
}
|
||||
8
Assets/_Game/Scripts/UI/Navigation.meta
Normal file
8
Assets/_Game/Scripts/UI/Navigation.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5e5751b1ad3a45439165d543174bbe1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
47
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs
Normal file
47
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 统一 UI 导航栈服务。所有面板(主菜单子面板 + 游戏内面板)经此压栈/出栈,
|
||||
/// 由它统一保证:只有栈顶可交互、下层被屏蔽出导航图、ESC 逐层回退、出栈恢复焦点。
|
||||
///
|
||||
/// <para>设计要点:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>单一取消入口:导航器是 EVT_UICancelPressed 的唯一消费者,ESC 只关栈顶一层。</item>
|
||||
/// <item>场景作用域:面板可能位于会被卸载的关卡 / 主菜单场景,导航器订阅 sceneUnloaded
|
||||
/// 清理随场景销毁的栈层,每次操作对已销毁面板兜底。</item>
|
||||
/// <item>非面板的"底层上下文"(如主菜单按钮组、游戏内 HUD)不入栈,由各自上下文
|
||||
/// 订阅 <see cref="StackChanged"/> / 读 <see cref="Depth"/> 自行屏蔽。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IUINavigator
|
||||
{
|
||||
/// <summary>当前栈顶面板;空栈为 null。</summary>
|
||||
UIPanelBase Top { get; }
|
||||
|
||||
/// <summary>栈深度(已打开的面板层数)。</summary>
|
||||
int Depth { get; }
|
||||
|
||||
/// <summary>栈结构变化(任何 Push / Pop / 场景清理)后触发,供底层上下文屏蔽自身。</summary>
|
||||
event Action StackChanged;
|
||||
|
||||
/// <summary>压栈打开面板。<paramref name="mode"/> 为空时用面板自身 <see cref="UIPanelBase.DefaultMode"/>。</summary>
|
||||
void Push(UIPanelBase panel, PushMode? mode = null);
|
||||
|
||||
/// <summary>关闭栈顶并恢复下层(若有)。空栈无操作。</summary>
|
||||
void Pop();
|
||||
|
||||
/// <summary>清空整个栈(逐层关闭)。</summary>
|
||||
void PopToRoot();
|
||||
|
||||
/// <summary>
|
||||
/// 压栈打开结果面板并等待玩家给出结果(确认 / 选择)。
|
||||
/// 面板被出栈(含 ESC 取消)、被销毁或 <paramref name="ct"/> 取消时,
|
||||
/// 以面板的取消默认值兜底完成,绝不悬挂 await。
|
||||
/// </summary>
|
||||
Task<T> PushForResultAsync<T>(UIResultPanel<T> panel, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0c910e685c9b35d4b90d187e2e20e614
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
14
Assets/_Game/Scripts/UI/Navigation/PushMode.cs
Normal file
14
Assets/_Game/Scripts/UI/Navigation/PushMode.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 面板压栈方式:决定一个面板压栈时,其正下方的面板如何处理。
|
||||
/// </summary>
|
||||
public enum PushMode
|
||||
{
|
||||
/// <summary>替换:压栈时停用下方面板(整屏切换,下层不可见、不参与导航)。</summary>
|
||||
Replace,
|
||||
|
||||
/// <summary>模态:压栈时下方面板保持可见,但屏蔽其交互与射线(对话框叠在其上)。</summary>
|
||||
Modal,
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/PushMode.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/PushMode.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2dad1d08b7f80394493ca81439a7eb02
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
182
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs
Normal file
182
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b777118c6c3387b4e81cbf5bb56c8a23
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
60
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs
Normal file
60
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回结果的模态面板基类(确认框、模式 / 难度选择等)。
|
||||
///
|
||||
/// <para>用法(调用方走线性 await,无回调嵌套):</para>
|
||||
/// <code>
|
||||
/// bool ok = await _confirmDialog.ShowAsync("CONFIRM_OVERWRITE_TITLE", "CONFIRM_OVERWRITE_BODY", ct);
|
||||
/// if (!ok) return;
|
||||
/// </code>
|
||||
///
|
||||
/// <para>结果通道由 <see cref="TaskCompletionSource{T}"/> 承载,并对所有"提前结束"路径兜底
|
||||
/// (ESC 取消出栈、外部强关、所属场景卸载、CancellationToken 取消),保证 await 绝不悬挂。</para>
|
||||
/// </summary>
|
||||
public abstract class UIResultPanel<T> : UIPanelBase
|
||||
{
|
||||
private TaskCompletionSource<T> _tcs;
|
||||
private CancellationTokenRegistration _ctReg;
|
||||
|
||||
/// <summary>取消 / 默认结果:ESC、返回、销毁、场景卸载、ct 取消时以此完成。</summary>
|
||||
protected abstract T CancelResult { get; }
|
||||
|
||||
/// <summary>由导航器 <see cref="IUINavigator.PushForResultAsync{T}"/> 调用:开启一轮结果等待。</summary>
|
||||
internal Task<T> BeginResult(CancellationToken ct)
|
||||
{
|
||||
// 复用面板:若上一轮仍未决,先以默认值收口,避免句柄泄漏。
|
||||
ResolvePending();
|
||||
|
||||
_tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_ctReg = ct.CanBeCanceled ? ct.Register(ResolvePending) : default;
|
||||
return _tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>具体按钮回调:以 <paramref name="result"/> 完成并出栈自身。</summary>
|
||||
protected void Complete(T result)
|
||||
{
|
||||
var tcs = _tcs;
|
||||
if (tcs != null && tcs.TrySetResult(result))
|
||||
{
|
||||
_ctReg.Dispose();
|
||||
GetService<IUINavigator>()?.Pop(); // 弹出自己(栈顶)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>以取消默认值收口未决结果(幂等)。</summary>
|
||||
private void ResolvePending()
|
||||
{
|
||||
var tcs = _tcs;
|
||||
if (tcs != null && tcs.TrySetResult(CancelResult))
|
||||
_ctReg.Dispose();
|
||||
}
|
||||
|
||||
// 出栈(含 ESC 取消)会 SetActive(false) → OnDisable → OnPanelClose;
|
||||
// 场景卸载 / 销毁同样经 OnDisable。统一在此兜底,覆盖所有提前结束路径。
|
||||
protected override void OnPanelClose() => ResolvePending();
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87869bd9c1e862141bff2eff5fcbaf51
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
17
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs
Normal file
17
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 导航栈中一层的记录。<see cref="FocusToRestore"/> 记录压栈前的选中项,
|
||||
/// 出栈时据此恢复键盘 / 手柄焦点;<see cref="OwningScene"/> 用于场景卸载时清理本层。
|
||||
/// </summary>
|
||||
internal sealed class UIStackEntry
|
||||
{
|
||||
public UIPanelBase Panel;
|
||||
public GameObject FocusToRestore;
|
||||
public PushMode Mode;
|
||||
public Scene OwningScene;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4f0c943bfa478e4aa7b24aafb5192f3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.UI.Settings
|
||||
/// <summary>
|
||||
/// 数据驱动设置面板:据 <see cref="SettingsSchemaSO"/> 生成控件行并绑定 <see cref="ISettingsService"/>。
|
||||
///
|
||||
/// 取代硬编码的 <see cref="BaseGames.UI.SettingsPanelController"/>:策划改表即可增删 / 重排 / 改标签 / 分节,
|
||||
/// 取代旧的硬编码设置面板控制器:策划改表即可增删 / 重排 / 改标签 / 分节,
|
||||
/// 无需改代码。每行据 <see cref="SettingKey"/> 自动选用 Slider / Toggle / Dropdown 行预制件,
|
||||
/// 并复用通用控件 <see cref="UISlider"/> / <see cref="UIDropdown"/>。
|
||||
///
|
||||
@@ -73,10 +73,17 @@ namespace BaseGames.UI.Settings
|
||||
}
|
||||
}
|
||||
|
||||
private void Clear()
|
||||
/// <summary>清空已生成的行(容器全部子节点)。编辑器预览与运行时关闭共用(编辑期用 DestroyImmediate)。</summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var go in _spawned) if (go != null) Destroy(go);
|
||||
_spawned.Clear();
|
||||
if (_container == null) return;
|
||||
for (int i = _container.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var child = _container.GetChild(i).gameObject;
|
||||
if (Application.isPlaying) Destroy(child);
|
||||
else DestroyImmediate(child);
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject PrefabFor(ControlKind kind) => kind switch
|
||||
|
||||
@@ -53,13 +53,9 @@ namespace BaseGames.UI
|
||||
[SerializeField] private VoidEventChannelSO _onSpellSelectOpen;
|
||||
[Tooltip("打开统一背包菜单(InventoryHub)。对应 EVT_InventoryOpen。")]
|
||||
[SerializeField] private VoidEventChannelSO _onInventoryOpen;
|
||||
[Tooltip("UI 取消操作(ESC / 手柄 B·Circle),全局关闭栈顶面板。对应 EVT_UICancelPressed。")]
|
||||
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
|
||||
|
||||
// ── 面板栈结构 ────────────────────────────────────────────────────────
|
||||
private readonly Stack<GameObject> _panelStack = new();
|
||||
/// <summary>O(1) 成员判断,与 _panelStack 保持同步,替代 Stack.Contains O(n)。</summary>
|
||||
private readonly HashSet<GameObject> _openPanelSet = new();
|
||||
// ── 面板栈:委托给统一的 UINavigator(不再自管栈)─────────────────────
|
||||
private IUINavigator _navigator;
|
||||
private readonly Dictionary<PanelId, GameObject> _panelRegistry = new();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
@@ -114,6 +110,7 @@ namespace BaseGames.UI
|
||||
private void OnEnable()
|
||||
{
|
||||
ServiceLocator.Register<IUIManager>(this);
|
||||
_navigator = ServiceLocator.GetOrDefault<IUINavigator>();
|
||||
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
|
||||
_onPauseRequested?.Subscribe(TogglePause).AddTo(_subs);
|
||||
_onFastTravelOpen?.Subscribe(OpenMap).AddTo(_subs);
|
||||
@@ -122,7 +119,7 @@ namespace BaseGames.UI
|
||||
_onCharmPanelOpen?.Subscribe(OpenCharmPanel).AddTo(_subs);
|
||||
_onSpellSelectOpen?.Subscribe(OpenSpellSelect).AddTo(_subs);
|
||||
_onInventoryOpen?.Subscribe(OpenInventory).AddTo(_subs);
|
||||
_onUICancelPressed?.Subscribe(HandleUICancelPressed).AddTo(_subs);
|
||||
// 取消键(ESC / 手柄 B)由 UINavigator 统一消费,UIManager 不再订阅。
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
@@ -239,46 +236,44 @@ namespace BaseGames.UI
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>打开指定 GameObject 面板并压栈;已在栈中则忽略(O(1) 判断)。</summary>
|
||||
/// <summary>打开指定 GameObject 面板:经统一 <see cref="IUINavigator"/> 压栈。</summary>
|
||||
public void OpenPanel(GameObject panel)
|
||||
{
|
||||
if (panel == null) return;
|
||||
if (!_openPanelSet.Add(panel)) return;
|
||||
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false);
|
||||
panel.SetActive(true);
|
||||
_panelStack.Push(panel);
|
||||
var uiPanel = EnsurePanel(panel);
|
||||
if (uiPanel != null) Navigator?.Push(uiPanel);
|
||||
}
|
||||
|
||||
/// <summary>关闭栈顶面板并恢复上一层(如有);上一层若实现 IFocusable 则自动恢复焦点。</summary>
|
||||
public void CloseTopPanel()
|
||||
/// <summary>关闭栈顶面板并恢复上一层(委托给导航器)。</summary>
|
||||
public void CloseTopPanel() => Navigator?.Pop();
|
||||
|
||||
/// <summary>
|
||||
/// 适配器:保证面板根挂有 <see cref="UIPanelBase"/>(导航器压栈对象)。
|
||||
/// 既有面板若未挂则运行时补 <see cref="UISimplePanel"/> + CanvasGroup,
|
||||
/// 使所有游戏内面板无需逐个改脚手架即可纳入统一导航栈。
|
||||
/// </summary>
|
||||
private static UIPanelBase EnsurePanel(GameObject go)
|
||||
{
|
||||
if (_panelStack.Count == 0) return;
|
||||
var top = _panelStack.Pop();
|
||||
_openPanelSet.Remove(top);
|
||||
top.SetActive(false);
|
||||
if (_panelStack.Count > 0)
|
||||
var p = go.GetComponent<UIPanelBase>();
|
||||
if (p == null)
|
||||
{
|
||||
var restored = _panelStack.Peek();
|
||||
restored.SetActive(true);
|
||||
restored.GetComponent<IFocusable>()?.OnFocusRestored();
|
||||
if (go.GetComponent<CanvasGroup>() == null) go.AddComponent<CanvasGroup>();
|
||||
p = go.AddComponent<UISimplePanel>();
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── 快捷事件回调 ──────────────────────────────────────────────────────
|
||||
private void HandleUICancelPressed()
|
||||
{
|
||||
if (_panelStack.Count > 0)
|
||||
CloseTopPanel();
|
||||
}
|
||||
|
||||
private void TogglePause()
|
||||
{
|
||||
if (_panelRegistry.TryGetValue(PanelId.Pause, out var pausePanel)
|
||||
&& _panelStack.Count > 0 && _panelStack.Peek() == pausePanel)
|
||||
CloseTopPanel();
|
||||
&& Navigator?.Top != null && Navigator.Top.gameObject == pausePanel)
|
||||
Navigator.Pop();
|
||||
else
|
||||
OpenPanel(PanelId.Pause);
|
||||
}
|
||||
|
||||
private IUINavigator Navigator => _navigator ??= ServiceLocator.GetOrDefault<IUINavigator>();
|
||||
private void OpenShop(string _) => OpenPanel(PanelId.Shop);
|
||||
private void OpenMap() => OpenPanel(PanelId.Map);
|
||||
private void OpenCharmPanel() => OpenPanel(PanelId.CharmPanel);
|
||||
@@ -335,8 +330,9 @@ namespace BaseGames.UI
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>仅供 UIManagerEditor 实时可视化面板栈(由栈顶到栈底顺序)。</summary>
|
||||
public GameObject[] EditorGetPanelSnapshot() => _panelStack.ToArray();
|
||||
/// <summary>仅供 UIManagerEditor 实时可视化栈顶面板(栈本体已迁移至 UINavigator)。</summary>
|
||||
public GameObject[] EditorGetPanelSnapshot()
|
||||
=> Navigator?.Top != null ? new[] { Navigator.Top.gameObject } : System.Array.Empty<GameObject>();
|
||||
#endif
|
||||
|
||||
[ContextMenu("测试:打开 Pause 面板")]
|
||||
|
||||
@@ -18,6 +18,10 @@ namespace BaseGames.UI
|
||||
[DefaultExecutionOrder(100)]
|
||||
public class UISelectionRestorer : MonoBehaviour
|
||||
{
|
||||
[Tooltip("始终保持选中:选中丢失(如鼠标点击空白处)即立即恢复,无需等待导航键。\n" +
|
||||
"适合\"菜单始终有一项高亮\"的手感;关闭则仅在按下方向/确认键时恢复(避免抢占鼠标)。")]
|
||||
[SerializeField] private bool _keepSelectionAlways = true;
|
||||
|
||||
private GameObject _lastSelected;
|
||||
|
||||
private void Update()
|
||||
@@ -32,8 +36,8 @@ namespace BaseGames.UI
|
||||
return;
|
||||
}
|
||||
|
||||
// 选中已丢失:仅当出现导航/确认意图时才恢复,避免抢占鼠标操作
|
||||
if (!NavigationIntentThisFrame(es)) return;
|
||||
// 选中已丢失:始终保持模式下立即恢复;否则仅在出现导航/确认意图时恢复
|
||||
if (!_keepSelectionAlways && !NavigationIntentThisFrame(es)) return;
|
||||
if (_lastSelected == null || !_lastSelected.activeInHierarchy) return;
|
||||
|
||||
var sel = _lastSelected.GetComponent<Selectable>();
|
||||
|
||||
Reference in New Issue
Block a user