Passion & UI

This commit is contained in:
SoulliesOfficial
2026-06-12 17:11:39 -04:00
parent 7bc1e1722c
commit 6d7ebc5825
3444 changed files with 865284 additions and 463132 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 991b62089abf73a419dee4edff972f3c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,28 @@
using System;
namespace Cielonos.Settings
{
/// <summary>
/// 标记设置字段在 UI 中显示的本地化键、说明键和表名。
/// 若未标记,使用字段名经格式化后的可读文本作为回退。
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class SettingsDisplayAttribute : Attribute
{
/// <summary>Unity Localization StringTable 中的条目键(标签)。</summary>
public string LabelKey { get; }
/// <summary>Unity Localization StringTable 中的条目键(说明文本,可选)。</summary>
public string DescriptionKey { get; }
/// <summary>Unity Localization StringTable 名称。默认为 "Settings"。</summary>
public string TableName { get; }
public SettingsDisplayAttribute(string labelKey, string descriptionKey = null, string tableName = "Settings")
{
LabelKey = labelKey;
DescriptionKey = descriptionKey;
TableName = tableName;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 17a87303651f74241973f1139fd3035e

View File

@@ -0,0 +1,11 @@
using System;
namespace Cielonos.Settings
{
/// <summary>
/// 标记设置字段不参与 UI 自动生成。
/// 用于需要自定义 UI 处理的字段(如 locale、bindingOverridesJson
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class SettingsIgnoreAttribute : Attribute { }
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 394f9b8da41f7584ca5f2c863476767a

View File

@@ -0,0 +1,86 @@
using System;
using UnityEngine;
namespace Cielonos.Settings
{
/// <summary>
/// Gameplay 相关设置语言、相机、HUD 显示等。
/// </summary>
[Serializable]
public class GameplaySettings
{
[SettingsIgnore]
public string locale = "zh-CN";
[Range(10f, 100f)]
public int cameraSensitivityX = 50;
[Range(10f, 100f)]
public int cameraSensitivityY = 50;
public bool invertYAxis = false;
public bool showHUD = true;
public bool showDamageNumbers = true;
public bool showFPS = false;
}
/// <summary>
/// 画面设置:分辨率、画质、帧率等。
/// </summary>
[Serializable]
public class GraphicsSettings
{
/// <summary>分辨率宽度0 表示使用当前原生分辨率。</summary>
public int resolutionWidth = 0;
/// <summary>分辨率高度0 表示使用当前原生分辨率。</summary>
public int resolutionHeight = 0;
public FullScreenMode fullscreenMode = FullScreenMode.FullScreenWindow;
/// <summary>画质预设索引,-1 表示使用项目默认值。</summary>
[Range(1, 4)]
public int qualityLevel = 4;
public bool vSync = true;
[Range(10f, 240f)]
public int targetFrameRate = 120;
[Range(10f, 100f)]
public int brightness = 50;
public bool postProcessing = true;
}
/// <summary>
/// 音频设置:各通道音量,映射到 Wwise RTPC。
/// </summary>
[Serializable]
public class SoundSettings
{
[Range(0f, 100f)]
public int masterVolume = 50;
[Range(0f, 100f)]
public int musicVolume = 50;
[Range(0f, 100f)]
public int sfxVolume = 50;
}
/// <summary>
/// 操控设置:按键绑定覆盖数据。
/// <para>
/// <see cref="bindingOverridesJson"/> 存储
/// <c>InputActionAsset.SaveBindingOverridesAsJson()</c> 的输出,
/// 由 <c>PlayerInputSubcontroller</c> 在初始化时读取并应用。
/// </para>
/// </summary>
[Serializable]
public class ControlsSettings
{
[SettingsIgnore]
public string bindingOverridesJson = "";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2388382ffecb474d8ae852702a6e463

View File

@@ -0,0 +1,290 @@
using System;
using Cielonos.MainGame.UI;
using SLSUtilities.General;
using SLSUtilities.UI;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
using UnityEngine.Localization.Settings;
namespace Cielonos.Settings
{
/// <summary>
/// 游戏设置的 Singleton 管理器。
/// <para>
/// <b>数据流:</b><br/>
/// 字段初始化器提供编译期默认值 →
/// <see cref="Awake"/> 中通过 ES3 加载磁盘数据覆盖 →
/// <see cref="Start"/> 中调用 <see cref="ApplyAll"/> 推送到各子系统。
/// </para>
/// <para>
/// <b>扩展方式:</b>直接在对应 Settings 类(<see cref="GameplaySettings"/>
/// / <see cref="GraphicsSettings"/> / <see cref="SoundSettings"/>
/// / <see cref="ControlsSettings"/>)中添加带默认值的新字段。
/// ES3 反序列化时,旧存档缺失的字段自动获取字段初始化器的默认值。
/// 然后在对应的 Apply 方法中添加新字段的应用逻辑即可。
/// </para>
/// </summary>
public class GameSettingsManager : Singleton<GameSettingsManager>
{
// ──────────────────────── 持久化常量 ────────────────────────
private const string SaveFileName = "GameSettings.es3";
private const string KeyGameplay = "gameplay";
private const string KeyGraphics = "graphics";
private const string KeySound = "sound";
private const string KeyControls = "controls";
// ──────────────────────── Wwise RTPC 名称 ──────────────────
private const string RtpcMasterVolume = "MasterVolume";
private const string RtpcMusicVolume = "MusicVolume";
private const string RtpcSfxVolume = "SFXVolume";
// ──────────────────────── 运行时数据 ───────────────────────
public GameplaySettings gameplay = new();
public GraphicsSettings graphics = new();
public SoundSettings sound = new();
public ControlsSettings controls = new();
// ──────────────────────── 事件 ─────────────────────────────
/// <summary>Gameplay 设置被应用后触发。</summary>
public static event Action OnGameplaySettingsApplied;
/// <summary>Graphics 设置被应用后触发。</summary>
public static event Action OnGraphicsSettingsApplied;
/// <summary>Sound 设置被应用后触发。</summary>
public static event Action OnSoundSettingsApplied;
/// <summary>Controls 设置被应用后触发。</summary>
public static event Action OnControlsSettingsApplied;
// ──────────────────────── 生命周期 ─────────────────────────
protected override void Awake()
{
base.Awake();
Load();
}
private void Start()
{
ApplyAll();
}
// ──────────────────────── 持久化 ───────────────────────────
/// <summary>
/// 将所有设置分类保存到磁盘。每个分类使用独立的 ES3 Key
/// 某个分类数据损坏时不影响其他分类。
/// </summary>
public void Save()
{
try
{
ES3.Save(KeyGameplay, gameplay, SaveFileName);
ES3.Save(KeyGraphics, graphics, SaveFileName);
ES3.Save(KeySound, sound, SaveFileName);
ES3.Save(KeyControls, controls, SaveFileName);
Debug.Log("[GameSettingsManager] Settings saved.");
}
catch (Exception e)
{
Debug.LogError($"[GameSettingsManager] Save failed: {e.Message}");
}
}
/// <summary>
/// 从磁盘加载设置。每个分类独立加载,某个分类缺失或损坏时
/// 保留字段初始化器的默认值。
/// </summary>
public void Load()
{
try
{
if (!ES3.FileExists(SaveFileName)) return;
if (ES3.KeyExists(KeyGameplay, SaveFileName))
gameplay = ES3.Load<GameplaySettings>(KeyGameplay, SaveFileName);
if (ES3.KeyExists(KeyGraphics, SaveFileName))
graphics = ES3.Load<GraphicsSettings>(KeyGraphics, SaveFileName);
if (ES3.KeyExists(KeySound, SaveFileName))
sound = ES3.Load<SoundSettings>(KeySound, SaveFileName);
if (ES3.KeyExists(KeyControls, SaveFileName))
controls = ES3.Load<ControlsSettings>(KeyControls, SaveFileName);
}
catch (Exception e)
{
Debug.LogError($"[GameSettingsManager] Load failed: {e.Message}");
}
}
/// <summary>
/// 删除设置存档文件。
/// </summary>
public void DeleteSaveFile()
{
try
{
if (ES3.FileExists(SaveFileName))
{
ES3.DeleteFile(SaveFileName);
Debug.Log("[GameSettingsManager] Save file deleted.");
}
}
catch (Exception e)
{
Debug.LogError($"[GameSettingsManager] Delete save failed: {e.Message}");
}
}
// ──────────────────────── Apply ────────────────────────────
/// <summary>
/// 应用所有分类的设置到对应系统。
/// </summary>
public void ApplyAll()
{
ApplyGameplay();
ApplyGraphics();
ApplySound();
ApplyControls();
}
/// <summary>
/// 应用 Gameplay 设置。
/// </summary>
public void ApplyGameplay()
{
// Locale
var availableLocales = LocalizationSettings.AvailableLocales;
if (availableLocales != null)
{
var locale = availableLocales.GetLocale(gameplay.locale);
if (locale != null)
LocalizationSettings.SelectedLocale = locale;
}
// FPS 显示
if (PlayerCanvas.Instance != null)
PlayerCanvas.Instance.frameRateText.gameObject.SetActive(gameplay.showFPS);
OnGameplaySettingsApplied?.Invoke();
}
/// <summary>
/// 应用 Graphics 设置。
/// </summary>
public void ApplyGraphics()
{
// 分辨率与全屏模式
if (graphics.resolutionWidth > 0 && graphics.resolutionHeight > 0)
{
Screen.SetResolution(
graphics.resolutionWidth,
graphics.resolutionHeight,
graphics.fullscreenMode
);
}
else
{
Screen.fullScreenMode = graphics.fullscreenMode;
}
// 画质预设
if (graphics.qualityLevel >= 0)
QualitySettings.SetQualityLevel(graphics.qualityLevel, applyExpensiveChanges: true);
// VSync
QualitySettings.vSyncCount = graphics.vSync ? 1 : 0;
// 帧率上限
Application.targetFrameRate = graphics.targetFrameRate;
OnGraphicsSettingsApplied?.Invoke();
}
/// <summary>
/// 应用 Sound 设置(通过 Wwise RTPC
/// </summary>
public void ApplySound()
{
if (AudioManager.Instance == null) return;
AudioManager.Instance.SetRTPC(RtpcMasterVolume, sound.masterVolume);
AudioManager.Instance.SetRTPC(RtpcMusicVolume, sound.musicVolume);
AudioManager.Instance.SetRTPC(RtpcSfxVolume, sound.sfxVolume);
OnSoundSettingsApplied?.Invoke();
}
/// <summary>
/// 应用 Controls 设置。
/// <para>
/// 按键绑定覆盖由 <c>PlayerInputSubcontroller</c> 消费:
/// 在其 <c>Initialize()</c> 中读取 <see cref="controls"/>.<see cref="ControlsSettings.bindingOverridesJson"/>
/// 并调用 <c>InputActionAsset.LoadBindingOverridesFromJson()</c>
/// 然后重新初始化 <see cref="InputBindingResolver"/>。
/// </para>
/// </summary>
public void ApplyControls()
{
OnControlsSettingsApplied?.Invoke();
}
// ──────────────────────── Reset ────────────────────────────
/// <summary>
/// 重置所有设置为默认值并立即应用。
/// </summary>
public void ResetAllToDefault()
{
gameplay = new GameplaySettings();
graphics = new GraphicsSettings();
sound = new SoundSettings();
controls = new ControlsSettings();
ApplyAll();
}
/// <summary>
/// 重置 Gameplay 设置为默认值并立即应用。
/// </summary>
public void ResetGameplayToDefault()
{
gameplay = new GameplaySettings();
ApplyGameplay();
}
/// <summary>
/// 重置 Graphics 设置为默认值并立即应用。
/// </summary>
public void ResetGraphicsToDefault()
{
graphics = new GraphicsSettings();
ApplyGraphics();
}
/// <summary>
/// 重置 Sound 设置为默认值并立即应用。
/// </summary>
public void ResetSoundToDefault()
{
sound = new SoundSettings();
ApplySound();
}
/// <summary>
/// 重置 Controls 设置为默认值并立即应用。
/// </summary>
public void ResetControlsToDefault()
{
controls = new ControlsSettings();
ApplyControls();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 68098320cfc2633408ccadda585abd83

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4fb5c1d6db2728f4a99a7b36073d0924
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using Cielonos.Settings;
using SLSUtilities.UI;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace Cielonos.Settings.UI
{
/// <summary>
/// 键位绑定二级 UIPage。
/// <para>
/// 从 <see cref="InputActionAsset"/> 读取所有可绑定的 Action
/// 为每个 Action 生成一行 <see cref="SettingsEntryKeyBinding"/>。
/// Composite 类型的 Action如 WASD Move的每个 part 单独显示一行。
/// </para>
/// <para>
/// 此页面是 <see cref="UIPageBase"/>需手动关闭Back 按钮或 ESC
/// 关闭时自动将绑定覆盖保存到 <see cref="ControlsSettings.bindingOverridesJson"/>。
/// </para>
/// </summary>
public class KeyBindingPage : UIPageBase
{
[Header("Prefabs")]
[Tooltip("键位绑定条目预制体,需挂载 SettingsEntryKeyBinding。")]
[SerializeField] private GameObject keyBindingEntryPrefab;
[Tooltip("分组标题预制体(可选),用于分隔不同 ActionMap。")]
[SerializeField] private GameObject sectionHeaderPrefab;
[Header("Content")]
[Tooltip("条目的父容器,应挂载 VerticalLayoutGroup。")]
[SerializeField] private RectTransform contentContainer;
[Header("Buttons")]
[SerializeField] private Button resetAllButton;
[SerializeField] private Button backButton;
[Header("Input")]
[Tooltip("要显示的 InputActionAsset。为空时从 GameSettingsManager 上下文获取。")]
[SerializeField] private InputActionAsset inputActionAsset;
[Tooltip("要显示的 Control Scheme 名称。")]
[SerializeField] private string controlScheme = "KeyboardMouse";
private readonly List<SettingsEntryKeyBinding> activeEntries = new();
private readonly List<GameObject> spawnedObjects = new();
/// <summary>当任何绑定变更时触发(供 SettingsUIPage 监听)。</summary>
public event Action OnAnyBindingChanged;
// ──────────────────── 生命周期 ────────────────────
protected override void Start()
{
base.Start();
resetAllButton?.onClick.AddListener(OnResetAllClicked);
backButton?.onClick.AddListener(OnBackClicked);
}
/// <summary>
/// 使用指定的 InputActionAsset 打开键位绑定页面。
/// </summary>
public void Open(InputActionAsset asset)
{
inputActionAsset = asset;
Open();
}
protected override void OnPageOpened()
{
BuildEntries();
}
protected override void OnPageClosed()
{
SaveBindingOverrides();
ClearAll();
}
// ──────────────────── 条目生成 ────────────────────
private void BuildEntries()
{
ClearAll();
if (inputActionAsset == null)
{
Debug.LogError("[KeyBindingPage] InputActionAsset is not assigned.");
return;
}
foreach (InputActionMap map in inputActionAsset.actionMaps)
{
bool hasBindableAction = false;
foreach (InputAction action in map.actions)
{
if (HasBindableBindings(action))
{
hasBindableAction = true;
break;
}
}
if (!hasBindableAction) continue;
// 插入 ActionMap 标题
SpawnSectionHeader(FormatMapName(map.name));
foreach (InputAction action in map.actions)
{
SpawnEntriesForAction(action);
}
}
}
/// <summary>
/// 为一个 Action 生成绑定条目。
/// Composite binding 的每个 part 生成独立条目;普通 binding 生成一个条目。
/// </summary>
private void SpawnEntriesForAction(InputAction action)
{
var bindings = action.bindings;
for (int i = 0; i < bindings.Count; i++)
{
InputBinding binding = bindings[i];
// 跳过不匹配 controlScheme 的绑定
if (!IsBindingMatchingScheme(binding))
continue;
if (binding.isComposite)
{
// composite 的 header不生成条目但后续 parts 会单独生成
continue;
}
if (binding.isPartOfComposite)
{
// composite 的一个 part显示为 "ActionName - PartName"
string displayName = $"{FormatActionName(action.name)} - {FormatActionName(binding.name)}";
SpawnKeyBindingEntry(action, i, displayName);
}
else
{
// 普通(非 compositebinding
SpawnKeyBindingEntry(action, i, FormatActionName(action.name));
}
}
}
private void SpawnKeyBindingEntry(InputAction action, int bindingIndex, string displayName)
{
if (keyBindingEntryPrefab == null) return;
GameObject entryObj = Instantiate(keyBindingEntryPrefab, contentContainer);
var entry = entryObj.GetComponent<SettingsEntryKeyBinding>();
if (entry == null)
{
Debug.LogError("[KeyBindingPage] keyBindingEntryPrefab is missing SettingsEntryKeyBinding.");
Destroy(entryObj);
return;
}
entry.Initialize(action, bindingIndex, displayName);
entry.OnBindingChanged += OnEntryBindingChanged;
activeEntries.Add(entry);
spawnedObjects.Add(entryObj);
}
private void SpawnSectionHeader(string title)
{
if (sectionHeaderPrefab == null) return;
GameObject headerObj = Instantiate(sectionHeaderPrefab, contentContainer);
var headerText = headerObj.GetComponentInChildren<TMPro.TMP_Text>();
if (headerText != null)
headerText.text = title;
spawnedObjects.Add(headerObj);
}
// ──────────────────── 绑定变更 ────────────────────
private void OnEntryBindingChanged()
{
OnAnyBindingChanged?.Invoke();
}
/// <summary>
/// 将当前绑定覆盖序列化到 <see cref="ControlsSettings.bindingOverridesJson"/>
/// 并重新初始化 <see cref="InputBindingResolver"/>。
/// </summary>
private void SaveBindingOverrides()
{
if (inputActionAsset == null) return;
var manager = GameSettingsManager.Instance;
if (manager != null)
{
manager.controls.bindingOverridesJson = inputActionAsset.SaveBindingOverridesAsJson();
manager.Save();
}
// 重新初始化 glyph 解析器以反映新的绑定
InputBindingResolver.Initialize(inputActionAsset, controlScheme);
}
// ──────────────────── 按钮回调 ────────────────────
private void OnResetAllClicked()
{
if (inputActionAsset == null) return;
inputActionAsset.RemoveAllBindingOverrides();
// 重建条目以刷新显示
BuildEntries();
OnAnyBindingChanged?.Invoke();
}
private void OnBackClicked()
{
Close();
}
// ──────────────────── 工具方法 ────────────────────
private bool HasBindableBindings(InputAction action)
{
foreach (InputBinding binding in action.bindings)
{
if (binding.isComposite) continue;
if (IsBindingMatchingScheme(binding))
return true;
}
return false;
}
private bool IsBindingMatchingScheme(InputBinding binding)
{
if (string.IsNullOrEmpty(controlScheme)) return true;
if (string.IsNullOrEmpty(binding.groups)) return false;
return binding.groups.Contains(controlScheme);
}
private static string FormatActionName(string name)
{
if (string.IsNullOrEmpty(name)) return name;
var sb = new System.Text.StringBuilder(name.Length + 4);
sb.Append(char.ToUpper(name[0]));
for (int i = 1; i < name.Length; i++)
{
char c = name[i];
if (char.IsUpper(c) && i > 0 && char.IsLower(name[i - 1]))
sb.Append(' ');
sb.Append(c);
}
return sb.ToString();
}
private static string FormatMapName(string mapName)
{
return FormatActionName(mapName);
}
// ──────────────────── 清理 ────────────────────────
private void ClearAll()
{
foreach (var entry in activeEntries)
{
if (entry != null)
entry.OnBindingChanged -= OnEntryBindingChanged;
}
activeEntries.Clear();
foreach (var obj in spawnedObjects)
{
if (obj != null)
Destroy(obj);
}
spawnedObjects.Clear();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8af1f846125623c4fb1f91199e44e9d3

View File

@@ -0,0 +1,71 @@
using TMPro;
using UnityEngine;
namespace Cielonos.Settings.UI
{
/// <summary>
/// 设置界面右侧的悬停说明面板。
/// <para>
/// 当鼠标悬停到某个 SettingsEntry 时显示该条目的标题和说明文本,
/// 鼠标离开后隐藏。此面板不是 <see cref="SLSUtilities.UI.UIPageBase"/>
/// 按 ESC 时由设置界面整体处理退出逻辑。
/// </para>
/// </summary>
public class SettingsDescriptionPanel : MonoBehaviour
{
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text descriptionText;
[SerializeField] private CanvasGroup canvasGroup;
private void Awake()
{
if (canvasGroup == null)
canvasGroup = GetComponent<CanvasGroup>();
Hide();
}
/// <summary>
/// 显示说明面板。如果 description 为空则不显示。
/// </summary>
/// <param name="title">条目标题。</param>
/// <param name="description">条目说明文本。</param>
public void Show(string title, string description)
{
if (string.IsNullOrEmpty(description))
{
Hide();
return;
}
if (titleText != null)
titleText.text = title;
if (descriptionText != null)
descriptionText.text = description;
SetVisible(true);
}
/// <summary>
/// 隐藏说明面板。
/// </summary>
public void Hide()
{
SetVisible(false);
}
private void SetVisible(bool visible)
{
if (canvasGroup != null)
{
canvasGroup.alpha = visible ? 1f : 0f;
canvasGroup.blocksRaycasts = visible;
}
else
{
gameObject.SetActive(visible);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b58dfa318609de049af79505f0b92652

View File

@@ -0,0 +1,160 @@
using System;
using System.Reflection;
using System.Text;
using SLSUtilities.General;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
namespace Cielonos.Settings.UI
{
/// <summary>
/// 设置条目 UI 的抽象基类。
/// <para>
/// 持有对设置实例和字段的反射引用,子类负责具体的 UI 交互逻辑。
/// 通过 <see cref="Initialize"/> 绑定到指定设置实例的字段后,
/// 子类在 <see cref="SetupUI"/> 中配置 UI 组件,
/// 在 <see cref="RefreshValue"/> 中同步字段值到 UI 显示。
/// </para>
/// <para>
/// 支持鼠标悬停时通过 <see cref="OnHoverEnter"/> / <see cref="OnHoverExit"/>
/// 通知父级显示/隐藏右侧说明面板。
/// </para>
/// </summary>
public abstract class SettingsEntryBase : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
[SerializeField] protected TMP_Text labelText;
protected object settingsInstance;
protected FieldInfo fieldInfo;
protected Action onValueChanged;
/// <summary>条目的显示标题。</summary>
public string DisplayLabel { get; protected set; }
/// <summary>条目的说明文本(可为空,为空时悬停不触发说明面板)。</summary>
public string Description { get; protected set; }
/// <summary>鼠标悬停进入时触发,参数为 (标题, 说明文本)。由 SettingsUIPage 设置。</summary>
public Action<string, string> OnHoverEnter { get; set; }
/// <summary>鼠标悬停离开时触发。由 SettingsUIPage 设置。</summary>
public Action OnHoverExit { get; set; }
/// <summary>
/// 初始化设置条目,绑定到指定设置实例的字段。
/// </summary>
/// <param name="instance">设置类实例(如 GameplaySettings。</param>
/// <param name="field">要绑定的字段反射信息。</param>
/// <param name="onChanged">字段值变更时的回调。</param>
public virtual void Initialize(object instance, FieldInfo field, Action onChanged)
{
settingsInstance = instance;
fieldInfo = field;
onValueChanged = onChanged;
// 优先使用 SettingsDisplayAttribute 提供的本地化键
var displayAttr = field.GetCustomAttribute<SettingsDisplayAttribute>();
if (displayAttr != null)
{
string localized = displayAttr.LabelKey.Localize(displayAttr.TableName);
DisplayLabel = !string.IsNullOrEmpty(localized) ? localized : displayAttr.LabelKey;
if (!string.IsNullOrEmpty(displayAttr.DescriptionKey))
{
string localizedDesc = displayAttr.DescriptionKey.Localize(displayAttr.TableName);
Description = !string.IsNullOrEmpty(localizedDesc)
? localizedDesc
: displayAttr.DescriptionKey;
}
}
else
{
DisplayLabel = FormatFieldName(field.Name);
}
SetLabel(DisplayLabel);
SetupUI();
RefreshValue();
}
/// <summary>
/// 子类实现:配置 UI 组件(如 Slider 的范围、Dropdown 的选项)。
/// 在 <see cref="Initialize"/> 中于 <see cref="RefreshValue"/> 之前调用。
/// </summary>
protected abstract void SetupUI();
/// <summary>
/// 子类实现:从字段读取当前值并更新 UI 显示。
/// </summary>
public abstract void RefreshValue();
/// <summary>
/// 将值写回设置字段,并通知外部值已变更。
/// </summary>
protected void SetFieldValue(object value)
{
fieldInfo.SetValue(settingsInstance, value);
onValueChanged?.Invoke();
}
/// <summary>
/// 从设置字段读取当前值。
/// </summary>
protected object GetFieldValue()
{
return fieldInfo.GetValue(settingsInstance);
}
protected void SetLabel(string text)
{
if (labelText != null)
labelText.text = text;
}
// ──────────────────── 悬停事件 ────────────────────
public void OnPointerEnter(PointerEventData eventData)
{
OnHoverEnter?.Invoke(DisplayLabel, Description);
}
public void OnPointerExit(PointerEventData eventData)
{
OnHoverExit?.Invoke();
}
// ──────────────────── 工具方法 ────────────────────
/// <summary>
/// 将 camelCase 字段名转换为可读格式。
/// <para>
/// <c>cameraSensitivityX</c> → "Camera Sensitivity X"<br/>
/// <c>showFPS</c> → "Show FPS"<br/>
/// <c>invertYAxis</c> → "Invert Y Axis"
/// </para>
/// </summary>
protected static string FormatFieldName(string fieldName)
{
if (string.IsNullOrEmpty(fieldName)) return fieldName;
var sb = new StringBuilder(fieldName.Length + 4);
sb.Append(char.ToUpper(fieldName[0]));
for (int i = 1; i < fieldName.Length; i++)
{
char c = fieldName[i];
if (char.IsUpper(c))
{
bool prevIsLower = char.IsLower(fieldName[i - 1]);
bool nextIsLower = i + 1 < fieldName.Length && char.IsLower(fieldName[i + 1]);
if (prevIsLower || nextIsLower)
sb.Append(' ');
}
sb.Append(c);
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9a7cf7f80a133a44cb243de940a1510e

View File

@@ -0,0 +1,77 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Cielonos.Settings.UI
{
/// <summary>
/// 按钮型设置条目。不绑定到数据字段,而是执行指定的点击动作。
/// <para>
/// 典型用途:打开二级 UIPage如 Key Binding 页面)、触发重置操作等。
/// 支持鼠标悬停说明面板。
/// </para>
/// </summary>
public class SettingsEntryButton : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
[SerializeField] private TMP_Text labelText;
[SerializeField] private Button button;
private Action onClickAction;
/// <summary>条目的显示标题。</summary>
public string DisplayLabel { get; private set; }
/// <summary>条目的说明文本(可为空)。</summary>
public string Description { get; private set; }
/// <summary>鼠标悬停进入时触发。由 SettingsUIPage 设置。</summary>
public Action<string, string> OnHoverEnter { get; set; }
/// <summary>鼠标悬停离开时触发。由 SettingsUIPage 设置。</summary>
public Action OnHoverExit { get; set; }
/// <summary>
/// 初始化按钮条目。
/// </summary>
/// <param name="label">显示标题。</param>
/// <param name="description">悬停说明文本(可为空)。</param>
/// <param name="onClick">点击按钮时执行的动作。</param>
public void Initialize(string label, string description, Action onClick)
{
DisplayLabel = label;
Description = description;
onClickAction = onClick;
if (labelText != null)
labelText.text = label;
if (button != null)
button.onClick.AddListener(OnButtonClicked);
}
private void OnButtonClicked()
{
onClickAction?.Invoke();
}
// ──────────────────── 悬停事件 ────────────────────
public void OnPointerEnter(PointerEventData eventData)
{
OnHoverEnter?.Invoke(DisplayLabel, Description);
}
public void OnPointerExit(PointerEventData eventData)
{
OnHoverExit?.Invoke();
}
private void OnDestroy()
{
if (button != null)
button.onClick.RemoveListener(OnButtonClicked);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0a2521e5887305e4997eee14ae6e4dc9

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
namespace Cielonos.Settings.UI
{
/// <summary>
/// enum 字段的设置条目 — 使用 TMP_Dropdown。
/// 自动从枚举类型生成选项列表。
/// </summary>
public class SettingsEntryDropdown : SettingsEntryBase
{
[SerializeField] private TMP_Dropdown dropdown;
private Array enumValues;
protected override void SetupUI()
{
if (dropdown == null) return;
enumValues = Enum.GetValues(fieldInfo.FieldType);
dropdown.ClearOptions();
var options = new List<string>(enumValues.Length);
foreach (object val in enumValues)
{
options.Add(val.ToString());
}
dropdown.AddOptions(options);
dropdown.onValueChanged.AddListener(OnDropdownChanged);
}
public override void RefreshValue()
{
if (dropdown == null || enumValues == null) return;
object currentValue = GetFieldValue();
for (int i = 0; i < enumValues.Length; i++)
{
if (enumValues.GetValue(i).Equals(currentValue))
{
dropdown.SetValueWithoutNotify(i);
break;
}
}
}
private void OnDropdownChanged(int index)
{
if (enumValues != null && index >= 0 && index < enumValues.Length)
{
SetFieldValue(enumValues.GetValue(index));
}
}
private void OnDestroy()
{
if (dropdown != null)
dropdown.onValueChanged.RemoveListener(OnDropdownChanged);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96d532833398d8f438ea9df4a7419432

View File

@@ -0,0 +1,45 @@
using TMPro;
using UnityEngine;
namespace Cielonos.Settings.UI
{
/// <summary>
/// 不带 [Range] 特性的 int 字段的设置条目 — 使用 TMP_InputField整数模式
/// </summary>
public class SettingsEntryIntInput : SettingsEntryBase
{
[SerializeField] private TMP_InputField inputField;
protected override void SetupUI()
{
if (inputField == null) return;
inputField.contentType = TMP_InputField.ContentType.IntegerNumber;
inputField.onEndEdit.AddListener(OnInputEndEdit);
}
public override void RefreshValue()
{
if (inputField != null)
inputField.SetTextWithoutNotify(GetFieldValue().ToString());
}
private void OnInputEndEdit(string text)
{
if (int.TryParse(text, out int value))
{
SetFieldValue(value);
}
else
{
RefreshValue();
}
}
private void OnDestroy()
{
if (inputField != null)
inputField.onEndEdit.RemoveListener(OnInputEndEdit);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9e533d50b00a53a43b7531360247d193

View File

@@ -0,0 +1,166 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace Cielonos.Settings.UI
{
/// <summary>
/// 键位绑定条目,用于 <see cref="KeyBindingPage"/> 中的每一行。
/// <para>
/// 显示:[Action 名称] [当前按键文本] [重新绑定按钮]
/// 点击重新绑定按钮后进入监听模式,捕获下一个按键输入并更新绑定。
/// </para>
/// </summary>
public class SettingsEntryKeyBinding : MonoBehaviour
{
[SerializeField] private TMP_Text actionNameText;
[SerializeField] private TMP_Text currentKeyText;
[SerializeField] private Button rebindButton;
[SerializeField] private TMP_Text rebindButtonText;
[Header("Visuals")]
[SerializeField] private string waitingText = "...";
[SerializeField] private GameObject waitingOverlay;
private InputAction inputAction;
private int bindingIndex;
private InputActionRebindingExtensions.RebindingOperation rebindOperation;
/// <summary>绑定完成时触发。</summary>
public event Action OnBindingChanged;
/// <summary>
/// 初始化键位绑定条目。
/// </summary>
/// <param name="action">要绑定的 InputAction。</param>
/// <param name="targetBindingIndex">
/// 要重新绑定的 binding 索引。
/// 对于非 composite 的 action通常为 0。
/// </param>
/// <param name="displayName">条目显示名称(可为 null则使用 action 名)。</param>
public void Initialize(InputAction action, int targetBindingIndex, string displayName = null)
{
inputAction = action;
bindingIndex = targetBindingIndex;
if (actionNameText != null)
actionNameText.text = displayName ?? FormatActionName(action.name);
if (rebindButton != null)
rebindButton.onClick.AddListener(StartRebind);
RefreshDisplay();
}
/// <summary>
/// 刷新当前按键的显示文本。
/// </summary>
public void RefreshDisplay()
{
if (inputAction == null || currentKeyText == null) return;
string displayString = inputAction.bindings[bindingIndex].ToDisplayString(
InputBinding.DisplayStringOptions.DontUseShortDisplayNames);
currentKeyText.text = displayString;
}
/// <summary>
/// 开始交互式重新绑定。
/// </summary>
private void StartRebind()
{
if (inputAction == null) return;
// 显示等待提示
SetWaitingState(true);
// 先禁用 action 以允许重新绑定
inputAction.Disable();
rebindOperation = inputAction.PerformInteractiveRebinding(bindingIndex)
.WithControlsExcluding("<Mouse>/position")
.WithControlsExcluding("<Mouse>/delta")
.WithControlsExcluding("<Pointer>/position")
.WithControlsExcluding("<Pointer>/delta")
.WithCancelingThrough("<Keyboard>/escape")
.OnMatchWaitForAnother(0.1f)
.OnComplete(operation => FinishRebind(true))
.OnCancel(operation => FinishRebind(false))
.Start();
}
/// <summary>
/// 重新绑定完成或取消后的处理。
/// </summary>
private void FinishRebind(bool completed)
{
rebindOperation?.Dispose();
rebindOperation = null;
inputAction.Enable();
SetWaitingState(false);
RefreshDisplay();
if (completed)
{
OnBindingChanged?.Invoke();
}
}
/// <summary>
/// 重置此条目的绑定到默认值。
/// </summary>
public void ResetToDefault()
{
if (inputAction == null) return;
inputAction.RemoveBindingOverride(bindingIndex);
RefreshDisplay();
OnBindingChanged?.Invoke();
}
private void SetWaitingState(bool isWaiting)
{
if (currentKeyText != null)
currentKeyText.text = isWaiting ? waitingText : "";
if (rebindButton != null)
rebindButton.interactable = !isWaiting;
if (waitingOverlay != null)
waitingOverlay.SetActive(isWaiting);
}
/// <summary>
/// 将 ActionName (camelCase) 格式化为可读文本。
/// </summary>
private static string FormatActionName(string actionName)
{
if (string.IsNullOrEmpty(actionName)) return actionName;
var sb = new System.Text.StringBuilder(actionName.Length + 4);
sb.Append(char.ToUpper(actionName[0]));
for (int i = 1; i < actionName.Length; i++)
{
char c = actionName[i];
if (char.IsUpper(c) && i > 0 && char.IsLower(actionName[i - 1]))
sb.Append(' ');
sb.Append(c);
}
return sb.ToString();
}
private void OnDestroy()
{
rebindOperation?.Dispose();
if (rebindButton != null)
rebindButton.onClick.RemoveListener(StartRebind);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1efe166d58c898143afb2c52c54b6c05

View File

@@ -0,0 +1,59 @@
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Cielonos.Settings.UI
{
/// <summary>
/// 带 [Range] 特性的 int 字段的设置条目 — 使用 Slider + 数值文本。
/// </summary>
public class SettingsEntrySlider : SettingsEntryBase
{
[SerializeField] private Slider slider;
[SerializeField] private TMP_Text valueText;
protected override void SetupUI()
{
if (slider == null) return;
var rangeAttr = fieldInfo.GetCustomAttribute<RangeAttribute>();
if (rangeAttr != null)
{
slider.minValue = rangeAttr.min;
slider.maxValue = rangeAttr.max;
}
slider.wholeNumbers = true;
slider.onValueChanged.AddListener(OnSliderChanged);
}
public override void RefreshValue()
{
if (slider == null) return;
int currentValue = (int)GetFieldValue();
slider.SetValueWithoutNotify(currentValue);
UpdateValueText(currentValue);
}
private void OnSliderChanged(float value)
{
int intValue = Mathf.RoundToInt(value);
SetFieldValue(intValue);
UpdateValueText(intValue);
}
private void UpdateValueText(int value)
{
if (valueText != null)
valueText.text = value.ToString();
}
private void OnDestroy()
{
if (slider != null)
slider.onValueChanged.RemoveListener(OnSliderChanged);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e83e75b8a9df5e4789c9259a8cf107b

View File

@@ -0,0 +1,36 @@
using UnityEngine;
using UnityEngine.UI;
namespace Cielonos.Settings.UI
{
/// <summary>
/// bool 字段的设置条目 — 使用 Toggle 组件。
/// </summary>
public class SettingsEntryToggle : SettingsEntryBase
{
[SerializeField] private Toggle toggle;
protected override void SetupUI()
{
if (toggle != null)
toggle.onValueChanged.AddListener(OnToggleChanged);
}
public override void RefreshValue()
{
if (toggle != null)
toggle.SetIsOnWithoutNotify((bool)GetFieldValue());
}
private void OnToggleChanged(bool value)
{
SetFieldValue(value);
}
private void OnDestroy()
{
if (toggle != null)
toggle.onValueChanged.RemoveListener(OnToggleChanged);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1a6d7dfafe6d4de4c8305f7237d12f78