463 lines
17 KiB
C#
463 lines
17 KiB
C#
|
|
using System;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.Reflection;
|
|||
|
|
using Cielonos.MainGame;
|
|||
|
|
using Cielonos.Settings;
|
|||
|
|
using Cielonos.Settings.UI;
|
|||
|
|
using SLSUtilities.UI;
|
|||
|
|
using TMPro;
|
|||
|
|
using UnityEngine;
|
|||
|
|
using UnityEngine.InputSystem;
|
|||
|
|
using UnityEngine.UI;
|
|||
|
|
|
|||
|
|
namespace Cielonos.UI
|
|||
|
|
{
|
|||
|
|
/// <summary>
|
|||
|
|
/// 设置界面页面(单页滚动式 + 右侧面板)。
|
|||
|
|
/// <para>
|
|||
|
|
/// <b>左侧:</b>所有设置分类在同一页面中按顺序展示,每个分类前插入标题。
|
|||
|
|
/// 通过反射读取各设置类的公共字段,按类型自动选择 Entry Prefab 实例化。
|
|||
|
|
/// 同时支持手动添加的按钮条目(如"Key Bindings"按钮)。
|
|||
|
|
/// </para>
|
|||
|
|
/// <para>
|
|||
|
|
/// <b>右侧:</b>包含两种面板状态:
|
|||
|
|
/// <list type="number">
|
|||
|
|
/// <item>说明面板(<see cref="descriptionPanel"/>):鼠标悬停条目时显示说明文本,离开后隐藏。</item>
|
|||
|
|
/// <item>详情页面(如 <see cref="keyBindingPage"/>):点击按钮后打开的二级 UIPage,
|
|||
|
|
/// 打开期间悬停说明被抑制,需手动关闭。</item>
|
|||
|
|
/// </list>
|
|||
|
|
/// </para>
|
|||
|
|
/// </summary>
|
|||
|
|
public class SettingsUIPage : UIPageBase
|
|||
|
|
{
|
|||
|
|
// ──────────────────── Entry Prefabs ──────────────────
|
|||
|
|
|
|||
|
|
[Header("Entry Prefabs")]
|
|||
|
|
[Tooltip("bool 字段使用的 Toggle 条目预制体,需挂载 SettingsEntryToggle。")]
|
|||
|
|
[SerializeField] private GameObject toggleEntryPrefab;
|
|||
|
|
|
|||
|
|
[Tooltip("带 [Range] 的 int 字段使用的 Slider 条目预制体,需挂载 SettingsEntrySlider。")]
|
|||
|
|
[SerializeField] private GameObject sliderEntryPrefab;
|
|||
|
|
|
|||
|
|
[Tooltip("enum 字段使用的 Dropdown 条目预制体,需挂载 SettingsEntryDropdown。")]
|
|||
|
|
[SerializeField] private GameObject dropdownEntryPrefab;
|
|||
|
|
|
|||
|
|
[Tooltip("按钮条目预制体,需挂载 SettingsEntryButton。")]
|
|||
|
|
[SerializeField] private GameObject buttonEntryPrefab;
|
|||
|
|
|
|||
|
|
// ──────────────────── Section Header ──────────────────
|
|||
|
|
|
|||
|
|
[Header("Section Header")]
|
|||
|
|
[Tooltip("分类标题预制体,需包含 TMP_Text 组件。")]
|
|||
|
|
[SerializeField] private GameObject sectionHeaderPrefab;
|
|||
|
|
|
|||
|
|
// ──────────────────── Left Content ────────────────────
|
|||
|
|
|
|||
|
|
[Header("Left Content")]
|
|||
|
|
[Tooltip("条目的父容器,应挂载 VerticalLayoutGroup 和 ContentSizeFitter。")]
|
|||
|
|
[SerializeField] private RectTransform contentContainer;
|
|||
|
|
|
|||
|
|
// ──────────────────── Right Panel ─────────────────────
|
|||
|
|
|
|||
|
|
[Header("Right Panel - Description")]
|
|||
|
|
[Tooltip("右侧悬停说明面板,显示条目的标题和说明文本。")]
|
|||
|
|
[SerializeField] private SettingsDescriptionPanel descriptionPanel;
|
|||
|
|
|
|||
|
|
[Header("Right Panel - Detail Pages")]
|
|||
|
|
[Tooltip("键位绑定二级页面。")]
|
|||
|
|
[SerializeField] private KeyBindingPage keyBindingPage;
|
|||
|
|
|
|||
|
|
// ──────────────────── Input ───────────────────────────
|
|||
|
|
|
|||
|
|
[Header("Input")]
|
|||
|
|
[Tooltip("用于键位绑定页面的 InputActionAsset。为空时从 Player 获取。")]
|
|||
|
|
[SerializeField] private InputActionAsset inputActionAsset;
|
|||
|
|
|
|||
|
|
// ──────────────────── 底部按钮 ────────────────────
|
|||
|
|
|
|||
|
|
[Header("Bottom Buttons")]
|
|||
|
|
[SerializeField] private Button resetButton;
|
|||
|
|
[SerializeField] private Button backButton;
|
|||
|
|
|
|||
|
|
// ──────────────────── 运行时状态 ──────────────────
|
|||
|
|
|
|||
|
|
private readonly List<SettingsEntryBase> activeFieldEntries = new();
|
|||
|
|
private readonly List<SettingsEntryButton> activeButtonEntries = new();
|
|||
|
|
private readonly List<GameObject> spawnedObjects = new();
|
|||
|
|
|
|||
|
|
/// <summary>是否有详情页面处于打开状态(抑制悬停说明)。</summary>
|
|||
|
|
private bool isDetailPageOpen;
|
|||
|
|
|
|||
|
|
// ──────────────────── 分类定义 ────────────────────
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 分类定义:显示名称、设置实例、Apply/Reset 回调、附加按钮列表。
|
|||
|
|
/// </summary>
|
|||
|
|
private struct SectionDefinition
|
|||
|
|
{
|
|||
|
|
public string displayName;
|
|||
|
|
public object settingsInstance;
|
|||
|
|
public Action applyAction;
|
|||
|
|
public Action resetAction;
|
|||
|
|
public List<ButtonDefinition> buttons;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 手动添加的按钮条目定义。
|
|||
|
|
/// </summary>
|
|||
|
|
private struct ButtonDefinition
|
|||
|
|
{
|
|||
|
|
public string label;
|
|||
|
|
public string description;
|
|||
|
|
public Action onClick;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 生命周期 ────────────────────
|
|||
|
|
|
|||
|
|
protected override void Start()
|
|||
|
|
{
|
|||
|
|
base.Start();
|
|||
|
|
|
|||
|
|
resetButton?.onClick.AddListener(OnResetClicked);
|
|||
|
|
backButton?.onClick.AddListener(OnBackClicked);
|
|||
|
|
|
|||
|
|
// 监听详情页面的开关状态
|
|||
|
|
if (keyBindingPage != null)
|
|||
|
|
{
|
|||
|
|
keyBindingPage.PageOpened += OnDetailPageOpened;
|
|||
|
|
keyBindingPage.PageClosed += OnDetailPageClosed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
protected override void OnPageOpened()
|
|||
|
|
{
|
|||
|
|
BuildAllSections();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
protected override void OnPageClosed()
|
|||
|
|
{
|
|||
|
|
// 先关闭可能打开的详情页面
|
|||
|
|
if (isDetailPageOpen && keyBindingPage != null && keyBindingPage.IsOpen)
|
|||
|
|
keyBindingPage.Close();
|
|||
|
|
|
|||
|
|
ClearAll();
|
|||
|
|
descriptionPanel?.Hide();
|
|||
|
|
GameSettingsManager.Instance?.Save();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 详情页面状态 ────────────────
|
|||
|
|
|
|||
|
|
private void OnDetailPageOpened()
|
|||
|
|
{
|
|||
|
|
isDetailPageOpen = true;
|
|||
|
|
descriptionPanel?.Hide();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void OnDetailPageClosed()
|
|||
|
|
{
|
|||
|
|
isDetailPageOpen = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 悬停回调 ────────────────────
|
|||
|
|
|
|||
|
|
private void HandleHoverEnter(string title, string description)
|
|||
|
|
{
|
|||
|
|
if (isDetailPageOpen) return;
|
|||
|
|
descriptionPanel?.Show(title, description);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void HandleHoverExit()
|
|||
|
|
{
|
|||
|
|
if (isDetailPageOpen) return;
|
|||
|
|
descriptionPanel?.Hide();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 分类定义构建 ────────────────
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 构建所有分类定义。扩展时在此处追加。
|
|||
|
|
/// </summary>
|
|||
|
|
private List<SectionDefinition> BuildSectionDefinitions()
|
|||
|
|
{
|
|||
|
|
var manager = GameSettingsManager.Instance;
|
|||
|
|
if (manager == null) return null;
|
|||
|
|
|
|||
|
|
return new List<SectionDefinition>
|
|||
|
|
{
|
|||
|
|
new()
|
|||
|
|
{
|
|||
|
|
displayName = "Gameplay",
|
|||
|
|
settingsInstance = manager.gameplay,
|
|||
|
|
applyAction = manager.ApplyGameplay,
|
|||
|
|
resetAction = manager.ResetGameplayToDefault
|
|||
|
|
},
|
|||
|
|
new()
|
|||
|
|
{
|
|||
|
|
displayName = "Graphics",
|
|||
|
|
settingsInstance = manager.graphics,
|
|||
|
|
applyAction = manager.ApplyGraphics,
|
|||
|
|
resetAction = manager.ResetGraphicsToDefault
|
|||
|
|
},
|
|||
|
|
new()
|
|||
|
|
{
|
|||
|
|
displayName = "Sound",
|
|||
|
|
settingsInstance = manager.sound,
|
|||
|
|
applyAction = manager.ApplySound,
|
|||
|
|
resetAction = manager.ResetSoundToDefault
|
|||
|
|
},
|
|||
|
|
new()
|
|||
|
|
{
|
|||
|
|
displayName = "Controls",
|
|||
|
|
settingsInstance = manager.controls,
|
|||
|
|
applyAction = manager.ApplyControls,
|
|||
|
|
resetAction = manager.ResetControlsToDefault,
|
|||
|
|
buttons = new List<ButtonDefinition>
|
|||
|
|
{
|
|||
|
|
new()
|
|||
|
|
{
|
|||
|
|
label = "Key Bindings",
|
|||
|
|
description = "Customize keyboard and mouse bindings for all game actions.",
|
|||
|
|
onClick = OpenKeyBindingPage
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 条目生成 ────────────────────
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 构建所有分类的标题、字段条目和按钮条目。
|
|||
|
|
/// </summary>
|
|||
|
|
private void BuildAllSections()
|
|||
|
|
{
|
|||
|
|
ClearAll();
|
|||
|
|
|
|||
|
|
var sections = BuildSectionDefinitions();
|
|||
|
|
if (sections == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogError("[SettingsUIPage] GameSettingsManager not found.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var section in sections)
|
|||
|
|
{
|
|||
|
|
BuildSection(section);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 构建单个分类的标题、字段条目和按钮条目。
|
|||
|
|
/// </summary>
|
|||
|
|
private void BuildSection(SectionDefinition section)
|
|||
|
|
{
|
|||
|
|
FieldInfo[] fields = section.settingsInstance.GetType()
|
|||
|
|
.GetFields(BindingFlags.Public | BindingFlags.Instance);
|
|||
|
|
|
|||
|
|
// 预扫描:确认该分类至少有一个可显示的字段或按钮
|
|||
|
|
bool hasVisibleContent = section.buttons is { Count: > 0 };
|
|||
|
|
|
|||
|
|
if (!hasVisibleContent)
|
|||
|
|
{
|
|||
|
|
foreach (FieldInfo field in fields)
|
|||
|
|
{
|
|||
|
|
if (field.GetCustomAttribute<SettingsIgnoreAttribute>() == null
|
|||
|
|
&& ResolvePrefab(field) != null)
|
|||
|
|
{
|
|||
|
|
hasVisibleContent = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!hasVisibleContent) return;
|
|||
|
|
|
|||
|
|
// 插入分类标题
|
|||
|
|
SpawnSectionHeader(section.displayName);
|
|||
|
|
|
|||
|
|
// 生成字段条目
|
|||
|
|
Action applyAction = section.applyAction;
|
|||
|
|
|
|||
|
|
foreach (FieldInfo field in fields)
|
|||
|
|
{
|
|||
|
|
if (field.GetCustomAttribute<SettingsIgnoreAttribute>() != null)
|
|||
|
|
continue;
|
|||
|
|
|
|||
|
|
GameObject prefab = ResolvePrefab(field);
|
|||
|
|
if (prefab == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning(
|
|||
|
|
$"[SettingsUIPage] No matching prefab for field '{field.Name}' " +
|
|||
|
|
$"(type: {field.FieldType.Name}). Skipping.");
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
GameObject entryObj = Instantiate(prefab, contentContainer);
|
|||
|
|
var entry = entryObj.GetComponent<SettingsEntryBase>();
|
|||
|
|
if (entry == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogError(
|
|||
|
|
$"[SettingsUIPage] Prefab for field '{field.Name}' " +
|
|||
|
|
"is missing a SettingsEntryBase component.");
|
|||
|
|
Destroy(entryObj);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
entry.Initialize(section.settingsInstance, field, () => applyAction?.Invoke());
|
|||
|
|
RegisterFieldEntryHover(entry);
|
|||
|
|
activeFieldEntries.Add(entry);
|
|||
|
|
spawnedObjects.Add(entryObj);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成按钮条目
|
|||
|
|
if (section.buttons != null)
|
|||
|
|
{
|
|||
|
|
foreach (var btnDef in section.buttons)
|
|||
|
|
{
|
|||
|
|
SpawnButtonEntry(btnDef);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 为字段条目注册悬停回调。
|
|||
|
|
/// </summary>
|
|||
|
|
private void RegisterFieldEntryHover(SettingsEntryBase entry)
|
|||
|
|
{
|
|||
|
|
entry.OnHoverEnter = HandleHoverEnter;
|
|||
|
|
entry.OnHoverExit = HandleHoverExit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 实例化按钮条目。
|
|||
|
|
/// </summary>
|
|||
|
|
private void SpawnButtonEntry(ButtonDefinition def)
|
|||
|
|
{
|
|||
|
|
if (buttonEntryPrefab == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("[SettingsUIPage] buttonEntryPrefab is not assigned.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
GameObject entryObj = Instantiate(buttonEntryPrefab, contentContainer);
|
|||
|
|
var buttonEntry = entryObj.GetComponent<SettingsEntryButton>();
|
|||
|
|
if (buttonEntry == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogError("[SettingsUIPage] buttonEntryPrefab is missing SettingsEntryButton.");
|
|||
|
|
Destroy(entryObj);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
buttonEntry.Initialize(def.label, def.description, def.onClick);
|
|||
|
|
buttonEntry.OnHoverEnter = HandleHoverEnter;
|
|||
|
|
buttonEntry.OnHoverExit = HandleHoverExit;
|
|||
|
|
activeButtonEntries.Add(buttonEntry);
|
|||
|
|
spawnedObjects.Add(entryObj);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 实例化分类标题预制体。
|
|||
|
|
/// </summary>
|
|||
|
|
private void SpawnSectionHeader(string title)
|
|||
|
|
{
|
|||
|
|
if (sectionHeaderPrefab == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("[SettingsUIPage] sectionHeaderPrefab is not assigned.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
GameObject headerObj = Instantiate(sectionHeaderPrefab, contentContainer);
|
|||
|
|
var headerText = headerObj.GetComponentInChildren<TMP_Text>();
|
|||
|
|
if (headerText != null)
|
|||
|
|
headerText.text = title;
|
|||
|
|
|
|||
|
|
spawnedObjects.Add(headerObj);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 根据字段类型选择对应的预制体。
|
|||
|
|
/// </summary>
|
|||
|
|
private GameObject ResolvePrefab(FieldInfo field)
|
|||
|
|
{
|
|||
|
|
Type fieldType = field.FieldType;
|
|||
|
|
|
|||
|
|
if (fieldType == typeof(bool))
|
|||
|
|
return toggleEntryPrefab;
|
|||
|
|
|
|||
|
|
if (fieldType == typeof(int))
|
|||
|
|
return sliderEntryPrefab;
|
|||
|
|
|
|||
|
|
if (fieldType.IsEnum)
|
|||
|
|
return dropdownEntryPrefab;
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 详情页面入口 ────────────────
|
|||
|
|
|
|||
|
|
private void OpenKeyBindingPage()
|
|||
|
|
{
|
|||
|
|
if (keyBindingPage == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("[SettingsUIPage] keyBindingPage is not assigned.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 优先使用配置的 InputActionAsset,否则尝试从 Player 获取
|
|||
|
|
InputActionAsset asset = inputActionAsset;
|
|||
|
|
if (asset == null)
|
|||
|
|
{
|
|||
|
|
var player = MainGameManager.Player;
|
|||
|
|
if (player != null)
|
|||
|
|
asset = player.inputSc.inputActions.asset;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (asset != null)
|
|||
|
|
{
|
|||
|
|
keyBindingPage.Open(asset);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
Debug.LogError("[SettingsUIPage] No InputActionAsset available for KeyBindingPage.");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 底部按钮回调 ────────────────
|
|||
|
|
|
|||
|
|
private void OnResetClicked()
|
|||
|
|
{
|
|||
|
|
GameSettingsManager.Instance?.ResetAllToDefault();
|
|||
|
|
BuildAllSections();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void OnBackClicked()
|
|||
|
|
{
|
|||
|
|
Close();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ──────────────────── 清理 ────────────────────────
|
|||
|
|
|
|||
|
|
private void ClearAll()
|
|||
|
|
{
|
|||
|
|
foreach (var obj in spawnedObjects)
|
|||
|
|
{
|
|||
|
|
if (obj != null)
|
|||
|
|
Destroy(obj);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
activeFieldEntries.Clear();
|
|||
|
|
activeButtonEntries.Clear();
|
|||
|
|
spawnedObjects.Clear();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void OnDestroy()
|
|||
|
|
{
|
|||
|
|
if (keyBindingPage != null)
|
|||
|
|
{
|
|||
|
|
keyBindingPage.PageOpened -= OnDetailPageOpened;
|
|||
|
|
keyBindingPage.PageClosed -= OnDetailPageClosed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|