Passion & UI
This commit is contained in:
8
Assets/Scripts/Settings/Attributes.meta
Normal file
8
Assets/Scripts/Settings/Attributes.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 991b62089abf73a419dee4edff972f3c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 17a87303651f74241973f1139fd3035e
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Cielonos.Settings
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记设置字段不参与 UI 自动生成。
|
||||
/// 用于需要自定义 UI 处理的字段(如 locale、bindingOverridesJson)。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class SettingsIgnoreAttribute : Attribute { }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 394f9b8da41f7584ca5f2c863476767a
|
||||
86
Assets/Scripts/Settings/GameSettingsData.cs
Normal file
86
Assets/Scripts/Settings/GameSettingsData.cs
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/GameSettingsData.cs.meta
Normal file
2
Assets/Scripts/Settings/GameSettingsData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2388382ffecb474d8ae852702a6e463
|
||||
290
Assets/Scripts/Settings/GameSettingsManager.cs
Normal file
290
Assets/Scripts/Settings/GameSettingsManager.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/GameSettingsManager.cs.meta
Normal file
2
Assets/Scripts/Settings/GameSettingsManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68098320cfc2633408ccadda585abd83
|
||||
8
Assets/Scripts/Settings/UI.meta
Normal file
8
Assets/Scripts/Settings/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4fb5c1d6db2728f4a99a7b36073d0924
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
293
Assets/Scripts/Settings/UI/KeyBindingPage.cs
Normal file
293
Assets/Scripts/Settings/UI/KeyBindingPage.cs
Normal 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
|
||||
{
|
||||
// 普通(非 composite)binding
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/UI/KeyBindingPage.cs.meta
Normal file
2
Assets/Scripts/Settings/UI/KeyBindingPage.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8af1f846125623c4fb1f91199e44e9d3
|
||||
71
Assets/Scripts/Settings/UI/SettingsDescriptionPanel.cs
Normal file
71
Assets/Scripts/Settings/UI/SettingsDescriptionPanel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b58dfa318609de049af79505f0b92652
|
||||
160
Assets/Scripts/Settings/UI/SettingsEntryBase.cs
Normal file
160
Assets/Scripts/Settings/UI/SettingsEntryBase.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/UI/SettingsEntryBase.cs.meta
Normal file
2
Assets/Scripts/Settings/UI/SettingsEntryBase.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a7cf7f80a133a44cb243de940a1510e
|
||||
77
Assets/Scripts/Settings/UI/SettingsEntryButton.cs
Normal file
77
Assets/Scripts/Settings/UI/SettingsEntryButton.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/UI/SettingsEntryButton.cs.meta
Normal file
2
Assets/Scripts/Settings/UI/SettingsEntryButton.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a2521e5887305e4997eee14ae6e4dc9
|
||||
64
Assets/Scripts/Settings/UI/SettingsEntryDropdown.cs
Normal file
64
Assets/Scripts/Settings/UI/SettingsEntryDropdown.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/UI/SettingsEntryDropdown.cs.meta
Normal file
2
Assets/Scripts/Settings/UI/SettingsEntryDropdown.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96d532833398d8f438ea9df4a7419432
|
||||
45
Assets/Scripts/Settings/UI/SettingsEntryIntInput.cs
Normal file
45
Assets/Scripts/Settings/UI/SettingsEntryIntInput.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/UI/SettingsEntryIntInput.cs.meta
Normal file
2
Assets/Scripts/Settings/UI/SettingsEntryIntInput.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e533d50b00a53a43b7531360247d193
|
||||
166
Assets/Scripts/Settings/UI/SettingsEntryKeyBinding.cs
Normal file
166
Assets/Scripts/Settings/UI/SettingsEntryKeyBinding.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1efe166d58c898143afb2c52c54b6c05
|
||||
59
Assets/Scripts/Settings/UI/SettingsEntrySlider.cs
Normal file
59
Assets/Scripts/Settings/UI/SettingsEntrySlider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/UI/SettingsEntrySlider.cs.meta
Normal file
2
Assets/Scripts/Settings/UI/SettingsEntrySlider.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e83e75b8a9df5e4789c9259a8cf107b
|
||||
36
Assets/Scripts/Settings/UI/SettingsEntryToggle.cs
Normal file
36
Assets/Scripts/Settings/UI/SettingsEntryToggle.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Settings/UI/SettingsEntryToggle.cs.meta
Normal file
2
Assets/Scripts/Settings/UI/SettingsEntryToggle.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1a6d7dfafe6d4de4c8305f7237d12f78
|
||||
Reference in New Issue
Block a user