阶段性完成

This commit is contained in:
SoulliesOfficial
2026-07-25 13:27:53 -04:00
parent de70870682
commit a34461d31f
121 changed files with 7022 additions and 4111 deletions

View File

@@ -1,48 +1,298 @@
using System.Collections;
using System.Collections.Generic;
using Ichni.Localization;
using Michsky.MUIP;
using TMPro;
using UnityEngine;
using UnityEngine.Localization;
namespace Ichni.UI
{
/// <summary>
/// Settings 页面使用的下拉控件。
/// <para>
/// 本地化枚举项由 <see cref="SetUpLocalized(int,string,string[])"/> 在代码中传入 String Table 名称与
/// Entry Key。控件会创建对应的 <see cref="LocalizedString"/>,并在 Michsky 生成 Item 后为每个
/// 可见文字绑定 <see cref="LocalizedTMPText"/>;切换语言时不重建列表。
/// </para>
/// <para>
/// 分辨率、语言自称等运行时文本则使用 <see cref="SetUpRuntime"/>,不会附加本地化组件。
/// </para>
/// </summary>
public class Dropdown : SettingsUIElementBase
{
public List<string> options;
// 仅保存本次初始化创建的运行时引用,不在 Inspector 中维护本地化选项列表。
private readonly List<LocalizedString> _localizedOptions = new List<LocalizedString>();
// 保留原字段以兼容已有的外部读取。静态本地化选项使用稳定的占位名称;实际显示内容由
// 动态生成 Item 上的 LocalizedTMPText 管理。
[HideInInspector] public List<string> options = new List<string>();
public CustomDropdown dropdown;
public int selectedIndex;
public string selectedOption;
public void SetUp(int initialValue, List<string> options, string title = "", bool preservePrefabTitle = false)
private bool _usesLocalizedOptions;
private bool _isValueChangeListenerRegistered;
private Coroutine _bindGeneratedItemsCoroutine;
/// <summary>
/// 使用代码提供的 String Table 与 Entry Key 初始化静态枚举选项。
/// <paramref name="entryKeys"/> 的顺序即设置枚举的索引顺序,必须保持稳定。
/// 标题和说明仍由其 TMP 对象上的 <see cref="LocalizedTMPText"/> 独立管理。
/// </summary>
public void SetUpLocalized(int initialValue, string tableCollection, params string[] entryKeys)
{
base.SetUp(title, preservePrefabTitle: preservePrefabTitle);
this.options = options;
this.dropdown.items = new List<CustomDropdown.Item>();
foreach (string option in options)
base.SetUp();
ClearLocalizedBindings();
ClearLocalizedOptionDefinitions();
_usesLocalizedOptions = true;
foreach (string entryKey in entryKeys)
{
dropdown.items.Add(new CustomDropdown.Item { itemName = option });
_localizedOptions.Add(new LocalizedString(tableCollection, entryKey));
}
dropdown.onValueChanged.AddListener(value =>
{
SetValue(value);
updateValueAction?.Invoke();
});
BuildLocalizedOptionPlaceholders();
EnsureValueChangeListener();
SetValue(initialValue);
ApplyOptionsToDropdown();
}
/// <summary>
/// 使用运行时生成的纯文本选项初始化下拉框。
/// 仅用于分辨率、语言自称等不应随当前 Locale 再次翻译的内容。
/// </summary>
public void SetUpRuntime(int initialValue, IReadOnlyList<string> runtimeOptions)
{
base.SetUp();
ClearLocalizedBindings();
ClearLocalizedOptionDefinitions();
_usesLocalizedOptions = false;
options = runtimeOptions == null ? new List<string>() : new List<string>(runtimeOptions);
EnsureValueChangeListener();
SetValue(initialValue);
ApplyOptionsToDropdown();
}
private void OnEnable()
{
if (_usesLocalizedOptions)
{
QueueGeneratedItemBinding();
}
}
private void OnDisable()
{
StopGeneratedItemBinding();
}
private void OnDestroy()
{
StopGeneratedItemBinding();
ClearLocalizedOptionDefinitions();
}
public int GetOptionIndex(string option)
{
return options.IndexOf(option);
}
/// <summary>
/// 只更新当前选择状态,不调用 <see cref="updateValueAction"/>。
/// 因此语言切换和 Dropdown 列表重建都不会重新应用玩家的设置。
/// </summary>
public void SetValue(int index)
{
selectedIndex = index;
if (options == null || options.Count == 0)
{
selectedIndex = 0;
selectedOption = string.Empty;
return;
}
selectedIndex = Mathf.Clamp(index, 0, options.Count - 1);
selectedOption = options[selectedIndex];
dropdown.selectedItemIndex = selectedIndex;
dropdown.UpdateItemLayout();
if (_usesLocalizedOptions)
{
BindSelectedLocalizedText();
}
else if (dropdown.selectedText != null)
{
dropdown.selectedText.text = selectedOption;
}
}
private void EnsureValueChangeListener()
{
if (_isValueChangeListenerRegistered)
{
return;
}
dropdown.onValueChanged.AddListener(OnDropdownValueChanged);
_isValueChangeListenerRegistered = true;
}
private void OnDropdownValueChanged(int index)
{
SetValue(index);
updateValueAction?.Invoke();
}
private void BuildLocalizedOptionPlaceholders()
{
options = new List<string>(_localizedOptions.Count);
for (int index = 0; index < _localizedOptions.Count; index++)
{
// Michsky 需要 itemName 创建 Item真正可见的内容会在创建完成后被 LocalizedTMPText 覆盖。
options.Add($"LocalizedOption_{index}");
}
}
private void ApplyOptionsToDropdown()
{
if (options == null || options.Count == 0)
{
dropdown.items = new List<CustomDropdown.Item>();
return;
}
SetValue(selectedIndex);
dropdown.items = new List<CustomDropdown.Item>(options.Count);
foreach (string option in options)
{
dropdown.items.Add(new CustomDropdown.Item { itemName = option });
}
if (dropdown.gameObject.activeInHierarchy)
{
if (dropdown.isOn)
{
dropdown.Animate();
}
dropdown.SetupDropdown();
}
else
{
dropdown.UpdateItemLayout();
}
if (_usesLocalizedOptions)
{
QueueGeneratedItemBinding();
}
}
/// <summary>
/// 等待一帧,确保 Michsky 已销毁旧 Item 并完成当前批次的实例化,再为每个 Item 绑定引用。
/// 这样不会保留已销毁 Item 的语言监听,也避免把引用绑定到上一批列表对象。
/// </summary>
private void QueueGeneratedItemBinding()
{
if (!isActiveAndEnabled || _bindGeneratedItemsCoroutine != null)
{
return;
}
_bindGeneratedItemsCoroutine = StartCoroutine(BindGeneratedItemsNextFrame());
}
private IEnumerator BindGeneratedItemsNextFrame()
{
yield return null;
_bindGeneratedItemsCoroutine = null;
BindSelectedLocalizedText();
if (dropdown.itemParent == null)
{
yield break;
}
int itemCount = Mathf.Min(dropdown.itemParent.childCount, _localizedOptions.Count);
for (int index = 0; index < itemCount; index++)
{
TMP_Text itemText = dropdown.itemParent.GetChild(index).GetComponentInChildren<TMP_Text>(true);
BindLocalizedText(itemText, _localizedOptions[index]);
}
}
private void BindSelectedLocalizedText()
{
if (!_usesLocalizedOptions || _localizedOptions.Count == 0)
{
return;
}
int optionIndex = Mathf.Clamp(selectedIndex, 0, _localizedOptions.Count - 1);
BindLocalizedText(dropdown.selectedText, _localizedOptions[optionIndex]);
}
private static void BindLocalizedText(TMP_Text text, LocalizedString reference)
{
if (text == null)
{
return;
}
LocalizedTMPText localizedText = text.GetComponent<LocalizedTMPText>();
if (localizedText == null)
{
localizedText = text.gameObject.AddComponent<LocalizedTMPText>();
}
localizedText.SetStringReference(reference);
}
/// <summary>
/// 将本 Dropdown 先前动态附加的本地化引用清空。
/// 同一控件改用运行时文本列表时必须执行此步骤,否则旧 Locale 回调会覆盖新的原始文本。
/// </summary>
private void ClearLocalizedBindings()
{
ClearLocalizedBinding(dropdown.selectedText);
if (dropdown.itemParent == null)
{
return;
}
foreach (Transform item in dropdown.itemParent)
{
ClearLocalizedBinding(item.GetComponentInChildren<TMP_Text>(true));
}
}
private static void ClearLocalizedBinding(TMP_Text text)
{
LocalizedTMPText localizedText = text == null ? null : text.GetComponent<LocalizedTMPText>();
if (localizedText != null)
{
localizedText.SetStringReference(null);
}
}
private void ClearLocalizedOptionDefinitions()
{
// LocalizedString 的订阅由每个 LocalizedTMPText 持有ClearLocalizedBindings 已经解除它们。
// 当前 Unity Localization 版本没有公开的 LocalizedString.Dispose因此清空运行时列表即可。
_localizedOptions.Clear();
}
private void StopGeneratedItemBinding()
{
if (_bindGeneratedItemsCoroutine == null)
{
return;
}
StopCoroutine(_bindGeneratedItemsCoroutine);
_bindGeneratedItemsCoroutine = null;
}
}
}

View File

@@ -17,10 +17,22 @@ namespace Ichni.UI
public TMP_Text keyText;
public GameObject waitingForInputCover;
public void SetUp(string title, string subtitle, string actionName, int bindingIndex)
private bool _isClickListenerRegistered;
/// <summary>
/// 初始化重绑定行为。标题和说明由 Prefab 上的 LocalizedTMPText 配置;
/// <paramref name="actionName"/> 仅用于定位 Input System Action不是本地化 Key。
/// </summary>
public void SetUp(string actionName, int bindingIndex)
{
base.SetUp(title, subtitle);
base.SetUp();
if (_isClickListenerRegistered)
{
return;
}
button.onClick.AddListener(() => StartRebinding(actionName, bindingIndex));
_isClickListenerRegistered = true;
}
// 开始重绑定流程
@@ -82,4 +94,4 @@ namespace Ichni.UI
}
}
}
}
}

View File

@@ -1,6 +1,3 @@
using System.Collections;
using System.Collections.Generic;
using I2.Loc;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
@@ -10,9 +7,6 @@ namespace Ichni.UI
{
public abstract class SettingsUIElementBase : MonoBehaviour
{
public string title;
public string subTitle;
public Image titleBackground;
public TMP_Text titleText;
public TMP_Text subTitleText;
@@ -21,52 +15,22 @@ namespace Ichni.UI
/// <summary>
/// 初始化设置控件的标题区域。
/// <paramref name="preservePrefabTitle"/> 用于已经由 Unity Localization 直接驱动标题的控件:
/// 运行时代码不会覆盖或隐藏 Prefab 中的本地化标题。
/// <para>
/// 标题与说明不再由此基础类写入 I2 Key。请在对应 <see cref="TMP_Text"/> 上配置
/// <c>LocalizedTMPText</c>,使静态文案与控件逻辑完全解耦。
/// </para>
/// <paramref name="preservePrefabTitle"/> 为 true 时保留 Prefab 的标题区域;这是设置页面的默认行为。
/// </summary>
public virtual void SetUp(string title = "", string subTitle = "", bool preservePrefabTitle = false)
public virtual void SetUp(bool preservePrefabTitle = true)
{
if (preservePrefabTitle)
{
return;
}
if (title != "")
{
SetTitle(title, subTitle);
}
else
{
titleBackground.gameObject.SetActive(false);
titleText.gameObject.SetActive(false);
subTitleText.gameObject.SetActive(false);
}
}
public void SetTitle(string title, string subTitle = "")
{
this.title = title;
this.subTitle = subTitle;
titleText.gameObject.SetActive(true);
var titleLoc = titleText.GetComponent<Localize>();
if (titleLoc != null)
titleLoc.SetTerm(title);
else
titleText.text = title;
if (subTitle != "")
{
subTitleText.gameObject.SetActive(true);
var subLoc = subTitleText.GetComponent<Localize>();
if (subLoc != null)
subLoc.SetTerm(subTitle);
else
subTitleText.text = subTitle;
}
else
{
subTitleText.gameObject.SetActive(false);
}
titleBackground.gameObject.SetActive(false);
titleText.gameObject.SetActive(false);
subTitleText.gameObject.SetActive(false);
}
}
}

View File

@@ -6,20 +6,27 @@ namespace Ichni.UI
{
public bool value;
public SwitchManager switchManager;
private bool _isValueChangeListenerRegistered;
public void SetUp(bool initialValue, string title = "", bool preservePrefabTitle = false)
public void SetUp(bool initialValue, bool preservePrefabTitle = true)
{
base.SetUp(title, preservePrefabTitle: preservePrefabTitle);
base.SetUp(preservePrefabTitle);
switchManager.onValueChanged.AddListener(isOn =>
if (!_isValueChangeListenerRegistered)
{
value = isOn;
updateValueAction?.Invoke();
});
switchManager.onValueChanged.AddListener(OnSwitchValueChanged);
_isValueChangeListenerRegistered = true;
}
SetValue(initialValue);
}
private void OnSwitchValueChanged(bool isOn)
{
value = isOn;
updateValueAction?.Invoke();
}
public void SetValue(bool value)
{
this.value = value;

View File

@@ -1,7 +1,3 @@
using System.Collections;
using System.Collections.Generic;
using I2.Loc;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
@@ -10,25 +6,28 @@ namespace Ichni.UI
public class TextButton : SettingsUIElementBase
{
public Button button;
public TMP_Text buttonText;
public void SetUp(string title, string subTitle, string textContent)
private bool _isClickListenerRegistered;
/// <summary>
/// 初始化按钮逻辑。标题、说明及按钮文字均由 Prefab 上的 LocalizedTMPText 配置,
/// 不再把字符串当作 I2 的 Term 传入。
/// </summary>
public void SetUp()
{
base.SetUp(title, subTitle);
var localize = buttonText.GetComponent<Localize>();
if (localize != null)
base.SetUp();
if (_isClickListenerRegistered)
{
localize.SetTerm(textContent);
}
else
{
buttonText.text = textContent;
return;
}
button.onClick.AddListener(() =>
{
updateValueAction?.Invoke();
});
button.onClick.AddListener(InvokeUpdateValueAction);
_isClickListenerRegistered = true;
}
private void InvokeUpdateValueAction()
{
updateValueAction?.Invoke();
}
}
}
}

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using I2.Loc;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
@@ -27,13 +26,13 @@ namespace Ichni.UI
public string suffix;
public bool showPositiveMark;
public void SetUp(int initialValue, int step, string title = "")
public void SetUp(int initialValue, int step)
{
intStep = step;
intMin = int.MinValue;
intMax = int.MaxValue;
base.SetUp(title);
base.SetUp();
increaseButton.SetUpButton(IncreaseValue, IncreaseValue);
decreaseButton.SetUpButton(DecreaseValue, DecreaseValue);
@@ -94,7 +93,7 @@ namespace Ichni.UI
{
valueText.text = prefix + (showPositiveMark && intValue > 0 ? "+" : "") + intValue.ToString() + suffix;
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 660, 20);
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 560, 20);
barImage.rectTransform.sizeDelta = target;
updateValueAction?.Invoke();
}
@@ -106,7 +105,7 @@ namespace Ichni.UI
if (LastValue == intValue) return;
tween?.Complete();
valueText.text = prefix + (showPositiveMark && intValue > 0 ? "+" : "") + intValue.ToString() + suffix;
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 660, 20);
var target = new Vector2((float)(intValue - intMin) / (intMax - intMin) * 560, 20);
tween = barImage.rectTransform.DOSizeDelta(target, 0.2f).SetEase(Ease.OutExpo).Play();
LastValue = intValue;
updateValueAction?.Invoke();
@@ -117,4 +116,4 @@ namespace Ichni.UI
return intValue;
}
}
}
}