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;
|
||
}
|
||
}
|
||
}
|
||
}
|