2917 lines
105 KiB
Text
2917 lines
105 KiB
Text
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Attributes.cs
|
|
|
|
using System;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
[AttributeUsage(AttributeTargets.All, Inherited = false)]
|
|
public class InspectableAttribute : Attribute { }
|
|
|
|
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
|
|
public abstract class PreferredInspectorAttribute : InspectableAttribute { }
|
|
|
|
public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute { }
|
|
|
|
public class InspectableRangedFloatAttribute : PreferredInspectorAttribute
|
|
{
|
|
public readonly float Min, Max;
|
|
|
|
public InspectableRangedFloatAttribute(float min, float max)
|
|
{
|
|
Min = min;
|
|
Max = max;
|
|
}
|
|
}
|
|
|
|
public class InspectableRangedIntAttribute : PreferredInspectorAttribute
|
|
{
|
|
public readonly int Min, Max;
|
|
|
|
public InspectableRangedIntAttribute(int min, int max)
|
|
{
|
|
Min = min;
|
|
Max = max;
|
|
}
|
|
}
|
|
|
|
public class InspectableIncrementIntAttribute : PreferredInspectorAttribute
|
|
{
|
|
public readonly int Min, Max;
|
|
|
|
public InspectableIncrementIntAttribute(int min, int max)
|
|
{
|
|
Min = min;
|
|
Max = max;
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\ColorConversion.cs
|
|
|
|
using Unity.Mathematics;
|
|
using UnityEngine;
|
|
using static Unity.Mathematics.math;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
// Color conversion functions from https://www.chilliant.com/rgb2hsv.html
|
|
public static class ColorConversion
|
|
{
|
|
private static float Epsilon = 1e-10f;
|
|
|
|
public static float3 HUEtoRGB(in float h)
|
|
{
|
|
float R = abs(h * 6 - 3) - 1;
|
|
float G = 2 - abs(h * 6 - 2);
|
|
float B = 2 - abs(h * 6 - 4);
|
|
return saturate(float3(R,G,B));
|
|
}
|
|
|
|
public static float3 RGBtoHCV(in float3 rgb)
|
|
{
|
|
// Based on work by Sam Hocevar and Emil Persson
|
|
float4 P = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.yz, 0.0f, -1.0f/3.0f);
|
|
float4 Q = (rgb.x < P.x) ? float4(P.xyw, rgb.x) : float4(rgb.x, P.yzx);
|
|
float C = Q.x - min(Q.w, Q.y);
|
|
float H = abs((Q.w - Q.y) / (6 * C + Epsilon) + Q.z);
|
|
return float3(H, C, Q.x);
|
|
}
|
|
|
|
public static float3 HSVtoRGB(in float3 hsv)
|
|
{
|
|
float3 rgb = HUEtoRGB(hsv.x);
|
|
return ((rgb - 1) * hsv.y + 1) * hsv.z;
|
|
}
|
|
|
|
public static float3 HSLtoRGB(in float3 hsl)
|
|
{
|
|
float3 RGB = HUEtoRGB(hsl.x);
|
|
float C = (1 - abs(2 * hsl.z - 1)) * hsl.y;
|
|
return (RGB - 0.5f) * C + hsl.z;
|
|
}
|
|
|
|
// The weights of RGB contributions to luminance.
|
|
// Should sum to unity.
|
|
private static float3 HCYwts = float3(0.299f, 0.587f, 0.114f);
|
|
|
|
public static float3 HCYtoRGB(float3 hcy)
|
|
{
|
|
float3 RGB = HUEtoRGB(hcy.x);
|
|
float Z = dot(RGB, HCYwts);
|
|
if (hcy.z < Z)
|
|
{
|
|
hcy.y *= hcy.z / Z;
|
|
}
|
|
else if (Z < 1)
|
|
{
|
|
hcy.y *= (1 - hcy.z) / (1 - Z);
|
|
}
|
|
return (RGB - Z) * hcy.y + hcy.z;
|
|
}
|
|
|
|
public static float3 RGBtoHSV(in float3 rgb)
|
|
{
|
|
float3 HCV = RGBtoHCV(rgb);
|
|
float S = HCV.y / (HCV.z + Epsilon);
|
|
return float3(HCV.x, S, HCV.z);
|
|
}
|
|
|
|
public static float3 RGBtoHSL(in float3 rgb)
|
|
{
|
|
float3 HCV = RGBtoHCV(rgb);
|
|
float L = HCV.z - HCV.y * 0.5f;
|
|
float S = HCV.y / (1 - abs(L * 2 - 1) + Epsilon);
|
|
return float3(HCV.x, S, L);
|
|
}
|
|
|
|
public static float3 RGBtoHCY(in float3 rgb)
|
|
{
|
|
// Corrected by David Schaeffer
|
|
float3 HCV = RGBtoHCV(rgb);
|
|
float Y = dot(rgb, HCYwts);
|
|
float Z = dot(HUEtoRGB(HCV.x), HCYwts);
|
|
if (Y < Z)
|
|
{
|
|
HCV.y *= Z / (Epsilon + Y);
|
|
}
|
|
else
|
|
{
|
|
HCV.y *= (1 - Z) / (Epsilon + 1 - Y);
|
|
}
|
|
return float3(HCV.x, HCV.y, Y);
|
|
}
|
|
|
|
public static Color ToColor(this float3 color) => new Color(color.x, color.y, color.z);
|
|
public static float3 ToFloat3(this Color color) => new float3(color.r, color.g, color.b);
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\DisplayOptions.cs
|
|
|
|
using TMPro;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
public class DisplayOptions
|
|
{
|
|
public TextAlignmentOptions? TextAlignment { get; }
|
|
public VerticalAlignmentOptions? VerticalAlignment { get; }
|
|
public float? LayoutPriority { get; }
|
|
public float? LayoutHeight { get; }
|
|
public float? TextSize { get; }
|
|
public bool Interactable { get; }
|
|
public bool PlaceInContext { get; }
|
|
|
|
public DisplayOptions(bool placeInContext = true,
|
|
bool interactable = true,
|
|
float? layoutHeight = null,
|
|
float? textSize = null,
|
|
float? layoutPriority = null,
|
|
VerticalAlignmentOptions? verticalAlignment = null,
|
|
TextAlignmentOptions? textAlignment = null)
|
|
{
|
|
PlaceInContext = placeInContext;
|
|
LayoutHeight = layoutHeight;
|
|
Interactable = interactable;
|
|
TextSize = textSize;
|
|
LayoutPriority = layoutPriority;
|
|
VerticalAlignment = verticalAlignment;
|
|
TextAlignment = textAlignment;
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Generator.cs
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
using GameCult.Unity.UI.Components;
|
|
using UnityEngine;
|
|
using ZLinq;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
public class Generator : ResolverComponent, IUIContext
|
|
{
|
|
[SerializeField] private float inspectorLabelPriority = 3;
|
|
[SerializeField] private float inspectorFieldPriority = 2;
|
|
[SerializeField] private ReflectiveResolver? resolver;
|
|
[SerializeField] private RectTransform? contentRoot;
|
|
protected event Action<GameObject>? OnPropertyAdded;
|
|
protected event Action? RefreshPropertyValues;
|
|
|
|
protected List<GameObject> Elements { get; } = new();
|
|
public IUIResolver Resolver => resolver ?? throw new NullReferenceException("Generator doesn't have an assigned resolver.");
|
|
public Action? Refresh
|
|
{
|
|
get => RefreshPropertyValues;
|
|
set => RefreshPropertyValues = value;
|
|
}
|
|
|
|
public RectTransform ContentRoot => contentRoot ?? (RectTransform)transform;
|
|
public Canvas Canvas => GetComponentInParent<Canvas>();
|
|
public RectTransform CanvasRoot => (RectTransform)Canvas.transform;
|
|
|
|
public void Inspect(object obj, bool inspectableOnly = false, bool recursive = true)
|
|
{
|
|
var members = obj.GetType().GetMembers(BindingFlags.Public | BindingFlags.Instance);
|
|
foreach (var member in members)
|
|
{
|
|
var inspectable = member.GetCustomAttribute<InspectableAttribute>();
|
|
if (inspectable == null && inspectableOnly) continue;
|
|
var preferred = inspectable as PreferredInspectorAttribute;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
{
|
|
var type = field.FieldType;
|
|
var handler = Resolver.ResolveField(type, preferred);
|
|
// If there's a handler for this field and attribute type, use that
|
|
if (handler != null)
|
|
{
|
|
var group = Resolver.Resolve<HorizontalGroup>()?.Configure(this);
|
|
if (group == null) continue;
|
|
Resolver.Resolve<Label>()?.Configure(group, member.Name);
|
|
handler.ConfigureForMember(group, obj, field);
|
|
}
|
|
else
|
|
{
|
|
// No field handler found;
|
|
// If recursion is enabled and the field is a class marked inspectable or if we don't care,
|
|
// Generate a foldout and then inspect recursively inside the foldout
|
|
if (recursive && field.FieldType.IsClass &&
|
|
(!inspectableOnly || field.FieldType.GetCustomAttribute<InspectableAttribute>() != null))
|
|
{
|
|
var foldout = Resolver.Resolve<GeneratorFoldout>();
|
|
if (foldout != null)
|
|
{
|
|
foldout.Title = member.Name;
|
|
foldout.Inspect(field.GetValue(obj), inspectableOnly, recursive);
|
|
}
|
|
}
|
|
// If all else fails, just display the ToString
|
|
else this.AddLabelInspector(member.Name, () => field.GetValue(obj)?.ToString() ?? "Null");
|
|
}
|
|
continue;
|
|
}
|
|
case PropertyInfo property:
|
|
{
|
|
var type = property.PropertyType;
|
|
var isReadOnly = !property.CanWrite;
|
|
PreferredInspectorAttribute? usedAttr = preferred;
|
|
if (isReadOnly && preferred == null && IsDisplayablePrimitive(type))
|
|
{
|
|
usedAttr = new InspectableReadOnlyLabelAttribute();
|
|
}
|
|
|
|
var handler = Resolver.ResolveField(type, usedAttr);
|
|
if (handler != null)
|
|
{
|
|
var group = Resolver.Resolve<HorizontalGroup>()?.Configure(this);
|
|
if (group == null) continue;
|
|
Resolver.Resolve<Label>()?.Configure(group, member.Name);
|
|
handler.ConfigureForMember(group, obj, property);
|
|
}
|
|
else
|
|
{
|
|
if (recursive && property.PropertyType.IsClass &&
|
|
(!inspectableOnly || property.PropertyType.GetCustomAttribute<InspectableAttribute>() != null))
|
|
{
|
|
var foldout = Resolver.Resolve<GeneratorFoldout>();
|
|
if (foldout != null)
|
|
{
|
|
foldout.Title = member.Name;
|
|
foldout.Inspect(property.GetValue(obj), inspectableOnly, recursive);
|
|
}
|
|
}
|
|
else this.AddLabelInspector(member.Name, () => property.GetValue(obj)?.ToString() ?? "Null");
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool IsDisplayablePrimitive(Type type)
|
|
{
|
|
return type.IsPrimitive || type == typeof(string) || type == typeof(decimal);
|
|
}
|
|
|
|
public virtual void Update()
|
|
{
|
|
RefreshValues();
|
|
}
|
|
|
|
protected void RefreshValues()
|
|
{
|
|
RefreshPropertyValues?.Invoke();
|
|
}
|
|
|
|
public void Register(GameObject go)
|
|
{
|
|
Elements.Add(go);
|
|
OnPropertyAdded?.Invoke(go);
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
foreach(var element in Elements)
|
|
Destroy(element);
|
|
Elements.Clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\GeneratorPanel.cs
|
|
|
|
using TMPro;
|
|
using UnityEngine;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
public class GeneratorPanel : Generator
|
|
{
|
|
[SerializeField] private TextMeshProUGUI? title;
|
|
|
|
public string Title
|
|
{
|
|
set
|
|
{
|
|
if (title is not null) title.text = value;
|
|
}
|
|
}
|
|
|
|
public void TitleSubtitle(string title, string subtitle) => Title = $"{title}\n<smallcaps><size=50%>{subtitle}";
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Interfaces.cs
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using GameCult.Unity.UI.Components;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
public interface IFieldHandler
|
|
{
|
|
int Priority { get; }
|
|
bool CanHandle(Type type, PreferredInspectorAttribute? attr);
|
|
void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null);
|
|
}
|
|
|
|
public interface IUIResolver
|
|
{
|
|
// For general field generation (reflection, attributes, etc.)
|
|
IFieldHandler? ResolveField(Type type, PreferredInspectorAttribute? attr);
|
|
|
|
// For declarative, known-type composition
|
|
T? Resolve<T>(string? prefabName = null) where T : ResolverComponent;
|
|
}
|
|
|
|
public interface IUIContext
|
|
{
|
|
IUIResolver Resolver { get; }
|
|
RectTransform ContentRoot { get; }
|
|
Canvas Canvas { get; }
|
|
RectTransform CanvasRoot { get; }
|
|
void Register(GameObject go);
|
|
Action? Refresh { get; set; }
|
|
// DisplayOptions InspectorLabelDisplayOptions { get; }
|
|
// DisplayOptions InspectorFieldDisplayOptions { get; }
|
|
}
|
|
|
|
public interface ILayoutElement
|
|
{
|
|
public LayoutElement? LayoutElement { get; }
|
|
}
|
|
|
|
public interface ITextElement
|
|
{
|
|
public TextMeshProUGUI? Text { get; }
|
|
}
|
|
|
|
public interface IMultipleTextElement
|
|
{
|
|
public IEnumerable<TextMeshProUGUI> Text { get; }
|
|
}
|
|
|
|
public static class DisplayOptionsExtensions
|
|
{
|
|
public static void ApplyLayoutOptions(this ILayoutElement layoutElement, DisplayOptions? displayOptions)
|
|
{
|
|
if(displayOptions == null || layoutElement.LayoutElement == null) return;
|
|
|
|
if(displayOptions.LayoutPriority != null) layoutElement.LayoutElement.flexibleWidth = displayOptions.LayoutPriority.Value;
|
|
if(displayOptions.LayoutHeight != null) layoutElement.LayoutElement.minHeight = displayOptions.LayoutHeight.Value;
|
|
}
|
|
|
|
public static void ApplyTextOptions(this ITextElement textElement, DisplayOptions? displayOptions)
|
|
{
|
|
if(displayOptions == null || textElement.Text is null) return;
|
|
|
|
if(displayOptions.TextAlignment != null) textElement.Text.alignment = displayOptions.TextAlignment.Value;
|
|
if(displayOptions.VerticalAlignment != null) textElement.Text.verticalAlignment = displayOptions.VerticalAlignment.Value;
|
|
if(displayOptions.TextSize != null) textElement.Text.fontSize = displayOptions.TextSize.Value;
|
|
}
|
|
|
|
public static void ApplyTextOptions(this IMultipleTextElement textElement, DisplayOptions? displayOptions)
|
|
{
|
|
if(displayOptions == null) return;
|
|
|
|
foreach(var text in textElement.Text)
|
|
{
|
|
if (displayOptions.TextAlignment != null) text.alignment = displayOptions.TextAlignment.Value;
|
|
if (displayOptions.VerticalAlignment != null) text.verticalAlignment = displayOptions.VerticalAlignment.Value;
|
|
if (displayOptions.TextSize != null) text.fontSize = displayOptions.TextSize.Value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\MainMenu.cs
|
|
|
|
using System;
|
|
using GameCult.Unity.UI.Components;
|
|
using UnityEngine;
|
|
using static Unity.Mathematics.math;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
public class MainMenu : MonoBehaviour
|
|
{
|
|
[SerializeField] private Prototype? panelPrototype;
|
|
[SerializeField] private float fadeTime = .5f;
|
|
[SerializeField] private float fadeDistance = 512;
|
|
[SerializeField] private float fadeAlphaExponent = 2;
|
|
[SerializeField] private float fadePositionExponent = 2;
|
|
|
|
private (GeneratorPanel panel, CanvasGroup group) _currentMenu, _nextMenu;
|
|
private bool _fadeFromRight;
|
|
private float _fadeLerp;
|
|
private bool _fading;
|
|
private Vector3 _panelPosition;
|
|
//private Task<DatabaseCache> _databaseLoad;
|
|
|
|
void Start()
|
|
{
|
|
// Start loading the database in the background 'cause it takes a few seconds
|
|
//_databaseLoad = Task.Run(() => ActionGameManager.Database);
|
|
|
|
if(panelPrototype == null)
|
|
{
|
|
gameObject.SetActive(false);
|
|
return;
|
|
}
|
|
|
|
_panelPosition = panelPrototype.transform.position;
|
|
|
|
var panel1 = panelPrototype.Instantiate<GeneratorPanel>();
|
|
_currentMenu = (panel1, panel1.GetComponent<CanvasGroup>());
|
|
|
|
var panel2 = panelPrototype.Instantiate<GeneratorPanel>();
|
|
_nextMenu = (panel2, panel2.GetComponent<CanvasGroup>());
|
|
|
|
_currentMenu.panel.gameObject.SetActive(false);
|
|
//_saveDirectory = ActionGameManager.GameDataDirectory.CreateSubdirectory("Saves");
|
|
|
|
ShowMain();
|
|
Fade(true);
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (_fading)
|
|
{
|
|
_fadeLerp += Time.deltaTime / fadeTime;
|
|
|
|
_currentMenu.panel.transform.position =
|
|
_panelPosition + (_fadeFromRight ? Vector3.left : Vector3.right) * (fadeDistance * pow(_fadeLerp, fadePositionExponent));
|
|
_nextMenu.panel.transform.position =
|
|
_panelPosition + (_fadeFromRight ? Vector3.right : Vector3.left) * (fadeDistance * pow(1-_fadeLerp, fadePositionExponent));
|
|
_currentMenu.group.alpha = pow(1 - _fadeLerp, fadeAlphaExponent);
|
|
_nextMenu.group.alpha = pow(_fadeLerp, fadeAlphaExponent);
|
|
|
|
if (_fadeLerp > 1)
|
|
{
|
|
_fading = false;
|
|
_currentMenu.panel.gameObject.SetActive(false);
|
|
(_currentMenu, _nextMenu) = (_nextMenu, _currentMenu);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Fade(bool fromRight)
|
|
{
|
|
_nextMenu.panel.gameObject.SetActive(true);
|
|
_nextMenu.group.alpha = 0;
|
|
_fading = true;
|
|
_fadeLerp = 0;
|
|
_fadeFromRight = fromRight;
|
|
}
|
|
|
|
private void ShowMain()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.TitleSubtitle("CultUI", "Runtime UI Composition");
|
|
_nextMenu.panel.AddTextButton("Demos",
|
|
() =>
|
|
{
|
|
ShowDemos();
|
|
Fade(true);
|
|
});
|
|
_nextMenu.panel.AddTextButton("Settings",
|
|
() =>
|
|
{
|
|
ShowSettings();
|
|
Fade(true);
|
|
});
|
|
_nextMenu.panel.AddTextButton("Quit", Application.Quit);
|
|
}
|
|
|
|
private void ShowDemos()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.TitleSubtitle("CultUI", "Demos");
|
|
_nextMenu.panel.AddTextButton("Procedural Inspector",
|
|
() =>
|
|
{
|
|
ShowProceduralInspectorDemo();
|
|
Fade(true);
|
|
});
|
|
_nextMenu.panel.AddTextButton("Back",
|
|
() =>
|
|
{
|
|
ShowMain();
|
|
Fade(false);
|
|
});
|
|
}
|
|
|
|
private void ShowProceduralInspectorDemo()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.TitleSubtitle("CultUI", "Code Inspector");
|
|
_nextMenu.panel.AddLabel("For the common task of inspector-style menus with a label and a field, " +
|
|
"CultUI offers convenient extensions " +
|
|
"which compose a label and a field handler within a horizontal scope. " +
|
|
"This can be great for options menus or in-game properties panels.");
|
|
_nextMenu.panel.AddLabelInspector("Read-only String", () => $"{Time.time:F1}");
|
|
var testString = "Test String";
|
|
_nextMenu.panel.AddStringInspector("String", () => testString, s => testString = s);
|
|
var testFloat = 10f;
|
|
_nextMenu.panel.AddFloatInspector("Float", () => testFloat, f => testFloat = f);
|
|
var testRangedFloat = 10f;
|
|
_nextMenu.panel.AddRangedFloatInspector("Ranged Float", () => testRangedFloat, f => testRangedFloat = f, 0, 100);
|
|
var testBool = true;
|
|
_nextMenu.panel.AddBoolInspector("Bool", () => testBool, b => testBool = b);
|
|
var testIncrement = 3;
|
|
_nextMenu.panel.AddIncrementIntInspector("Increment Int", () => testIncrement, i => testIncrement = i, 0, 5);
|
|
_nextMenu.panel.AddButtonInspector("Button", "Click me!", () => Debug.Log("Button Clicked!"));
|
|
var testEnum = Animals.Dog;
|
|
_nextMenu.panel.AddEnumInspector("Enum", () => testEnum, e => testEnum = e);
|
|
var testFlags = AnimalTraits.HasFur;
|
|
_nextMenu.panel.AddFlagsEnumInspector("Flags", () => testFlags, f => testFlags = f);
|
|
_nextMenu.panel.AddTextButton("Back",
|
|
() =>
|
|
{
|
|
ShowDemos();
|
|
Fade(false);
|
|
});
|
|
}
|
|
|
|
[Flags]
|
|
enum AnimalTraits
|
|
{
|
|
None = 0,
|
|
HasFur = 1,
|
|
CanFly = 2,
|
|
CanSwim = 4,
|
|
IsVenomous = 8,
|
|
Nocturnal = 16
|
|
}
|
|
|
|
private enum Animals
|
|
{
|
|
Dog,
|
|
Cat,
|
|
Bird
|
|
}
|
|
|
|
private void ShowSettings()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.Title = "Settings";
|
|
_nextMenu.panel.AddTextButton("Gameplay",
|
|
() =>
|
|
{
|
|
ShowGameplaySettings();
|
|
Fade(true);
|
|
});
|
|
_nextMenu.panel.AddTextButton("Graphics",
|
|
() =>
|
|
{
|
|
ShowGraphicsSettings();
|
|
Fade(true);
|
|
});
|
|
_nextMenu.panel.AddTextButton("Input",
|
|
() =>
|
|
{
|
|
ShowInputSettings();
|
|
Fade(true);
|
|
});
|
|
_nextMenu.panel.AddTextButton("Audio",
|
|
() =>
|
|
{
|
|
ShowAudioSettings();
|
|
Fade(true);
|
|
});
|
|
_nextMenu.panel.AddTextButton("Back",
|
|
() =>
|
|
{
|
|
ShowMain();
|
|
Fade(false);
|
|
});
|
|
}
|
|
|
|
private void ShowGameplaySettings()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.TitleSubtitle("Gameplay", "Settings");
|
|
_nextMenu.panel.AddTextButton("Back",
|
|
() =>
|
|
{
|
|
ShowSettings();
|
|
Fade(false);
|
|
});
|
|
}
|
|
|
|
private void ShowGraphicsSettings()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.TitleSubtitle("Graphics", "Settings");
|
|
_nextMenu.panel.AddTextButton("Back",
|
|
() =>
|
|
{
|
|
ShowSettings();
|
|
Fade(false);
|
|
});
|
|
}
|
|
|
|
private void ShowInputSettings()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.TitleSubtitle("Input", "Settings");
|
|
_nextMenu.panel.AddTextButton("Back",
|
|
() =>
|
|
{
|
|
ShowSettings();
|
|
Fade(false);
|
|
});
|
|
}
|
|
|
|
private void ShowAudioSettings()
|
|
{
|
|
_nextMenu.panel.Clear();
|
|
_nextMenu.panel.TitleSubtitle("Audio", "Settings");
|
|
_nextMenu.panel.AddTextButton("Back",
|
|
() =>
|
|
{
|
|
ShowSettings();
|
|
Fade(false);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Prototype.cs
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
/// <summary>
|
|
/// Convenience helper that lets you create "prototype" GameObjects in-scene which act like prefabs:<br/>
|
|
/// - The prototype (root) exists in the scene and deactivates itself on start.<br/>
|
|
/// - You can Instantiate<T>() to get an active copy in the same parent/transform space.<br/>
|
|
/// - When finished, call ReturnToPool() on the instance to return it to the prototype's pool
|
|
/// so it can be reused instead of being destroyed and recreated.
|
|
/// </summary>
|
|
public class Prototype : MonoBehaviour
|
|
{
|
|
/// <summary>
|
|
/// Fired when an instance is returned into the prototype's pool (after it is deactivated).
|
|
/// </summary>
|
|
public event Action? OnReturnToPool;
|
|
|
|
// True when this component is the prototype/root (i.e. not a pooled instance).
|
|
public bool IsPrototype => _rootPrototype is null;
|
|
|
|
// Always returns the prototype/root for this object. If this object is the prototype, it returns itself.
|
|
public Prototype RootPrototype => _rootPrototype ?? this;
|
|
|
|
private void Start()
|
|
{
|
|
// Prototype objects remain inactive in the scene by default.
|
|
if (IsPrototype)
|
|
gameObject.SetActive(false);
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
// When the prototype is destroyed, clean up pooled instances too.
|
|
if (_instancePool != null)
|
|
{
|
|
foreach (var instance in _instancePool)
|
|
Destroy(instance.gameObject);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Instantiate a copy of this Prototype and return the requested component T from the instance.
|
|
/// If there is an available instance in the pool, reuse it.
|
|
/// </summary>
|
|
public T Instantiate<T>() where T : Component
|
|
{
|
|
Prototype instance;
|
|
|
|
// Re-use from pool if possible (pool belongs to the prototype/root).
|
|
var poolOwner = RootPrototype;
|
|
if (poolOwner._instancePool != null && poolOwner._instancePool.Count > 0)
|
|
{
|
|
var lastIndex = poolOwner._instancePool.Count - 1;
|
|
instance = poolOwner._instancePool[lastIndex];
|
|
poolOwner._instancePool.RemoveAt(lastIndex);
|
|
instance.transform.SetAsLastSibling();
|
|
}
|
|
else
|
|
{
|
|
// Create a fresh instance from this object (clone entire GameObject).
|
|
// Place it under the same parent as the prototype.
|
|
instance = UnityEngine.Object.Instantiate(this, transform.parent, false);
|
|
|
|
// For UI RectTransforms, copy anchors/pivot/sizeDelta so layout matches.
|
|
if (transform is RectTransform protoRT &&
|
|
instance.transform is RectTransform instRT)
|
|
{
|
|
instRT.anchorMin = protoRT.anchorMin;
|
|
instRT.anchorMax = protoRT.anchorMax;
|
|
instRT.pivot = protoRT.pivot;
|
|
instRT.sizeDelta = protoRT.sizeDelta;
|
|
}
|
|
|
|
// Match local transform values exactly.
|
|
instance.transform.localPosition = transform.localPosition;
|
|
instance.transform.localRotation = transform.localRotation;
|
|
instance.transform.localScale = transform.localScale;
|
|
|
|
// Mark this new copy as an instance of this prototype.
|
|
instance._rootPrototype = this;
|
|
}
|
|
|
|
instance.gameObject.SetActive(true);
|
|
return instance.GetComponent<T>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Return this instance to its prototype's pool for reuse.
|
|
/// Can only be called on an instance (not on the prototype/root itself).
|
|
/// </summary>
|
|
public void ReturnToPool()
|
|
{
|
|
if (IsPrototype)
|
|
{
|
|
Debug.LogError($"Cannot ReturnToPool: '{name}' appears to be the prototype root (it has no root prototype). " +
|
|
"Only instantiated instances can be returned to a pool.");
|
|
Destroy(gameObject);
|
|
return;
|
|
}
|
|
|
|
// Move instance back under the prototype's parent and add to pool.
|
|
transform.SetParent(RootPrototype.transform.parent);
|
|
RootPrototype.AddToPool(this);
|
|
|
|
// Clear any handlers to avoid leaking references while pooled.
|
|
OnReturnToPool = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the requested component on the prototype (if this is the prototype) or on the root prototype.
|
|
/// </summary>
|
|
public T GetOriginal<T>()
|
|
{
|
|
return RootPrototype.GetComponent<T>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an instance back into this prototype's pool.
|
|
/// This method is internal to the prototype; it should only be called on the prototype/root.
|
|
/// </summary>
|
|
private void AddToPool(Prototype instance)
|
|
{
|
|
if (!IsPrototype)
|
|
{
|
|
Debug.LogError($"Attempting to add '{instance.name}' to the pool of '{name}', but '{name}' is not the prototype root.");
|
|
// still attempt to add to the real root if available
|
|
if (_rootPrototype is not null)
|
|
{
|
|
_rootPrototype.AddToPool(instance);
|
|
return;
|
|
}
|
|
}
|
|
|
|
instance.gameObject.SetActive(false);
|
|
|
|
_instancePool ??= new List<Prototype>();
|
|
_instancePool.Add(instance);
|
|
|
|
// Notify any subscribers that this instance has returned to the pool.
|
|
instance.OnReturnToPool?.Invoke();
|
|
}
|
|
|
|
// Reference to the prototype/root. Non-null on instances; null on the prototype itself.
|
|
private Prototype? _rootPrototype;
|
|
|
|
// Pool of instances owned by the prototype/root.
|
|
private List<Prototype>? _instancePool;
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\ReflectiveResolver.cs
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using GameCult.Unity.UI.Components;
|
|
using UnityEngine;
|
|
using Object = UnityEngine.Object;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
[CreateAssetMenu(menuName="Inspector/Reflective Resolver")]
|
|
public class ReflectiveResolver : ScriptableObject, IUIResolver
|
|
{
|
|
[SerializeField] private List<LayoutComponent> fieldPrefabs = new();
|
|
[SerializeField] private List<ResolverComponent> componentPrefabs = new();
|
|
|
|
private List<IFieldHandler> _handlers = new();
|
|
|
|
private void Awake()
|
|
{
|
|
foreach (var prefab in fieldPrefabs)
|
|
{
|
|
if (prefab is not IFieldHandler)
|
|
{
|
|
Debug.LogWarning($"Prefab {prefab.gameObject.name} in Field Prefabs is not IFieldHandler");
|
|
}
|
|
_handlers.Add((IFieldHandler)prefab);
|
|
}
|
|
_handlers = _handlers.OrderByDescending(h => h.Priority).ToList();
|
|
}
|
|
|
|
public IFieldHandler? ResolveField(Type type, PreferredInspectorAttribute? attribute)
|
|
{
|
|
var match = _handlers.FirstOrDefault(h => h.CanHandle(type, attribute));
|
|
if (match != null) return (IFieldHandler)Instantiate((LayoutComponent)match);
|
|
Debug.LogWarning($"{name} unable to resolve {type}" +
|
|
(attribute != null ? $" with attribute {attribute.GetType().Name}" : ""));
|
|
return null;
|
|
}
|
|
|
|
public T? Resolve<T>(string? prefabName = null) where T : ResolverComponent
|
|
{
|
|
foreach (var component in componentPrefabs)
|
|
{
|
|
if (component is T t && (prefabName==null || t.gameObject.name.StartsWith(prefabName))) return Instantiate(t);
|
|
}
|
|
foreach (var field in fieldPrefabs)
|
|
{
|
|
if (field is T t && (prefabName==null || t.gameObject.name.StartsWith(prefabName))) return Instantiate(t);
|
|
}
|
|
|
|
Debug.LogWarning($"{name} unable to resolve {typeof(T).Name}" + (prefabName != null ? $" with name {prefabName}" : ""));
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\StringExtensions.cs
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace GameCult.Unity.UI
|
|
{
|
|
public static class StringExtensions
|
|
{
|
|
public static string SplitCamelCase( this string str )
|
|
{
|
|
return Regex.Replace(
|
|
Regex.Replace(
|
|
str,
|
|
@"(\P{Ll})(\P{Ll}\p{Ll})",
|
|
"$1 $2"
|
|
),
|
|
@"(\p{Ll})(\P{Ll})",
|
|
"$1 $2"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\BoolField.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.Serialization;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class BoolField : LayoutComponent, IFieldHandler
|
|
{
|
|
[SerializeField] private Toggle? toggle;
|
|
|
|
public int Priority => 10;
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
type == typeof(bool) && attr == null;
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? options = null)
|
|
{
|
|
Func<bool> read;
|
|
Action<bool> write;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
read = () => (bool)field.GetValue(target);
|
|
write = b => field.SetValue(target, b);
|
|
break;
|
|
case PropertyInfo property:
|
|
read = () => (bool)property.GetValue(target);
|
|
write = b => property.SetValue(target, b);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
Configure(context, read, write, options);
|
|
}
|
|
|
|
public BoolField Configure(IUIContext context, Func<bool> read, Action<bool> write, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (toggle is null) return this;
|
|
toggle.isOn = read();
|
|
toggle.onValueChanged.AddListener(b => write(b));
|
|
|
|
toggle.interactable = displayOptions?.Interactable??true;
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
|
|
context.Refresh += () => toggle.isOn = read();
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public static class BoolFieldGeneratorExtension
|
|
{
|
|
public static void AddBoolInspector(this IUIContext context,
|
|
string label,
|
|
Func<bool> read,
|
|
Action<bool> write,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<BoolField>(prefabName)?.Configure(context, read, write));
|
|
|
|
public static void AddBoolField(this IUIContext context,
|
|
Func<bool> read,
|
|
Action<bool> write,
|
|
string? prefabName = null,
|
|
DisplayOptions? displayOptions = null) =>
|
|
context.Resolver.Resolve<BoolField>(prefabName)?.Configure(context, read, write, displayOptions);
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ButtonField.cs
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ButtonField.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.Serialization;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class ButtonField : LayoutComponent, IFieldHandler, ITextElement
|
|
{
|
|
[SerializeField] private TextMeshProUGUI? buttonLabel;
|
|
[SerializeField] private Button? button;
|
|
|
|
public int Priority => 10;
|
|
|
|
public TextMeshProUGUI? Text => buttonLabel;
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
type == typeof(Action);
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
|
|
{
|
|
Action action;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
action = (Action)field.GetValue(target);
|
|
break;
|
|
case PropertyInfo property:
|
|
action = (Action)property.GetValue(target);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
Configure(context, member.Name, action, displayOptions);
|
|
}
|
|
|
|
public ButtonField Configure(IUIContext context, string labelText, Action onClick, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (buttonLabel is null || button is null) return this;
|
|
buttonLabel.text = labelText;
|
|
button.onClick.AddListener(() => onClick());
|
|
|
|
button.interactable = displayOptions?.Interactable??true;
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
|
|
public Component Component => this;
|
|
}
|
|
|
|
public static class ButtonFieldGeneratorExtension
|
|
{
|
|
public static void AddButtonInspector(this IUIContext context,
|
|
string label,
|
|
string buttonLabel,
|
|
Action onClick,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<ButtonField>(prefabName)?.Configure(context, buttonLabel, onClick));
|
|
|
|
public static void AddButton(this IUIContext context,
|
|
string label,
|
|
Action onClick,
|
|
DisplayOptions? displayOptions = null,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<ButtonField>(prefabName)?.Configure(context, label, onClick, displayOptions);
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ClickCatcher.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using R3;
|
|
using UnityEngine.EventSystems;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class ClickCatcher : ResolverComponent, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerClickHandler
|
|
{
|
|
public float dragDistance = 25f;
|
|
|
|
public bool PointerIsInside { get; private set; }
|
|
|
|
public Subject<PointerEventData> OnEnter = new Subject<PointerEventData>();
|
|
public Subject<PointerEventData> OnExit = new Subject<PointerEventData>();
|
|
public Subject<PointerEventData> OnClick = new Subject<PointerEventData>();
|
|
public Subject<PointerEventData> OnDown = new Subject<PointerEventData>();
|
|
|
|
public void OnPointerEnter(PointerEventData eventData)
|
|
{
|
|
//Debug.Log("Pointer Entered");
|
|
OnEnter.OnNext(eventData);
|
|
PointerIsInside = true;
|
|
}
|
|
|
|
public void OnPointerExit(PointerEventData eventData)
|
|
{
|
|
//Debug.Log("Pointer Exited");
|
|
OnExit.OnNext(eventData);
|
|
PointerIsInside = false;
|
|
}
|
|
|
|
public void OnPointerDown(PointerEventData eventData)
|
|
{
|
|
OnDown.OnNext(eventData);
|
|
}
|
|
|
|
public void OnPointerClick(PointerEventData eventData)
|
|
{
|
|
if((eventData.pressPosition - eventData.position).sqrMagnitude < dragDistance)
|
|
OnClick.OnNext(eventData);
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
OnEnter.Dispose();
|
|
OnExit.Dispose();
|
|
OnClick.Dispose();
|
|
OnDown.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ColorModal.cs
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using TMPro;
|
|
using Unity.Mathematics;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using static GameCult.Unity.UI.ColorConversion;
|
|
using static Unity.Mathematics.math;
|
|
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class ColorModal : Modal
|
|
{
|
|
private static readonly int Mode = Shader.PropertyToID("_Mode");
|
|
private static readonly int H = Shader.PropertyToID("_H");
|
|
private static readonly int S = Shader.PropertyToID("_S");
|
|
private static readonly int V = Shader.PropertyToID("_V");
|
|
private static readonly int Value = Shader.PropertyToID("_Value");
|
|
[SerializeField] private ConstrainedSlider2D colorCircle;
|
|
[SerializeField] private Slider hueSlider;
|
|
[SerializeField] private Slider satSlider;
|
|
[SerializeField] private Slider valSlider;
|
|
[SerializeField] private EnumField modeEnum;
|
|
[SerializeField] private TMP_InputField hexStringField;
|
|
[SerializeField] private Image circleImage;
|
|
[SerializeField] private Image previewImage;
|
|
[SerializeField] private List<Image> sliderBackgrounds = new();
|
|
[SerializeField] private List<Image> sliderHandles = new();
|
|
[SerializeField] private List<Image> sliderHandleOutlines = new();
|
|
[SerializeField] private List<Material> circleMaterials = new();
|
|
[SerializeField] private List<Material> hueSliderMaterials = new();
|
|
[SerializeField] private List<Material> satSliderMaterials = new();
|
|
[SerializeField] private List<Material> valSliderMaterials = new();
|
|
|
|
private Color _color = Color.red;
|
|
private float3 _hsv;
|
|
private ColorMode _mode = ColorMode.HSV;
|
|
|
|
private float3 HSV
|
|
{
|
|
get => _hsv;
|
|
set
|
|
{
|
|
_hsv = value;
|
|
circleImage.material.SetFloat(Value, _hsv.z);
|
|
foreach (var image in sliderBackgrounds)
|
|
{
|
|
image.material.SetFloat(H, _hsv.x);
|
|
image.material.SetFloat(S, _hsv.y);
|
|
image.material.SetFloat(V, _hsv.z);
|
|
}
|
|
|
|
colorCircle.Value = PolarToRect(_hsv.xy);
|
|
hueSlider.value = _hsv.x;
|
|
satSlider.value = _hsv.y;
|
|
valSlider.value = _hsv.z;
|
|
RefreshColor();
|
|
}
|
|
}
|
|
|
|
private Color Color
|
|
{
|
|
get => _color;
|
|
set
|
|
{
|
|
_color = value;
|
|
foreach (var handle in sliderHandles)
|
|
{
|
|
handle.color = value;
|
|
}
|
|
previewImage.color = value;
|
|
RefreshHex();
|
|
}
|
|
}
|
|
|
|
private float2 RectToPolar(float2 rect)
|
|
{
|
|
var offset = rect * 2 - float2(1,1);
|
|
return float2(atan2(offset.y, -offset.x) / PI2 + .5f, length(offset));
|
|
}
|
|
|
|
private float2 PolarToRect(float2 polar)
|
|
{
|
|
float angle = (polar.x - 0.5f) * PI2;
|
|
float r = polar.y;
|
|
var offset = float2(-r * cos(angle), r * sin(angle));
|
|
return (offset + float2(1,1)) / 2;
|
|
}
|
|
|
|
private void RefreshColor()
|
|
{
|
|
Color = _mode switch
|
|
{
|
|
ColorMode.HSV => HSVtoRGB(HSV).ToColor().gamma,
|
|
ColorMode.HSL => HSLtoRGB(HSV).ToColor().gamma,
|
|
ColorMode.HCY => HCYtoRGB(HSV).ToColor().gamma,
|
|
_ => throw new ArgumentOutOfRangeException()
|
|
};
|
|
}
|
|
|
|
private void RefreshHSV()
|
|
{
|
|
HSV = _mode switch
|
|
{
|
|
ColorMode.HSV => RGBtoHSV(_color.linear.ToFloat3()),
|
|
ColorMode.HSL => RGBtoHSL(_color.linear.ToFloat3()),
|
|
ColorMode.HCY => RGBtoHCY(_color.linear.ToFloat3()),
|
|
_ => throw new ArgumentOutOfRangeException()
|
|
};
|
|
}
|
|
|
|
private void RefreshHex() => hexStringField.text = '#' + ColorUtility.ToHtmlStringRGB(Color);
|
|
|
|
private void SetColorMode(ColorMode mode)
|
|
{
|
|
circleImage.material = circleMaterials[(int)mode];
|
|
sliderBackgrounds[0].material = hueSliderMaterials[(int)mode];
|
|
sliderBackgrounds[1].material = satSliderMaterials[(int)mode];
|
|
sliderBackgrounds[2].material = valSliderMaterials[(int)mode];
|
|
}
|
|
|
|
// BUG: This should work, but neither setting the mode integer or shader keywords actually affects the material
|
|
// private void SetColorMode(Material mat, ColorMode mode)
|
|
// {
|
|
// mat.SetInteger("_Mode", (int)mode);
|
|
// var enumType = typeof(ColorMode);
|
|
// var names = Enum.GetNames(enumType);
|
|
// var values = Enum.GetValues(enumType);
|
|
// for (var i = 0; i < names.Length; i++)
|
|
// {
|
|
// if((ColorMode)values.GetValue(i) == mode)
|
|
// {
|
|
// mat.EnableKeyword("_Mode_" + names[i]);
|
|
// Debug.Log($"Enabling keyword {"_Mode_" + names[i]} on {mat.name}");
|
|
// }
|
|
// else
|
|
// {
|
|
// mat.DisableKeyword("_Mode_" + names[i]);
|
|
// Debug.Log($"Disabling keyword {"_Mode_" + names[i]} on {mat.name}");
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
private void OnEnable()
|
|
{
|
|
colorCircle.ConstrainFunction = v =>
|
|
{
|
|
var offset = v * 2 - Vector2.one;
|
|
if (offset.sqrMagnitude > 1) offset = offset.normalized;
|
|
return (offset + Vector2.one) / 2;
|
|
};
|
|
colorCircle.OnValueChanged.AddListener(v => HSV = float3(RectToPolar(v),_hsv.z));
|
|
hueSlider.onValueChanged.AddListener(h => HSV = float3(h, _hsv.yz));
|
|
satSlider.onValueChanged.AddListener(s => HSV = float3(_hsv.x, s, _hsv.z));
|
|
valSlider.onValueChanged.AddListener(v => HSV = float3(_hsv.xy, v));
|
|
|
|
var enumType = typeof(ColorMode);
|
|
var names = Enum.GetNames(enumType);
|
|
var values = Enum.GetValues(enumType);
|
|
modeEnum.Configure(this,
|
|
() => Array.IndexOf(values, _mode),
|
|
i =>
|
|
{
|
|
_mode = (ColorMode)values.GetValue(i);
|
|
SetColorMode(_mode);
|
|
RefreshHSV();
|
|
}, names, new DisplayOptions(placeInContext:false));
|
|
RefreshHSV();
|
|
|
|
hexStringField.onEndEdit.AddListener(s =>
|
|
{
|
|
if(ColorUtility.TryParseHtmlString(s, out Color color))
|
|
{
|
|
Color = color;
|
|
RefreshHSV();
|
|
}
|
|
else
|
|
RefreshHex();
|
|
});
|
|
RefreshHex();
|
|
}
|
|
|
|
enum ColorMode
|
|
{
|
|
HSV,
|
|
HSL,
|
|
HCY
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ConstrainedSlider2D.cs
|
|
|
|
using System;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
/// <summary>
|
|
/// A UI element similar to Slider but allowing free 2D movement of the handle within a rect,
|
|
/// constrained by an external validation function.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The handle RectTransform is driven by the ConstrainedHandle. The position is normalized (0-1) in both axes.
|
|
/// An external Func<Vector2, Vector2> can be provided via constrainFunction to validate/constrain positions (e.g., to a circle for HSV picker).
|
|
/// When a change to the handle value occurs, a callback is sent to any registered listeners of UI.ConstrainedHandle.onValueChanged.
|
|
/// </remarks>
|
|
[AddComponentMenu("UI/Constrained Slider 2D")]
|
|
[ExecuteAlways]
|
|
[RequireComponent(typeof(RectTransform))]
|
|
public class ConstrainedSlider2D : Selectable, IDragHandler, IInitializePotentialDragHandler, ICanvasElement
|
|
{
|
|
[Serializable]
|
|
public class ConstrainedSlider2DEvent : UnityEvent<Vector2> { }
|
|
|
|
[SerializeField]
|
|
private RectTransform? handleRect;
|
|
|
|
public RectTransform? HandleRect
|
|
{
|
|
get => handleRect;
|
|
set
|
|
{
|
|
if (handleRect == value) return;
|
|
handleRect = value;
|
|
UpdateCachedReferences();
|
|
UpdateVisuals();
|
|
}
|
|
}
|
|
|
|
[Space]
|
|
|
|
[SerializeField]
|
|
private ConstrainedSlider2DEvent onValueChanged = new ConstrainedSlider2DEvent();
|
|
|
|
public ConstrainedSlider2DEvent OnValueChanged
|
|
{
|
|
get => onValueChanged;
|
|
set => onValueChanged = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// External function to constrain the normalized position (Vector2 in [0,1] range).
|
|
/// Defaults to identity (no constraint).
|
|
/// </summary>
|
|
public System.Func<Vector2, Vector2> ConstrainFunction { get; set; } = pos => pos;
|
|
|
|
// Private fields
|
|
private RectTransform? _handleContainerRect;
|
|
private Vector2 _offset = Vector2.zero;
|
|
|
|
// field is never assigned warning
|
|
#pragma warning disable 649
|
|
private DrivenRectTransformTracker _tracker;
|
|
#pragma warning restore 649
|
|
|
|
// This "delayed" mechanism is required for case 1037681.
|
|
private bool _delayedUpdateVisuals;
|
|
|
|
[SerializeField]
|
|
protected Vector2 value = new(0.5f, 0.5f);
|
|
|
|
/// <summary>
|
|
/// Normalized value (0-1 in both axes) representing handle position fraction within the container.
|
|
/// </summary>
|
|
public virtual Vector2 Value
|
|
{
|
|
get => value;
|
|
set => Set(value);
|
|
}
|
|
|
|
public virtual void SetValueWithoutNotify(Vector2 input)
|
|
{
|
|
Set(input, false);
|
|
}
|
|
|
|
protected ConstrainedSlider2D() { }
|
|
|
|
#if UNITY_EDITOR
|
|
protected override void OnValidate()
|
|
{
|
|
base.OnValidate();
|
|
|
|
// OnValidate is called before OnEnable. We need to make sure not to touch any other objects before OnEnable is run.
|
|
if (IsActive())
|
|
{
|
|
UpdateCachedReferences();
|
|
// Update rects in next update since other things might affect them even if value didn't change.
|
|
_delayedUpdateVisuals = true;
|
|
}
|
|
|
|
if (!PrefabUtility.IsPartOfPrefabAsset(this) && !Application.isPlaying)
|
|
CanvasUpdateRegistry.RegisterCanvasElementForLayoutRebuild(this);
|
|
}
|
|
#endif
|
|
|
|
public virtual void Rebuild(CanvasUpdate executing)
|
|
{
|
|
#if UNITY_EDITOR
|
|
if (executing == CanvasUpdate.Prelayout)
|
|
OnValueChanged.Invoke(Value);
|
|
#endif
|
|
}
|
|
|
|
/// <summary>
|
|
/// See ICanvasElement.LayoutComplete
|
|
/// </summary>
|
|
public virtual void LayoutComplete() { }
|
|
|
|
/// <summary>
|
|
/// See ICanvasElement.GraphicUpdateComplete
|
|
/// </summary>
|
|
public virtual void GraphicUpdateComplete() { }
|
|
|
|
protected override void OnEnable()
|
|
{
|
|
base.OnEnable();
|
|
UpdateCachedReferences();
|
|
Set(value, false);
|
|
// Update rects since they need to be initialized correctly.
|
|
UpdateVisuals();
|
|
#if UNITY_EDITOR
|
|
Undo.undoRedoEvent -= OnUndoRedoEvent;
|
|
Undo.undoRedoEvent += OnUndoRedoEvent;
|
|
#endif
|
|
}
|
|
|
|
protected override void OnDisable()
|
|
{
|
|
#if UNITY_EDITOR
|
|
Undo.undoRedoEvent -= OnUndoRedoEvent;
|
|
#endif
|
|
_tracker.Clear();
|
|
base.OnDisable();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the rect based on the delayed update visuals.
|
|
/// Got around issue of calling SendMessage from OnValidate.
|
|
/// </summary>
|
|
protected virtual void Update()
|
|
{
|
|
if (_delayedUpdateVisuals)
|
|
{
|
|
_delayedUpdateVisuals = false;
|
|
Set(value, false);
|
|
UpdateVisuals();
|
|
}
|
|
}
|
|
|
|
protected override void OnDidApplyAnimationProperties()
|
|
{
|
|
Vector2 animatedValue = ComputeValueFromVisuals();
|
|
if (animatedValue != value)
|
|
{
|
|
value = animatedValue;
|
|
OnValueChanged.Invoke(value);
|
|
}
|
|
base.OnDidApplyAnimationProperties();
|
|
}
|
|
|
|
private Vector2 ComputeValueFromVisuals()
|
|
{
|
|
if (_handleContainerRect is null || handleRect is null) return value;
|
|
Vector2 size = _handleContainerRect.rect.size;
|
|
if (size == Vector2.zero) return value;
|
|
Vector2 delta = handleRect.anchoredPosition;
|
|
return new Vector2(
|
|
delta.x / size.x + 0.5f,
|
|
delta.y / size.y + 0.5f
|
|
);
|
|
}
|
|
|
|
void UpdateCachedReferences()
|
|
{
|
|
if (handleRect && handleRect != (RectTransform)transform)
|
|
{
|
|
_handleContainerRect = handleRect.parent as RectTransform;
|
|
}
|
|
else
|
|
{
|
|
handleRect = null;
|
|
_handleContainerRect = null;
|
|
}
|
|
}
|
|
|
|
protected virtual void Set(Vector2 input, bool sendCallback = true)
|
|
{
|
|
// Apply constraint and clamp
|
|
Vector2 newValue = ConstrainFunction(input);
|
|
newValue.x = Mathf.Clamp01(newValue.x);
|
|
newValue.y = Mathf.Clamp01(newValue.y);
|
|
|
|
if (value == newValue)
|
|
return;
|
|
|
|
value = newValue;
|
|
UpdateVisuals();
|
|
if (sendCallback)
|
|
{
|
|
UISystemProfilerApi.AddMarker("ConstrainedHandle.value", this);
|
|
onValueChanged.Invoke(newValue);
|
|
}
|
|
}
|
|
|
|
protected override void OnRectTransformDimensionsChange()
|
|
{
|
|
base.OnRectTransformDimensionsChange();
|
|
|
|
// This can be invoked before OnEnable is called. So we shouldn't be accessing other objects before OnEnable is called.
|
|
if (!IsActive())
|
|
return;
|
|
|
|
UpdateVisuals();
|
|
}
|
|
|
|
#if UNITY_EDITOR
|
|
void OnUndoRedoEvent(in UndoRedoInfo undo)
|
|
{
|
|
UpdateVisuals();
|
|
}
|
|
#endif
|
|
|
|
// Force-update the handle. Useful if you've changed the properties and want it to update visually.
|
|
private void UpdateVisuals()
|
|
{
|
|
#if UNITY_EDITOR
|
|
if (!Application.isPlaying)
|
|
UpdateCachedReferences();
|
|
#endif
|
|
|
|
_tracker.Clear();
|
|
|
|
if (_handleContainerRect != null && handleRect != null)
|
|
{
|
|
_tracker.Add(this, handleRect, DrivenTransformProperties.AnchoredPosition);
|
|
Vector2 size = _handleContainerRect.rect.size;
|
|
if (size != Vector2.zero)
|
|
{
|
|
Vector2 delta = (value - new Vector2(0.5f, 0.5f)) * size;
|
|
handleRect.anchoredPosition = delta;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the handle's position based on the mouse.
|
|
void UpdateDrag(PointerEventData eventData, Camera cam)
|
|
{
|
|
RectTransform? containerRect = _handleContainerRect;
|
|
if (containerRect == null) return;
|
|
|
|
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(containerRect, eventData.position, cam, out var localCursor))
|
|
return;
|
|
|
|
Vector2 candidateAnchored = localCursor - _offset;
|
|
Vector2 size = containerRect.rect.size;
|
|
if (size == Vector2.zero) return;
|
|
|
|
Vector2 candidateNorm = new Vector2(
|
|
candidateAnchored.x / size.x + 0.5f,
|
|
candidateAnchored.y / size.y + 0.5f
|
|
);
|
|
|
|
// Apply constraint here as well for drag consistency
|
|
Vector2 constrainedNorm = ConstrainFunction(candidateNorm);
|
|
constrainedNorm.x = Mathf.Clamp01(constrainedNorm.x);
|
|
constrainedNorm.y = Mathf.Clamp01(constrainedNorm.y);
|
|
|
|
Value = constrainedNorm;
|
|
}
|
|
|
|
private bool MayDrag(PointerEventData eventData)
|
|
{
|
|
return IsActive() && IsInteractable() && eventData.button == PointerEventData.InputButton.Left;
|
|
}
|
|
|
|
public override void OnPointerDown(PointerEventData eventData)
|
|
{
|
|
if (!MayDrag(eventData))
|
|
return;
|
|
|
|
base.OnPointerDown(eventData);
|
|
|
|
_offset = Vector2.zero;
|
|
Camera cam = eventData.pressEventCamera;
|
|
if (handleRect != null && RectTransformUtility.RectangleContainsScreenPoint(handleRect, eventData.pointerPressRaycast.screenPosition, cam))
|
|
{
|
|
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(handleRect, eventData.pointerPressRaycast.screenPosition, cam, out var localMousePos))
|
|
_offset = localMousePos;
|
|
}
|
|
else
|
|
{
|
|
// Outside the handle - jump to this point instead
|
|
UpdateDrag(eventData, cam);
|
|
}
|
|
}
|
|
|
|
public virtual void OnDrag(PointerEventData eventData)
|
|
{
|
|
if (!MayDrag(eventData))
|
|
return;
|
|
UpdateDrag(eventData, eventData.pressEventCamera);
|
|
}
|
|
|
|
public override void OnMove(AxisEventData eventData)
|
|
{
|
|
if (!IsActive() || !IsInteractable())
|
|
{
|
|
base.OnMove(eventData);
|
|
return;
|
|
}
|
|
|
|
Set(value + eventData.moveVector * .05f);
|
|
}
|
|
|
|
public virtual void OnInitializePotentialDrag(PointerEventData eventData)
|
|
{
|
|
eventData.useDragThreshold = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\EnumField.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.Serialization;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class EnumField : LayoutComponent, IFieldHandler, ITextElement
|
|
{
|
|
[SerializeField] private Button? dropdown;
|
|
[SerializeField] private TextMeshProUGUI? dropdownLabel;
|
|
|
|
public TextMeshProUGUI? Text => dropdownLabel;
|
|
|
|
public int Priority => 10;
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
type.IsEnum && type.GetCustomAttribute<FlagsAttribute>() == null;
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
|
|
{
|
|
var fieldType = member is FieldInfo fi ? fi.FieldType : ((PropertyInfo)member).PropertyType;
|
|
var names = Enum.GetNames(fieldType);
|
|
var values = Enum.GetValues(fieldType);
|
|
|
|
Configure(context, () =>
|
|
{
|
|
if(member is FieldInfo field) return Array.IndexOf(values, field.GetValue(target));
|
|
if(member is PropertyInfo property) return Array.IndexOf(values, property.GetValue(target));
|
|
return 0;
|
|
},i =>
|
|
{
|
|
if(member is FieldInfo field) field.SetValue(target, values.GetValue(i));
|
|
if(member is PropertyInfo property) property.SetValue(target, values.GetValue(i));
|
|
}, names, displayOptions);
|
|
}
|
|
|
|
public EnumField Configure(IUIContext context, Func<int> read, Action<int> write, string[] options, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
|
|
if (dropdownLabel is null || dropdown is null) return this;
|
|
|
|
dropdown.onClick.AddListener(() =>
|
|
{
|
|
var modal = context.ShowModal();
|
|
if (modal == null) return;
|
|
for (var i = 0; i < options.Length; i++)
|
|
{
|
|
var i1 = i;
|
|
modal.Resolver.Resolve<ButtonField>("Enum")?.Configure(modal, options[i], () =>
|
|
{
|
|
write(i1);
|
|
Destroy(modal.gameObject);
|
|
});
|
|
}
|
|
modal.AlignVertical((RectTransform)dropdown.transform);
|
|
});
|
|
|
|
dropdown.interactable = displayOptions?.Interactable ?? true;
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
context.Refresh += () => dropdownLabel.text = options[read()];
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public static class EnumFieldGeneratorExtension
|
|
{
|
|
public static void AddEnumInspector(this IUIContext context,
|
|
string label,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
string[] options,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context, read, write, options));
|
|
|
|
public static void AddEnumInspector<TEnum>(this IUIContext context,
|
|
string label,
|
|
Func<TEnum> read,
|
|
Action<TEnum> write,
|
|
string? prefabName = null) where TEnum : Enum
|
|
{
|
|
var enumType = typeof(TEnum);
|
|
var names = Enum.GetNames(enumType);
|
|
var values = Enum.GetValues(enumType);
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context,
|
|
() => Array.IndexOf(values, read()),
|
|
i => write((TEnum)values.GetValue(i)), names));
|
|
}
|
|
|
|
public static void AddEnumField(this IUIContext context,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
string[] options,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context, read, write, options);
|
|
|
|
public static void AddEnumField<TEnum>(this IUIContext context,
|
|
Func<TEnum> read,
|
|
Action<TEnum> write,
|
|
string? prefabName = null) where TEnum : Enum
|
|
{
|
|
var enumType = typeof(TEnum);
|
|
var names = Enum.GetNames(enumType);
|
|
var values = Enum.GetValues(enumType);
|
|
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context,
|
|
() => Array.IndexOf(values, read()),
|
|
i => write((TEnum)values.GetValue(i)), names);
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\GeneratorFoldout.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class GeneratorFoldout : Generator, IFieldHandler, ILayoutElement, ITextElement
|
|
{
|
|
[SerializeField] private TextMeshProUGUI? label;
|
|
[SerializeField] private Image? foldoutIcon;
|
|
[SerializeField] private Button? button;
|
|
[SerializeField] private VerticalLayoutGroup? layoutGroup;
|
|
[SerializeField] private LayoutElement? layoutElement;
|
|
[SerializeField] private float foldoutRotationDamping = 10f;
|
|
[SerializeField] private int foldedPadding = -8;
|
|
[SerializeField] private int expandedPadding = 8;
|
|
public event Action<bool>? OnExpand;
|
|
|
|
private bool _expanded;
|
|
private float _targetFoldoutRotation;
|
|
private float _foldoutRotation;
|
|
|
|
public TextMeshProUGUI? Text => label;
|
|
|
|
public LayoutElement? LayoutElement => layoutElement;
|
|
|
|
public string Title
|
|
{
|
|
set
|
|
{
|
|
if (label != null) label.text = value;
|
|
}
|
|
}
|
|
|
|
public bool Expanded => _expanded;
|
|
|
|
public new void Awake()
|
|
{
|
|
button?.onClick.AddListener(ToggleExpand);
|
|
OnPropertyAdded += go => go.SetActive(_expanded);
|
|
}
|
|
|
|
public override void Update()
|
|
{
|
|
RefreshValues();
|
|
_foldoutRotation =
|
|
Mathf.Lerp(_foldoutRotation, _targetFoldoutRotation, foldoutRotationDamping * Time.deltaTime);
|
|
if (foldoutIcon is not null) foldoutIcon.transform.localRotation = Quaternion.Euler(0, 0, _foldoutRotation);
|
|
}
|
|
|
|
public void ToggleExpand() => SetExpanded(!_expanded, false);
|
|
|
|
public void SetExpanded(bool expanded, bool force)
|
|
{
|
|
_expanded = expanded;
|
|
if (layoutGroup != null)
|
|
{
|
|
var padding = layoutGroup.padding;
|
|
padding = new RectOffset(padding.left, padding.right, padding.top, _expanded ? expandedPadding : foldedPadding);
|
|
layoutGroup.padding = padding;
|
|
}
|
|
|
|
foreach (var property in Elements) property.SetActive(_expanded);
|
|
_targetFoldoutRotation = _expanded ? -90 : 0;
|
|
if (force)
|
|
{
|
|
_foldoutRotation = _targetFoldoutRotation;
|
|
if (foldoutIcon != null) foldoutIcon.transform.localRotation = Quaternion.Euler(0, 0, _foldoutRotation);
|
|
}
|
|
OnExpand?.Invoke(_expanded);
|
|
}
|
|
|
|
public int Priority => 10;
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
type.IsEnum && type.GetCustomAttribute<FlagsAttribute>() != null;
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (label is null || button is null) return;
|
|
var type = member is FieldInfo fi ? fi.FieldType : ((PropertyInfo)member).PropertyType;
|
|
label.text = type.Name.SplitCamelCase();
|
|
var names = Enum.GetNames(type);
|
|
var values = Enum.GetValues(type);
|
|
|
|
// Get current bitfield as ulong for flexibility
|
|
Func<ulong> read = () => Convert.ToUInt64(member is FieldInfo f ? f.GetValue(target)
|
|
: ((PropertyInfo)member).GetValue(target));
|
|
|
|
// Build one checkbox per flag
|
|
foreach (var val in values)
|
|
{
|
|
ulong bit = Convert.ToUInt64(val);
|
|
string flagName = Enum.GetName(type, val) ?? "Default";
|
|
|
|
if (bit == 0)
|
|
{
|
|
// Handle "None" specially
|
|
this.AddBoolInspector(flagName,
|
|
() => Convert.ToUInt64(read()) == 0,
|
|
b =>
|
|
{
|
|
if (b)
|
|
{
|
|
// Setting "None" clears all bits
|
|
var newEnum = Enum.ToObject(type, 0);
|
|
if (member is FieldInfo f2) f2.SetValue(target, newEnum);
|
|
else if (member is PropertyInfo p2) p2.SetValue(target, newEnum);
|
|
}
|
|
// Unsetting None is a no-op
|
|
});
|
|
}
|
|
else
|
|
{
|
|
this.AddBoolInspector(flagName,
|
|
() => (read() & bit) != 0,
|
|
b =>
|
|
{
|
|
ulong value = Convert.ToUInt64(read());
|
|
if (b) value |= bit;
|
|
else value &= ~bit;
|
|
|
|
var newEnum = Enum.ToObject(type, value);
|
|
if (member is FieldInfo f2) f2.SetValue(target, newEnum);
|
|
else if (member is PropertyInfo p2) p2.SetValue(target, newEnum);
|
|
});
|
|
}
|
|
}
|
|
|
|
button.interactable = displayOptions?.Interactable??true;
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
}
|
|
|
|
public GeneratorFoldout Configure<TEnum>(IUIContext context, Func<TEnum> read, Action<TEnum> write, DisplayOptions? displayOptions = null) where TEnum : Enum
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (label is null || button is null) return this;
|
|
label.text = typeof(TEnum).Name.SplitCamelCase();
|
|
|
|
foreach (TEnum val in Enum.GetValues(typeof(TEnum)))
|
|
{
|
|
ulong bit = Convert.ToUInt64(val);
|
|
string flagName = Enum.GetName(typeof(TEnum), val) ?? "Default";
|
|
|
|
if (bit == 0)
|
|
{
|
|
this.AddBoolInspector(flagName,
|
|
() => Convert.ToUInt64(read()) == 0,
|
|
b =>
|
|
{
|
|
if (b)
|
|
{
|
|
write((TEnum)Enum.ToObject(typeof(TEnum), 0UL));
|
|
}
|
|
});
|
|
}
|
|
else
|
|
{
|
|
this.AddBoolInspector(flagName,
|
|
() => (Convert.ToUInt64(read()) & bit) != 0,
|
|
b =>
|
|
{
|
|
ulong value = Convert.ToUInt64(read());
|
|
if (b) value |= bit;
|
|
else value &= ~bit;
|
|
write((TEnum)Enum.ToObject(typeof(TEnum), value));
|
|
});
|
|
}
|
|
}
|
|
|
|
button.interactable = displayOptions?.Interactable??true;
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
|
|
public GeneratorFoldout Configure(IUIContext context, string labelText, DisplayOptions? displayOptions = null, params LayoutComponent[] children)
|
|
{
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
|
|
if (label is null || button is null) return this;
|
|
label.text = labelText;
|
|
button.interactable = displayOptions?.Interactable??true;
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
foreach (var component in children)
|
|
{
|
|
if (component != null) AddComponent(component);
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
public void AddComponent(LayoutComponent component)
|
|
{
|
|
component.transform.SetParent(ContentRoot, false);
|
|
}
|
|
}
|
|
public static class EnumGeneratorExtensions
|
|
{
|
|
public static void AddFlagsEnumInspector<TEnum>(
|
|
this IUIContext context, string label,
|
|
Func<TEnum> read, Action<TEnum> write)
|
|
where TEnum : Enum =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<GeneratorFoldout>()?.Configure(context, read, write));
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\HorizontalGroup.cs
|
|
|
|
using System;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
#pragma warning disable CS8602 // Dereference of a possibly null reference.
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class HorizontalGroup : ResolverComponent, IUIContext
|
|
{
|
|
[SerializeField] private HorizontalLayoutGroup? layoutGroup;
|
|
private IUIContext? _parentContext;
|
|
|
|
public IUIResolver Resolver => _parentContext.Resolver;
|
|
public RectTransform ContentRoot => (RectTransform)transform;
|
|
public Canvas Canvas => _parentContext.Canvas;
|
|
public RectTransform CanvasRoot => _parentContext.CanvasRoot;
|
|
public void Register(GameObject go) => _parentContext.Register(go);
|
|
|
|
public Action? Refresh
|
|
{
|
|
get => _parentContext.Refresh;
|
|
set => _parentContext.Refresh = value;
|
|
}
|
|
|
|
// public DisplayOptions InspectorLabelDisplayOptions => _parentContext.InspectorLabelDisplayOptions;
|
|
// public DisplayOptions InspectorFieldDisplayOptions => _parentContext.InspectorFieldDisplayOptions;
|
|
|
|
public HorizontalGroup Configure(IUIContext context, params ILayoutElement?[] children)
|
|
{
|
|
_parentContext = context;
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
|
|
foreach (var component in children)
|
|
{
|
|
if (component != null) AddComponent(component);
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
public void AddComponent(ILayoutElement component)
|
|
{
|
|
component.LayoutElement?.transform.SetParent(ContentRoot, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\IncrementField.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class IncrementField : LayoutComponent, IFieldHandler, ITextElement
|
|
{
|
|
[SerializeField] private TextMeshProUGUI? value;
|
|
[SerializeField] private Button? increment;
|
|
[SerializeField] private Button? decrement;
|
|
|
|
public int Priority => 100;
|
|
|
|
public TextMeshProUGUI? Text => value;
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
type == typeof(int) && attr is InspectableIncrementIntAttribute;
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
|
|
{
|
|
Func<int> read;
|
|
Action<int> write;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
read = () => (int)field.GetValue(target);
|
|
write = i => field.SetValue(target, i);
|
|
break;
|
|
case PropertyInfo property:
|
|
read = () => (int)property.GetValue(target);
|
|
write = i => property.SetValue(target, i);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
var range = member.GetCustomAttribute<InspectableIncrementIntAttribute>();
|
|
Configure(context, read, write, range.Min, range.Max, displayOptions);
|
|
}
|
|
|
|
public IncrementField Configure(IUIContext context,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
int min,
|
|
int max,
|
|
DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
|
|
if (value is null || increment is null || decrement is null) return this;
|
|
|
|
value.text = read().ToString();
|
|
increment.onClick.AddListener(() => write(Mathf.Min(read() + 1, max)));
|
|
decrement.onClick.AddListener(() => write(Mathf.Max(read() - 1, min)));
|
|
|
|
context.Refresh += () =>
|
|
{
|
|
var val = read();
|
|
value.text = val.ToString();
|
|
increment.interactable = val < max;
|
|
decrement.interactable = val > min;
|
|
};
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public static class IncrementFieldGeneratorExtension
|
|
{
|
|
public static void AddIncrementIntInspector(this IUIContext context,
|
|
string label,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
int min,
|
|
int max,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<IncrementField>(prefabName)?.Configure(context, read, write, min, max));
|
|
|
|
public static void AddIncrementIntField(this IUIContext context,
|
|
string label,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
int min,
|
|
int max,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<IncrementField>(prefabName)?.Configure(context, read, write, min, max);
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\InputField.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class InputField : LayoutComponent, IFieldHandler, IMultipleTextElement
|
|
{
|
|
[SerializeField] private TMP_InputField? inputField;
|
|
[SerializeField] private TextMeshProUGUI[]? textMeshes;
|
|
|
|
public int Priority => 10;
|
|
|
|
public IEnumerable<TextMeshProUGUI> Text => textMeshes ?? Enumerable.Empty<TextMeshProUGUI>();
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
(type == typeof(string) || type == typeof(int) || type == typeof(float)) && attr == null;
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
|
|
{
|
|
var type = member switch
|
|
{
|
|
FieldInfo field => field.FieldType,
|
|
PropertyInfo property => property.PropertyType,
|
|
_ => null
|
|
};
|
|
|
|
if (type == null) return;
|
|
|
|
if (type == typeof(string))
|
|
{
|
|
Func<string> read;
|
|
Action<string> write;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
read = () => (string)field.GetValue(target);
|
|
write = s => field.SetValue(target, s);
|
|
break;
|
|
case PropertyInfo property:
|
|
read = () => (string)property.GetValue(target);
|
|
write = s => property.SetValue(target, s);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
Configure(context, read, write);
|
|
}
|
|
else if (type == typeof(int))
|
|
{
|
|
Func<int> read;
|
|
Action<int> write;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
read = () => (int)field.GetValue(target);
|
|
write = i => field.SetValue(target, i);
|
|
break;
|
|
case PropertyInfo property:
|
|
read = () => (int)property.GetValue(target);
|
|
write = i => property.SetValue(target, i);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
Configure(context, read, write);
|
|
}
|
|
else if (type == typeof(float))
|
|
{
|
|
Func<float> read;
|
|
Action<float> write;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
read = () => (float)field.GetValue(target);
|
|
write = f => field.SetValue(target, f);
|
|
break;
|
|
case PropertyInfo property:
|
|
read = () => (float)property.GetValue(target);
|
|
write = f => property.SetValue(target, f);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
Configure(context, read, write);
|
|
}
|
|
}
|
|
|
|
public InputField Configure(IUIContext context, Func<string> read, Action<string> write, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (inputField is null) return this;
|
|
inputField.contentType = TMP_InputField.ContentType.Standard;
|
|
inputField.text = read() ?? string.Empty;
|
|
inputField.onValueChanged.AddListener(v => write(v));
|
|
|
|
context.Refresh += () =>
|
|
{
|
|
var val = read() ?? string.Empty;
|
|
if (inputField.text != val) inputField.text = val;
|
|
};
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
|
|
public InputField Configure(IUIContext context, Func<int> read, Action<int> write, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (inputField is null) return this;
|
|
inputField.contentType = TMP_InputField.ContentType.IntegerNumber;
|
|
inputField.text = read().ToString();
|
|
inputField.onValueChanged.AddListener(v =>
|
|
{
|
|
if (int.TryParse(v, out var i)) write(i);
|
|
});
|
|
|
|
context.Refresh += () =>
|
|
{
|
|
var val = read();
|
|
if (int.TryParse(inputField.text, out var existing) && existing != val)
|
|
inputField.text = val.ToString();
|
|
};
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
|
|
public InputField Configure(IUIContext context, Func<float> read, Action<float> write, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (inputField is null) return this;
|
|
inputField.contentType = TMP_InputField.ContentType.DecimalNumber;
|
|
inputField.text = read().ToString("0.###");
|
|
inputField.onValueChanged.AddListener(v => write(float.Parse(v)));
|
|
|
|
context.Refresh += () =>
|
|
{
|
|
var val = read();
|
|
if (float.TryParse(inputField.text, out var existing) && Mathf.Abs(existing-val) < .001)
|
|
inputField.text = val.ToString("0.###");
|
|
};
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public static class InputFieldGeneratorExtension
|
|
{
|
|
public static void AddStringInspector(this IUIContext context,
|
|
string label,
|
|
Func<string> read,
|
|
Action<string> write,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write));
|
|
|
|
public static void AddIntInspector(this IUIContext context,
|
|
string label,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write));
|
|
|
|
public static void AddFloatInspector(this IUIContext context,
|
|
string label,
|
|
Func<float> read,
|
|
Action<float> write,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write));
|
|
|
|
public static void AddStringField(this IUIContext context,
|
|
Func<string> read,
|
|
Action<string> write,
|
|
DisplayOptions? displayOptions = null,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write);
|
|
|
|
public static void AddIntField(this IUIContext context,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
DisplayOptions? displayOptions = null,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write);
|
|
|
|
public static void AddFloatField(this IUIContext context,
|
|
Func<float> read,
|
|
Action<float> write,
|
|
DisplayOptions? displayOptions = null,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write);
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\Label.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Globalization;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.Serialization;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class Label : LayoutComponent, IFieldHandler, ITextElement
|
|
{
|
|
[SerializeField] protected TextMeshProUGUI? label;
|
|
|
|
public int Priority => 100;
|
|
|
|
public TextMeshProUGUI? Text => label;
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
attr is InspectableReadOnlyLabelAttribute;
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
|
|
{
|
|
Type memberType;
|
|
Func<object> getValue;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
memberType = field.FieldType;
|
|
getValue = () => field.GetValue(target);
|
|
break;
|
|
case PropertyInfo property:
|
|
memberType = property.PropertyType;
|
|
getValue = () => property.GetValue(target);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
string FormattedRead()
|
|
{
|
|
var val = getValue();
|
|
if (val == null) return "Null";
|
|
return FormatValue(val, memberType);
|
|
}
|
|
|
|
Configure(context, FormattedRead, displayOptions);
|
|
}
|
|
|
|
private string FormatValue(object val, Type type)
|
|
{
|
|
if (type == typeof(float))
|
|
{
|
|
return ((float)val).ToString("F3", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
if (type == typeof(double))
|
|
{
|
|
return ((double)val).ToString("F3", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
if (type == typeof(decimal))
|
|
{
|
|
return ((decimal)val).ToString("F3", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
return val.ToString();
|
|
}
|
|
|
|
public Label Configure(IUIContext context, Func<string> read, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (label is null) return this;
|
|
label.text = read();
|
|
|
|
context.Refresh += () => label.text = read();
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
|
|
public Label Configure(IUIContext context, string labelText, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (label is null) return this;
|
|
label.text = labelText;
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public static class LabelFieldGeneratorExtension
|
|
{
|
|
public static void AddLabelInspector(this IUIContext context,
|
|
string label,
|
|
Func<string> read) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<Label>("Value")?.Configure(context, read));
|
|
|
|
public static void AddLabel(this IUIContext context, Func<string> read, string? prefabName = null) =>
|
|
context.Resolver.Resolve<Label>(prefabName)?.Configure(context, read);
|
|
|
|
public static void AddLabel(this IUIContext context, string label, string? prefabName = null) =>
|
|
context.Resolver.Resolve<Label>(prefabName)?.Configure(context, label);
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\LayoutComponent.cs
|
|
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class LayoutComponent : ResolverComponent, ILayoutElement
|
|
{
|
|
[SerializeField] private LayoutElement? layoutElement;
|
|
|
|
public float LayoutPriority
|
|
{
|
|
set
|
|
{
|
|
if (layoutElement != null) layoutElement.flexibleWidth = value;
|
|
}
|
|
}
|
|
|
|
public LayoutElement? LayoutElement => layoutElement;
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\Modal.cs
|
|
|
|
using System;
|
|
using System.Collections;
|
|
using R3;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using Object = UnityEngine.Object;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
/// <summary>
|
|
/// Generic modal UI element that can either center itself
|
|
/// or align to another RectTransform while staying within screen bounds.
|
|
/// </summary>
|
|
[RequireComponent(typeof(RectTransform))]
|
|
public class Modal : Generator
|
|
{
|
|
public event Action? BeforeDestroy;
|
|
[SerializeField] protected VerticalLayoutGroup? layoutGroup;
|
|
|
|
/// <summary>
|
|
/// Positions the modal below the given element (or above if there's no space), matching its width
|
|
/// </summary>
|
|
public void AlignVertical(RectTransform alignToElement)
|
|
{
|
|
var rectTransform = (RectTransform)transform;
|
|
|
|
LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform);
|
|
var modalSize = rectTransform.rect.size;
|
|
|
|
var pivot = rectTransform.pivot;
|
|
|
|
pivot.x = 0;
|
|
var scaleFactor = Canvas.scaleFactor;
|
|
var corners = new Vector3[4];
|
|
alignToElement.GetWorldCorners(corners);
|
|
var pivotTop = corners[0].y > modalSize.y * scaleFactor;
|
|
pivot.y = pivotTop ? 1 : 0;
|
|
rectTransform.pivot = pivot;
|
|
var delta = alignToElement.sizeDelta;
|
|
if (layoutGroup != null)
|
|
delta.x = alignToElement.sizeDelta.x + (layoutGroup.padding.left + layoutGroup.padding.right);
|
|
rectTransform.sizeDelta = delta;
|
|
rectTransform.position = pivotTop ? corners[0] : corners[1];
|
|
if (layoutGroup != null)
|
|
rectTransform.anchoredPosition += Vector2.left * layoutGroup.padding.left;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Positions the modal along the right or left edge,
|
|
/// pivoting down or up, respectively, according to available space
|
|
/// </summary>
|
|
public void AlignEdge(RectTransform alignToElement)
|
|
{
|
|
var rectTransform = (RectTransform)transform;
|
|
|
|
LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform);
|
|
var modalSize = rectTransform.rect.size;
|
|
var canvasRect = CanvasRoot.rect;
|
|
|
|
var pivot = rectTransform.pivot;
|
|
|
|
var scaleFactor = Canvas.scaleFactor;
|
|
var corners = new Vector3[4];
|
|
alignToElement.GetWorldCorners(corners);
|
|
// Is there enough space for the element to drop down from the upper edge?
|
|
var pivotTop = corners[2].y > modalSize.y * scaleFactor;
|
|
pivot.y = pivotTop ? 1 : 0;
|
|
// Is there enough space for the modal to sit on the right side?
|
|
var pivotRight = corners[2].x / scaleFactor + modalSize.x < canvasRect.x;
|
|
pivot.x = pivotRight ? 1 : 0;
|
|
rectTransform.pivot = pivot;
|
|
rectTransform.position = pivotTop ? corners[0] : corners[1];
|
|
}
|
|
|
|
public void Center()
|
|
{
|
|
var rect = (RectTransform)transform;
|
|
rect.pivot = new Vector2(.5f,.5f);
|
|
rect.anchoredPosition = Vector2.zero;
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
BeforeDestroy?.Invoke();
|
|
}
|
|
}
|
|
|
|
public static class ModalGeneratorExtensions
|
|
{
|
|
/// <summary>
|
|
/// Spawns a modal. Automatically creates and wires a click catcher that closes both.
|
|
/// </summary>
|
|
public static Modal? ShowModal(
|
|
this IUIContext context)
|
|
{
|
|
// Instantiate click catcher behind modal
|
|
var catcher = context.Resolver.Resolve<ClickCatcher>();
|
|
if (catcher == null) return null;
|
|
catcher.transform.SetParent(context.CanvasRoot, false);
|
|
catcher.transform.SetAsLastSibling();
|
|
|
|
// Instantiate modal on top
|
|
var modal = context.Resolver.Resolve<Modal>();
|
|
if (modal == null) return null;
|
|
modal.transform.SetParent(context.CanvasRoot, false);
|
|
modal.transform.SetAsLastSibling();
|
|
|
|
// If modal is ever destroyed, also destroy click catcher
|
|
modal.BeforeDestroy += () => Object.Destroy(catcher.gameObject);
|
|
|
|
// Hook up dismissal
|
|
catcher.OnClick.Subscribe(_ => Object.Destroy(modal.gameObject));
|
|
|
|
return modal;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ResolverComponent.cs
|
|
|
|
using UnityEngine;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class ResolverComponent : MonoBehaviour
|
|
{
|
|
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\SliderField.cs
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Reflection;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class SliderField : LayoutComponent, IFieldHandler
|
|
{
|
|
[SerializeField] private Slider? slider;
|
|
|
|
public int Priority => 100;
|
|
|
|
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
|
|
(type == typeof(float) && attr is InspectableRangedFloatAttribute) ||
|
|
(type == typeof(int) && attr is InspectableRangedIntAttribute);
|
|
|
|
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
|
|
{
|
|
var type = member switch
|
|
{
|
|
FieldInfo field => field.FieldType,
|
|
PropertyInfo property => property.PropertyType,
|
|
_ => null
|
|
};
|
|
|
|
if (type == null) return;
|
|
|
|
if (type == typeof(float))
|
|
{
|
|
Func<float> read;
|
|
Action<float> write;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
read = () => (float)field.GetValue(target);
|
|
write = f => field.SetValue(target, f);
|
|
break;
|
|
case PropertyInfo property:
|
|
read = () => (float)property.GetValue(target);
|
|
write = f => property.SetValue(target, f);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
var range = member.GetCustomAttribute<InspectableRangedFloatAttribute>();
|
|
Configure(context, read, write, range.Min, range.Max, displayOptions);
|
|
}
|
|
else if (type == typeof(int))
|
|
{
|
|
Func<int> read;
|
|
Action<int> write;
|
|
switch (member)
|
|
{
|
|
case FieldInfo field:
|
|
read = () => (int)field.GetValue(target);
|
|
write = i => field.SetValue(target, i);
|
|
break;
|
|
case PropertyInfo property:
|
|
read = () => (int)property.GetValue(target);
|
|
write = i => property.SetValue(target, i);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
var range = member.GetCustomAttribute<InspectableRangedIntAttribute>();
|
|
Configure(context, read, write, range.Min, range.Max, displayOptions);
|
|
}
|
|
}
|
|
|
|
public SliderField Configure(IUIContext context, Func<float> read, Action<float> write, float min, float max, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (slider is null) return this;
|
|
slider.wholeNumbers = false;
|
|
slider.minValue = min;
|
|
slider.maxValue = max;
|
|
slider.value = read();
|
|
slider.onValueChanged.AddListener(f => write(f));
|
|
|
|
context.Refresh += () => slider.value = read();
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
|
|
public SliderField Configure(IUIContext context, Func<int> read, Action<int> write, int min, int max, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if (slider is null) return this;
|
|
slider.wholeNumbers = true;
|
|
slider.minValue = min;
|
|
slider.maxValue = max;
|
|
slider.value = read();
|
|
slider.onValueChanged.AddListener(f => write(Mathf.RoundToInt(f)));
|
|
|
|
context.Refresh += () => slider.value = read();
|
|
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public static class RangedFloatFieldGeneratorExtension
|
|
{
|
|
public static void AddRangedFloatInspector(this IUIContext context,
|
|
string label,
|
|
Func<float> read,
|
|
Action<float> write,
|
|
float min,
|
|
float max,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max));
|
|
|
|
public static void AddRangedIntInspector(this IUIContext context,
|
|
string label,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
int min,
|
|
int max,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
|
|
context.Resolver.Resolve<Label>()?.Configure(context, label),
|
|
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max));
|
|
|
|
public static void AddRangedFloatField(this IUIContext context,
|
|
Func<float> read,
|
|
Action<float> write,
|
|
float min,
|
|
float max,
|
|
DisplayOptions? displayOptions = null,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max, displayOptions);
|
|
|
|
public static void AddRangedIntField(this IUIContext context,
|
|
Func<int> read,
|
|
Action<int> write,
|
|
int min,
|
|
int max,
|
|
DisplayOptions? displayOptions = null,
|
|
string? prefabName = null) =>
|
|
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max, displayOptions);
|
|
}
|
|
}
|
|
|
|
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\TextButton.cs
|
|
|
|
using System;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.UI;
|
|
using static Unity.Mathematics.math;
|
|
|
|
namespace GameCult.Unity.UI.Components
|
|
{
|
|
public class TextButton : LayoutComponent, IPointerEnterHandler, IPointerExitHandler, ITextElement
|
|
{
|
|
[SerializeField] private Button? button;
|
|
[SerializeField] private TextMeshProUGUI? label;
|
|
[SerializeField] private float animationDuration;
|
|
[SerializeField] private float hoverSpacing;
|
|
|
|
private float _lerp;
|
|
private bool _hovering;
|
|
|
|
public TextMeshProUGUI? Text => label;
|
|
|
|
private void Update()
|
|
{
|
|
_lerp = saturate(_lerp + Time.deltaTime / animationDuration * (button is { interactable: true } && _hovering ? 1 : -1));
|
|
if (label is not null) label.characterSpacing = hoverSpacing * _lerp;
|
|
}
|
|
|
|
public void OnPointerEnter(PointerEventData eventData)
|
|
{
|
|
_hovering = true;
|
|
}
|
|
|
|
public void OnPointerExit(PointerEventData eventData)
|
|
{
|
|
_hovering = false;
|
|
}
|
|
|
|
public TextButton Configure(IUIContext context, string labelText, Action onClick, DisplayOptions? displayOptions = null)
|
|
{
|
|
if(displayOptions is not { PlaceInContext: true })
|
|
transform.SetParent(context.ContentRoot, false);
|
|
context.Register(gameObject);
|
|
if(button is null || label is null) return this;
|
|
|
|
label.text = labelText;
|
|
button.onClick.AddListener(() => onClick());
|
|
|
|
button.interactable = displayOptions?.Interactable??true;
|
|
this.ApplyLayoutOptions(displayOptions);
|
|
this.ApplyTextOptions(displayOptions);
|
|
|
|
return this;
|
|
}
|
|
}
|
|
|
|
public static class TextButtonGeneratorExtensions
|
|
{
|
|
public static void AddTextButton(this IUIContext context, string label, Action onClick, DisplayOptions? displayOptions = null) =>
|
|
context.Resolver.Resolve<TextButton>()?.Configure(context, label, onClick, displayOptions);
|
|
}
|
|
}
|