This commit is contained in:
2026-05-02 21:08:13 +08:00
parent e02c7f5e89
commit 91bc3a269b
27 changed files with 212553 additions and 18619 deletions

View File

@@ -0,0 +1,225 @@
using System.Collections;
using System.Collections.Generic;
using Ichni.RhythmGame;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace Ichni.Editor
{
public class DynamicUISlider : DynamicUIElement
{
public Slider slider;
public TMP_InputField valueInputField;
public TMP_InputField minInputField;
public TMP_InputField maxInputField;
private UnityAction<float> customAction;
private bool updatingInternally; // 防止输入框和滑块互相递归更新
#region [] Initialize
public override void Initialize(IBaseElement baseElement, string title, string parameterName)
{
Initialize(baseElement, title, parameterName, 0f, 1f, false);
}
public void Initialize(IBaseElement baseElement, string title, string parameterName,
float min, float max, bool wholeNumbers = false)
{
slider.minValue = min;
slider.maxValue = max;
slider.wholeNumbers = wholeNumbers;
// [对象池安全] 精准解绑业务代理
slider.onValueChanged.RemoveListener(OnSliderValueChanged);
valueInputField?.onEndEdit.RemoveListener(OnValueInputEndEdit);
minInputField?.onEndEdit.RemoveListener(OnMinInputEndEdit);
maxInputField?.onEndEdit.RemoveListener(OnMaxInputEndEdit);
customAction = null;
base.Initialize(baseElement, title, parameterName);
if (parameterName != string.Empty)
{
var val = ReflectionHelper.GetDeepValue(connectedBaseElement, parameterName);
if (val != null)
{
slider.SetValueWithoutNotify(System.Convert.ToSingle(val));
}
else
{
Debug.LogWarning($"[DynamicUI] 尝试绑定 {title} ({parameterName}) 失败,由于其值或路径无效。");
}
slider.onValueChanged.AddListener(OnSliderValueChanged);
}
else
{
slider.onValueChanged.AddListener(OnSliderValueChanged);
}
SyncAllInputFields();
}
#endregion
#region [] Slider Callback
private void OnSliderValueChanged(float value)
{
if (updatingInternally) return;
updatingInternally = true;
UpdateValueInputText(value);
if (parameterName != string.Empty)
{
ApplyParameters(value);
}
customAction?.Invoke(value);
updatingInternally = false;
}
#endregion
#region [] Input Field Callbacks
private void OnValueInputEndEdit(string text)
{
if (updatingInternally) return;
if (!float.TryParse(text, out float val)) return;
val = Mathf.Clamp(val, slider.minValue, slider.maxValue);
updatingInternally = true;
slider.SetValueWithoutNotify(val);
UpdateValueInputText(val);
if (parameterName != string.Empty)
{
ApplyParameters(val);
}
customAction?.Invoke(val);
updatingInternally = false;
}
private void OnMinInputEndEdit(string text)
{
if (updatingInternally) return;
if (!float.TryParse(text, out float val)) return;
if (val >= slider.maxValue) val = slider.maxValue - 0.0001f;
slider.minValue = val;
if (slider.value < val)
{
slider.value = val;
}
UpdateMinMaxInputTexts();
UpdateValueInputText(slider.value);
}
private void OnMaxInputEndEdit(string text)
{
if (updatingInternally) return;
if (!float.TryParse(text, out float val)) return;
if (val <= slider.minValue) val = slider.minValue + 0.0001f;
slider.maxValue = val;
if (slider.value > val)
{
slider.value = val;
}
UpdateMinMaxInputTexts();
UpdateValueInputText(slider.value);
}
#endregion
#region [] Apply Parameters
private void ApplyParameters(float value)
{
object convertedValue;
if (slider.wholeNumbers)
{
convertedValue = Mathf.RoundToInt(value);
}
else
{
convertedValue = value;
}
Ichni.Editor.Commands.CommandManager.ExecuteCommand(
new Ichni.Editor.Commands.ChangeValueCommand(connectedBaseElement, parameterName, convertedValue));
connectedBaseElement.Refresh();
}
#endregion
#region [UI ] UI Sync Helpers
private void SyncAllInputFields()
{
UpdateValueInputText(slider.value);
UpdateMinMaxInputTexts();
}
private void UpdateValueInputText(float value)
{
if (valueInputField != null)
{
valueInputField.SetTextWithoutNotify(slider.wholeNumbers
? Mathf.RoundToInt(value).ToString()
: value.ToString("F3"));
}
}
private void UpdateMinMaxInputTexts()
{
string FormatRangeVal(float v)
{
return slider.wholeNumbers ? Mathf.RoundToInt(v).ToString() : v.ToString("F3");
}
if (minInputField != null)
{
minInputField.SetTextWithoutNotify(FormatRangeVal(slider.minValue));
}
if (maxInputField != null)
{
maxInputField.SetTextWithoutNotify(FormatRangeVal(slider.maxValue));
}
}
#endregion
#region [Public API]
public void SetRange(float min, float max)
{
slider.minValue = min;
slider.maxValue = max;
UpdateMinMaxInputTexts();
UpdateValueInputText(slider.value);
}
public void SetWholeNumbers(bool wholeNumbers)
{
slider.wholeNumbers = wholeNumbers;
SyncAllInputFields();
}
public override DynamicUIElement AddListenerFunction(UnityAction action)
{
customAction += _ => action();
return this;
}
#endregion
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -59,6 +59,27 @@ namespace Ichni.Editor
}
}
/// <summary>
/// 滑块控制属性,适用于 float/int 类型字段,自动生成带范围限制的 Slider 替代 InputField。
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public class DynamicUISliderAttribute : DynamicUIAttribute
{
public float Min { get; set; }
public float Max { get; set; }
public bool WholeNumbers { get; set; }
public DynamicUISliderAttribute(string name = "", string group = "Default",
float min = 0f, float max = 1f, bool wholeNumbers = false,
int span = 0, float height = 0f, bool autoUpdate = false, bool readOnly = false)
: base(name, group, span, height, autoUpdate, readOnly)
{
Min = min;
Max = max;
WholeNumbers = wholeNumbers;
}
}
/// <summary>
/// 操作按钮卡片,挂载在没有任何参数的 Method() 方法上
/// </summary>

View File

@@ -73,7 +73,7 @@ namespace Ichni.Editor
// 先排变量
foreach (var field in fieldsInGroup)
{
int span = field.Attr.Span > 0 ? field.Attr.Span : SmartInferSpan(field.ValueType);
int span = GetEffectiveSpan(field);
span = Mathf.Clamp(span, 1, 3);
// 如果该行的槽被塞满了或者装不下了
@@ -83,12 +83,12 @@ namespace Ichni.Editor
currentLineBuffer.Clear();
currentSpanCount = 0;
}
// 特殊兼容:由于 GridLayoutGroup 的强制统一 cellSize, 我们如果在同一行混搭不同 Span 宽度的,反而会让它变形。
// 因此若当前这行的空间要求和前一个需求跨度不一致,也必须强行断点分层!
if (currentLineBuffer.Count > 0)
{
var lastSpan = currentLineBuffer[0].Attr.Span > 0 ? currentLineBuffer[0].Attr.Span : SmartInferSpan(currentLineBuffer[0].ValueType);
var lastSpan = GetEffectiveSpan(currentLineBuffer[0]);
if (span != lastSpan)
{
FlushLine(element, inspector, container, currentLineBuffer, GetHighestSpanRequirement(currentLineBuffer));
@@ -124,8 +124,7 @@ namespace Ichni.Editor
// 如果一行要放 3 份Span 都是 1 拼成的),那格子数量也是 3600/3=200一个
// 但是如果是独占的Span 都是 3格子数量反而应该是 1600/1=600一个
// 为了防止排版错开变形,我们通过 3 / span 来决定 Subcontainer 内部允许的等分元素数量。
int subcontainerDivision = 3 / lineSpanCapacity;
if (subcontainerDivision < 1) subcontainerDivision = 1;
int subcontainerDivision = Mathf.Max(1, Mathf.RoundToInt(3f / lineSpanCapacity));
float rowHeight = GetHighestHeightRequirement(fields);
var sub = container.GenerateSubcontainer(subcontainerDivision, rowHeight);
@@ -169,6 +168,10 @@ namespace Ichni.Editor
{
inspector.GenerateDropdown(element, sub, title, fd.ValueType, pName);
}
else if (fd.Attr is DynamicUISliderAttribute sliderAttr)
{
inspector.GenerateSlider(element, sub, title, pName, sliderAttr.Min, sliderAttr.Max, sliderAttr.WholeNumbers);
}
else // 容错给普通的 InputField
{
inspector.GenerateInputField(element, sub, title, pName, fd.Attr.AutoUpdate);
@@ -191,12 +194,17 @@ namespace Ichni.Editor
int highest = 1;
foreach (var f in fields)
{
int span = f.Attr.Span > 0 ? f.Attr.Span : SmartInferSpan(f.ValueType);
int span = GetEffectiveSpan(f);
if (span > highest) highest = span;
}
return highest;
}
private static int GetEffectiveSpan(FieldDef fd)
{
return fd.Attr.Span > 0 ? fd.Attr.Span : SmartInferSpan(fd.ValueType);
}
private static float SmartInferHeight(FieldDef fd)
{
// EmissionColorPicker 有 Toggle/RGB滑块/Intensity输入框高度明显大于 BaseColorPicker

View File

@@ -274,5 +274,31 @@ namespace Ichni.Editor
subcontainer.dynamicUIElements.Add(hsvDrawer);
return hsvDrawer;
}
public DynamicUISlider GenerateSlider(DynamicUISubcontainer subcontainer,
string title, float defaultValue = 0.5f, float min = 0f, float max = 1f, bool wholeNumbers = false)
{
DynamicUISlider slider = LeanPool
.Spawn(EditorManager.instance.basePrefabs.slider, subcontainer.rect)
.GetComponent<DynamicUISlider>();
slider.Initialize(null, title, string.Empty, min, max, wholeNumbers);
slider.slider.SetValueWithoutNotify(defaultValue);
subcontainer.dynamicUIElements.Add(slider);
return slider;
}
public DynamicUISlider GenerateSlider(IBaseElement baseElement,
DynamicUISubcontainer subcontainer, string title, string parameterName,
float min = 0f, float max = 1f, bool wholeNumbers = false)
{
DynamicUISlider slider = LeanPool
.Spawn(EditorManager.instance.basePrefabs.slider, subcontainer.rect)
.GetComponent<DynamicUISlider>();
slider.Initialize(baseElement, title, parameterName, min, max, wholeNumbers);
var nav = new Navigation { mode = Navigation.Mode.None };
slider.slider.navigation = nav;
subcontainer.dynamicUIElements.Add(slider);
return slider;
}
}
}

View File

@@ -3,8 +3,11 @@ using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using DG.Tweening;
using Ichni.RhythmGame;
using Lean.Pool;
using Sirenix.OdinInspector;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
@@ -24,9 +27,16 @@ namespace Ichni.Editor
public Button clipLoadButton;
public Button beatmapToolsButton;
[Title("Search")]
public TMP_InputField searchInput;
public RectTransform searchResultsRoot;
public GameObject searchResultItemPrefab;
[Title("Windows")]
public GeneralSecondaryWindow clipManagementWindow;
private readonly List<GameObject> searchResultItems = new List<GameObject>();
protected override void Start()
{
base.Start();
@@ -40,6 +50,84 @@ namespace Ichni.Editor
beatmapToolsButton.onClick.AddListener(GenerateBeatmapToolsWindow);
//songInfoButton.onClick.AddListener(已经在songinfo好了);
//projectInfoButton.onClick.AddListener(已经在projectinfo好了);
SetupSearch();
}
private void SetupSearch()
{
searchInput.onValueChanged.AddListener(OnSearchValueChanged);
searchInput.onSelect.AddListener(_ =>
{
searchResultsRoot.transform.DOScaleX(1, 0.25f);
searchResultsRoot.gameObject.SetActive(true);
});
// searchInput.onDeselect.AddListener(_ =>
// {
// // 延迟隐藏,避免点击结果项时面板先消失了
//
// });
searchResultsRoot.gameObject.SetActive(false);
}
private void OnSearchValueChanged(string query)
{
ClearSearchResults();
if (string.IsNullOrWhiteSpace(query)) return;
string lowerQuery = query.ToLower();
var matches = EditorManager.instance.beatmapContainer.gameElementList
.Where(e => e != null && e.elementName.ToLower().Contains(lowerQuery))
.Take(20)
.ToList();
if (matches.Count == 0) return;
searchResultsRoot.gameObject.SetActive(true);
foreach (var element in matches)
{
var item = LeanPool.Spawn(searchResultItemPrefab, searchResultsRoot);
var itemText = item.GetComponentInChildren<TMP_Text>();
if (itemText != null)
itemText.text = element.elementName + " " + element.GetType().ToString();
var button = item.GetComponentInChildren<Button>();
if (button != null)
{
GameElement captured = element;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() =>
{
EditorManager.instance.uiManager.hierarchy.FindTab(captured);
searchResultsRoot.transform.DOScaleX(0, 0.25f).OnComplete(() =>
{
searchInput.text = string.Empty;
ClearSearchResults();
searchResultsRoot.gameObject.SetActive(false);
});
});
}
searchResultItems.Add(item);
}
}
private void ClearSearchResults()
{
foreach (var item in searchResultItems)
{
if (item != null)
LeanPool.Despawn(item);
}
searchResultItems.Clear();
}
}
@@ -214,7 +302,7 @@ namespace Ichni.Editor
flick.Refresh();
}
});
var ResetNoteAudioButton = beatmapToolsWindow.GenerateButton(beatmapToolsSettings, "Reset Note Audio", () =>
{
List<NoteBase> allNotes = EditorManager.instance.beatmapContainer.gameElementList.FindAll(x => x is NoteBase).ConvertAll(x => x as NoteBase);
@@ -227,11 +315,11 @@ namespace Ichni.Editor
{
hold.submoduleList.Remove(hold.noteAudioSubmodule);
hold.noteAudioSubmodule = null;
hold.noteAudioSubmodule = new NoteAudioSubmodule(hold,
new List<string>(){"DefaultEndHold"},
hold.noteAudioSubmodule = new NoteAudioSubmodule(hold,
new List<string>() { "DefaultEndHold" },
new List<string>(), new List<string>(),
new List<string>(), new List<string>(),
new List<string>(){"DefaultStartHold"});
new List<string>() { "DefaultStartHold" });
}
}
@@ -241,8 +329,8 @@ namespace Ichni.Editor
{
flick.submoduleList.Remove(flick.noteAudioSubmodule);
flick.noteAudioSubmodule = null;
flick.noteAudioSubmodule = new NoteAudioSubmodule(flick,
new List<string>(){"DefaultFlick"},
flick.noteAudioSubmodule = new NoteAudioSubmodule(flick,
new List<string>() { "DefaultFlick" },
new List<string>(), new List<string>(),
new List<string>(), new List<string>(),
new List<string>());

View File

@@ -61,6 +61,7 @@ namespace Ichni.RhythmGame
public GameObject baseColorPicker;
public GameObject emissionColorPicker;
public GameObject hsvDrawer;
public GameObject slider;
[Title("DynamicUI相关-Composite")] public GameObject generalSecondaryWindow;
public GameObject compositeParameterWindow;

View File

@@ -42,10 +42,10 @@ namespace Ichni
#region [] Editor Preferences
// 这些首选项字段放在 ProjectContainer 中,以便 SetUpInspector 将 this 作为 IBaseElement owner 传给 GenerateToggle
public NoteBase.NoteJudgeType currentJudgeType;
public bool useClickSelect;
public bool useNotePrefab;
public bool ExpandWhileClick;
public bool useQuickMove;
public bool useClickSelect = true;
public bool useNotePrefab = true;
public bool ExpandWhileClick = true;
public bool useQuickMove = false;
#endregion
#region [] Generation & Initialization