forked from Shardstone/trail-into-darkness
Made the popup system a lot more generic
This commit is contained in:
@@ -1,86 +1,114 @@
|
||||
using Jovian.PopupSystem.UI;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
/// <summary>
|
||||
/// Fluent API struct for building popup content. Operates directly on cached elements
|
||||
/// inside a <see cref="PopupReference"/> — no intermediate allocations. Received in
|
||||
/// the build callback passed to <see cref="IPopupSystem.Show"/>.
|
||||
/// Fluent API struct for building popup content. Operates on the generic element cache
|
||||
/// inside a <see cref="PopupView"/>. Received in the build callback passed to
|
||||
/// <see cref="IPopupSystem.Show"/>. Use <see cref="Add"/> for fully custom elements,
|
||||
/// or convenience methods for common types.
|
||||
/// </summary>
|
||||
public struct PopupContentBuilder {
|
||||
readonly PopupReference view;
|
||||
public readonly struct PopupContentBuilder {
|
||||
readonly PopupView view;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a builder targeting the given popup reference.
|
||||
/// Creates a builder targeting the given popup view.
|
||||
/// </summary>
|
||||
public PopupContentBuilder(PopupReference view) {
|
||||
public PopupContentBuilder(PopupView view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a header text element (bold, larger font).
|
||||
/// Returns a cached or newly instantiated element by its registered type.
|
||||
/// Use this for fully custom or game-specific element types.
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddHeader(string text) {
|
||||
var header = view.GetHeader();
|
||||
header.text = text;
|
||||
public GameObject Add(PopupElementType elementType) {
|
||||
return view.GetElement(elementType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a text element using the given element type and sets its TMP_Text content.
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddText(string text, PopupElementType elementType) {
|
||||
var go = view.GetElement(elementType);
|
||||
if(go == null) {
|
||||
return this;
|
||||
}
|
||||
var tmp = go.GetComponentInChildren<TMP_Text>();
|
||||
if(tmp) {
|
||||
tmp.text = text;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a body text element.
|
||||
/// Adds a colored text element using the given element type.
|
||||
/// Hex color can be with or without the # prefix.
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddText(string text) {
|
||||
var label = view.GetText();
|
||||
label.text = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a colored body text element. Hex color can be with or without the # prefix.
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddText(string text, string hexColor) {
|
||||
var label = view.GetText();
|
||||
public PopupContentBuilder AddText(string text, string hexColor, PopupElementType elementType) {
|
||||
var go = view.GetElement(elementType);
|
||||
if(!go) {
|
||||
return this;
|
||||
}
|
||||
var tmp = go.GetComponentInChildren<TMP_Text>();
|
||||
if(!tmp) {
|
||||
return this;
|
||||
}
|
||||
var prefix = hexColor.Length > 0 && hexColor[0] == '#' ? "" : "#";
|
||||
label.text = $"<color={prefix}{hexColor}>{text}</color>";
|
||||
tmp.text = $"<color={prefix}{hexColor}>{text}</color>";
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stat row with a label and integer value.
|
||||
/// Adds a name/value row using the given element type. The prefab must have
|
||||
/// exactly two TMP_Text children (label first, value second).
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddStat(string label, int value) {
|
||||
var (labelText, valueText) = view.GetStat();
|
||||
labelText.text = label;
|
||||
valueText.text = value.ToString();
|
||||
return this;
|
||||
public PopupContentBuilder AddNameValue(string label, int value, PopupElementType elementType) {
|
||||
return AddNameValueInternal(elementType, label, value.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stat row with a label and string value.
|
||||
/// Adds a name/value row using the given element type. The prefab must have
|
||||
/// exactly two TMP_Text children (label first, value second).
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddStat(string label, string value) {
|
||||
var (labelText, valueText) = view.GetStat();
|
||||
labelText.text = label;
|
||||
valueText.text = value;
|
||||
public PopupContentBuilder AddNameValue(string label, string value, PopupElementType elementType) {
|
||||
return AddNameValueInternal(elementType, label, value);
|
||||
}
|
||||
|
||||
private PopupContentBuilder AddNameValueInternal(PopupElementType elementType, string label, string value) {
|
||||
var go = view.GetElement(elementType);
|
||||
if(go != null) {
|
||||
var children = go.GetComponentsInChildren<TMP_Text>(true);
|
||||
if(children.Length >= 2) {
|
||||
children[0].text = label;
|
||||
children[1].text = value;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an image element with the given sprite and optional height.
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddImage(Sprite sprite, float height = 64f) {
|
||||
var image = view.GetImage();
|
||||
image.sprite = sprite;
|
||||
var rt = (RectTransform)image.transform;
|
||||
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
|
||||
public PopupContentBuilder AddImage(Sprite sprite, PopupElementType elementType, float height = 64f) {
|
||||
var go = view.GetElement(elementType);
|
||||
if(go != null) {
|
||||
var image = go.GetComponentInChildren<Image>();
|
||||
if(image != null) {
|
||||
image.sprite = sprite;
|
||||
var rt = (RectTransform)image.transform;
|
||||
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a horizontal separator line.
|
||||
/// Adds a separator element using the given element type.
|
||||
/// </summary>
|
||||
public PopupContentBuilder AddSeparator() {
|
||||
view.GetSeparator();
|
||||
public PopupContentBuilder AddSeparator(PopupElementType elementType) {
|
||||
view.GetElement(elementType);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
74
Packages/com.jovian.popup-system/Runtime/PopupElementType.cs
Normal file
74
Packages/com.jovian.popup-system/Runtime/PopupElementType.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
/// <summary>
|
||||
/// Value type identifying a popup element prefab. Compared by string ID using ordinal
|
||||
/// comparison. Built-in types cover common popup elements. Define custom types as static
|
||||
/// fields or create instances for game-specific elements and variants.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public struct PopupElementType : IEquatable<PopupElementType> {
|
||||
[SerializeField] private string id;
|
||||
|
||||
/// <summary>The string identifier for this element type.</summary>
|
||||
public string Id => id;
|
||||
|
||||
public PopupElementType(string id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
// --- Built-in types ---
|
||||
|
||||
/// <summary>Bold header text element.</summary>
|
||||
public static readonly PopupElementType Header = new("header");
|
||||
|
||||
/// <summary>Body text element.</summary>
|
||||
public static readonly PopupElementType Text = new("text");
|
||||
|
||||
/// <summary>Label + value stat row element.</summary>
|
||||
public static readonly PopupElementType LabelValueText = new("label_value_text");
|
||||
|
||||
/// <summary>Image/icon element.</summary>
|
||||
public static readonly PopupElementType Image = new("image");
|
||||
|
||||
/// <summary>Horizontal separator line.</summary>
|
||||
public static readonly PopupElementType Separator = new("separator");
|
||||
|
||||
// --- Variant helper ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a variant of this element type by appending a suffix.
|
||||
/// e.g. <c>PopupElementType.Header.Variant("gold")</c> produces <c>"header_gold"</c>.
|
||||
/// </summary>
|
||||
public PopupElementType Variant(string variant) {
|
||||
return new PopupElementType($"{id}_{variant}");
|
||||
}
|
||||
|
||||
// --- Equality ---
|
||||
|
||||
public bool Equals(PopupElementType other) {
|
||||
return string.Equals(id, other.id, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) {
|
||||
return obj is PopupElementType other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return id != null ? id.GetHashCode() : 0;
|
||||
}
|
||||
|
||||
public override string ToString() {
|
||||
return id ?? string.Empty;
|
||||
}
|
||||
|
||||
public static bool operator ==(PopupElementType left, PopupElementType right) {
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(PopupElementType left, PopupElementType right) {
|
||||
return !left.Equals(right);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ba7e78e3d930334a935a600a8f67ded
|
||||
@@ -24,12 +24,33 @@ namespace Jovian.PopupSystem {
|
||||
public float touchHoldDuration = 0.6f;
|
||||
public bool gamepadFocusTrigger = true;
|
||||
|
||||
[Header("Element Prefabs")]
|
||||
public List<PopupElementEntry> elementPrefabs = new();
|
||||
|
||||
[Header("Priority")]
|
||||
public List<CategoryPriority> categoryPriorities = new();
|
||||
|
||||
[Header("Per-Category Overrides")]
|
||||
public List<CategoryDelay> categoryDelayOverrides = new();
|
||||
|
||||
private Dictionary<PopupElementType, GameObject> prefabLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the element prefab registered under the given type, or null if not found.
|
||||
/// </summary>
|
||||
public GameObject GetPrefab(PopupElementType elementType) {
|
||||
if(prefabLookup == null) {
|
||||
prefabLookup = new Dictionary<PopupElementType, GameObject>();
|
||||
foreach(var entry in elementPrefabs) {
|
||||
if(!string.IsNullOrEmpty(entry.type.Id) && entry.prefab != null) {
|
||||
prefabLookup[entry.type] = entry.prefab;
|
||||
}
|
||||
}
|
||||
}
|
||||
prefabLookup.TryGetValue(elementType, out var result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured priority for a category, or 0 if not configured.
|
||||
/// </summary>
|
||||
@@ -55,6 +76,12 @@ namespace Jovian.PopupSystem {
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class PopupElementEntry {
|
||||
public PopupElementType type;
|
||||
public GameObject prefab;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class CategoryPriority {
|
||||
public PopupCategory category;
|
||||
|
||||
@@ -189,8 +189,8 @@ namespace Jovian.PopupSystem {
|
||||
/// <inheritdoc />
|
||||
public void Dispose() {
|
||||
foreach(var kvp in categories) {
|
||||
if(kvp.Value.view != null) {
|
||||
Object.Destroy(kvp.Value.view.gameObject);
|
||||
if(kvp.Value.view?.Reference != null) {
|
||||
Object.Destroy(kvp.Value.view.Reference.gameObject);
|
||||
}
|
||||
}
|
||||
categories.Clear();
|
||||
@@ -210,7 +210,7 @@ namespace Jovian.PopupSystem {
|
||||
// Force full layout rebuild so positioning has correct size on first show
|
||||
Canvas.ForceUpdateCanvases();
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(state.view.Content);
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)state.view.transform);
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)state.view.Transform);
|
||||
|
||||
if(state.pendingScreenPos.HasValue) {
|
||||
state.view.SetFixedPosition(state.pendingScreenPos.Value, settings.screenEdgePadding);
|
||||
@@ -234,21 +234,21 @@ namespace Jovian.PopupSystem {
|
||||
return;
|
||||
}
|
||||
|
||||
PopupReference popupRef;
|
||||
if(canvasParent != null) {
|
||||
// Parent under existing scene Canvas — nested Canvas inherits CanvasScaler
|
||||
state.view = Object.Instantiate(viewPrefab, canvasParent);
|
||||
popupRef = Object.Instantiate(viewPrefab, canvasParent);
|
||||
}
|
||||
else {
|
||||
state.view = Object.Instantiate(viewPrefab);
|
||||
popupRef = Object.Instantiate(viewPrefab);
|
||||
}
|
||||
|
||||
// Configure Canvas as override sorting so it renders on top
|
||||
var canvas = state.view.GetComponent<Canvas>();
|
||||
var canvas = popupRef.GetComponent<Canvas>();
|
||||
if(canvas != null) {
|
||||
canvas.overrideSorting = true;
|
||||
canvas.sortingOrder = settings.sortingOrder;
|
||||
}
|
||||
|
||||
state.view = new PopupView(popupRef, settings);
|
||||
state.view.SetVisible(false);
|
||||
state.view.SetMaxWidth(settings.maxPopupWidth);
|
||||
}
|
||||
@@ -266,7 +266,7 @@ namespace Jovian.PopupSystem {
|
||||
}
|
||||
|
||||
private sealed class ViewState {
|
||||
public PopupReference view;
|
||||
public PopupView view;
|
||||
public IPopupAnimator animator;
|
||||
public int priority;
|
||||
public float delay;
|
||||
|
||||
224
Packages/com.jovian.popup-system/Runtime/PopupView.cs
Normal file
224
Packages/com.jovian.popup-system/Runtime/PopupView.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System.Collections.Generic;
|
||||
using Jovian.PopupSystem.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
/// <summary>
|
||||
/// Behavior class for a popup view. Manages the generic grow-only element cache,
|
||||
/// positioning, visibility, and layout. Operates on a <see cref="PopupReference"/>
|
||||
/// MonoBehaviour for scene references.
|
||||
/// </summary>
|
||||
public sealed class PopupView {
|
||||
readonly PopupReference reference;
|
||||
readonly PopupSettings settings;
|
||||
|
||||
// Generic element cache: keyed by element type
|
||||
readonly Dictionary<PopupElementType, List<GameObject>> elementCache = new();
|
||||
readonly Dictionary<PopupElementType, int> elementIndex = new();
|
||||
|
||||
// Positioning state
|
||||
PopupPositionMode positionMode;
|
||||
RectTransform anchorTarget;
|
||||
AnchorSide anchorSide;
|
||||
Vector2 followOffset;
|
||||
float screenEdgePadding;
|
||||
float maxWidth;
|
||||
bool isVisible;
|
||||
bool layoutDirty;
|
||||
Canvas rootCanvas;
|
||||
Camera canvasCamera;
|
||||
|
||||
/// <summary>The underlying MonoBehaviour reference holder.</summary>
|
||||
public PopupReference Reference => reference;
|
||||
|
||||
/// <summary>The CanvasGroup for animation control.</summary>
|
||||
public CanvasGroup CanvasGroup => reference.CanvasGroup;
|
||||
|
||||
/// <summary>The content RectTransform where elements are parented.</summary>
|
||||
public RectTransform Content => reference.Content;
|
||||
|
||||
/// <summary>The root Transform of the popup GameObject.</summary>
|
||||
public Transform Transform => reference.transform;
|
||||
|
||||
/// <summary>Whether the popup is currently visible.</summary>
|
||||
public bool IsVisible => isVisible;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new popup view wrapping the given reference and settings.
|
||||
/// </summary>
|
||||
public PopupView(PopupReference reference, PopupSettings settings) {
|
||||
this.reference = reference;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
// --- Element cache (generic, grow-only) ---
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next available cached element for the given type, or instantiates
|
||||
/// a new one from the settings prefab registry. The element is activated and placed
|
||||
/// at the end of the content layout.
|
||||
/// </summary>
|
||||
public GameObject GetElement(PopupElementType elementType) {
|
||||
if(!elementCache.TryGetValue(elementType, out var cache)) {
|
||||
cache = new List<GameObject>();
|
||||
elementCache[elementType] = cache;
|
||||
elementIndex[elementType] = 0;
|
||||
}
|
||||
|
||||
var index = elementIndex[elementType];
|
||||
if(index < cache.Count) {
|
||||
var existing = cache[index];
|
||||
existing.SetActive(true);
|
||||
existing.transform.SetAsLastSibling();
|
||||
elementIndex[elementType] = index + 1;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var prefab = settings.GetPrefab(elementType);
|
||||
if(prefab == null) {
|
||||
Debug.LogWarning($"[PopupView] No prefab registered for element '{elementType}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
var created = Object.Instantiate(prefab, reference.Content);
|
||||
created.SetActive(true);
|
||||
created.transform.SetAsLastSibling();
|
||||
cache.Add(created);
|
||||
elementIndex[elementType] = index + 1;
|
||||
return created;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates all cached content elements and marks layout as dirty.
|
||||
/// </summary>
|
||||
public void ClearContent() {
|
||||
foreach(var kvp in elementCache) {
|
||||
var cache = kvp.Value;
|
||||
var activeCount = elementIndex[kvp.Key];
|
||||
for(int i = 0; i < activeCount; i++) {
|
||||
cache[i].SetActive(false);
|
||||
}
|
||||
elementIndex[kvp.Key] = 0;
|
||||
}
|
||||
layoutDirty = true;
|
||||
}
|
||||
|
||||
// --- Visibility ---
|
||||
|
||||
/// <summary>Shows or hides the popup GameObject. Resets alpha to 0 when hiding.</summary>
|
||||
public void SetVisible(bool visible) {
|
||||
isVisible = visible;
|
||||
reference.gameObject.SetActive(visible);
|
||||
if(!visible) {
|
||||
reference.CanvasGroup.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constrains the popup's horizontal size to the given maximum width in pixels.</summary>
|
||||
public void SetMaxWidth(float maxPopupWidth) {
|
||||
maxWidth = maxPopupWidth;
|
||||
if(maxWidth > 0f) {
|
||||
var rt = (RectTransform)reference.transform;
|
||||
var size = rt.sizeDelta;
|
||||
size.x = Mathf.Min(size.x, maxWidth);
|
||||
rt.sizeDelta = size;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
/// <summary>Configures the popup to anchor to a target element on the specified side.</summary>
|
||||
public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
|
||||
positionMode = PopupPositionMode.AnchorToElement;
|
||||
anchorTarget = target;
|
||||
anchorSide = side;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
/// <summary>Configures the popup to follow the mouse cursor with the given offset.</summary>
|
||||
public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
|
||||
positionMode = PopupPositionMode.FollowMouse;
|
||||
followOffset = offset;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
/// <summary>Positions the popup at a fixed screen coordinate with edge clamping.</summary>
|
||||
public void SetFixedPosition(Vector2 screenPos, float edgePadding) {
|
||||
positionMode = PopupPositionMode.AnchorToElement;
|
||||
anchorTarget = null;
|
||||
screenEdgePadding = edgePadding;
|
||||
PositionAtScreenPoint(screenPos);
|
||||
}
|
||||
|
||||
/// <summary>Updates the popup position based on the current mode (follow mouse or anchored).</summary>
|
||||
public void UpdatePosition() {
|
||||
if(positionMode == PopupPositionMode.FollowMouse) {
|
||||
PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
|
||||
}
|
||||
else if(anchorTarget != null) {
|
||||
PositionAnchoredTo(anchorTarget, anchorSide);
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheCanvas() {
|
||||
if(rootCanvas != null) {
|
||||
return;
|
||||
}
|
||||
rootCanvas = reference.GetComponentInParent<Canvas>()?.rootCanvas;
|
||||
canvasCamera = rootCanvas != null && rootCanvas.renderMode != RenderMode.ScreenSpaceOverlay
|
||||
? rootCanvas.worldCamera : null;
|
||||
}
|
||||
|
||||
private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
|
||||
CacheCanvas();
|
||||
var targetRect = GetScreenRect(target, canvasCamera);
|
||||
var popupRect = GetScreenRect((RectTransform)reference.transform, canvasCamera);
|
||||
var popupSize = popupRect.size;
|
||||
|
||||
var pos = side switch {
|
||||
AnchorSide.Below => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMin - popupSize.y),
|
||||
AnchorSide.Above => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMax),
|
||||
AnchorSide.Left => new Vector2(targetRect.xMin - popupSize.x, targetRect.center.y - popupSize.y * 0.5f),
|
||||
AnchorSide.Right => new Vector2(targetRect.xMax, targetRect.center.y - popupSize.y * 0.5f),
|
||||
_ => targetRect.center
|
||||
};
|
||||
|
||||
PositionAtScreenPoint(pos);
|
||||
}
|
||||
|
||||
private void PositionAtScreenPoint(Vector2 screenPos) {
|
||||
CacheCanvas();
|
||||
var rt = (RectTransform)reference.transform;
|
||||
if(layoutDirty) {
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(reference.Content);
|
||||
layoutDirty = false;
|
||||
}
|
||||
var popupSize = GetScreenRect(rt, canvasCamera).size;
|
||||
|
||||
// Clamp to screen
|
||||
screenPos.x = Mathf.Clamp(screenPos.x, screenEdgePadding, Screen.width - popupSize.x - screenEdgePadding);
|
||||
screenPos.y = Mathf.Clamp(screenPos.y, screenEdgePadding, Screen.height - popupSize.y - screenEdgePadding);
|
||||
|
||||
// Convert screen position to parent local space
|
||||
var parentRt = rt.parent as RectTransform;
|
||||
if(parentRt != null) {
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(parentRt, screenPos, canvasCamera, out var localPos);
|
||||
rt.localPosition = localPos;
|
||||
}
|
||||
else {
|
||||
rt.position = screenPos;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Vector3[] cornersBuffer = new Vector3[4];
|
||||
|
||||
private static Rect GetScreenRect(RectTransform rt, Camera camera) {
|
||||
rt.GetWorldCorners(cornersBuffer);
|
||||
var min = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[0]);
|
||||
var max = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[2]);
|
||||
return new Rect(min, max - min);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18ebd1b0205e20440aa4c4991b43cc46
|
||||
@@ -1,253 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Jovian.PopupSystem.UI {
|
||||
/// <summary>
|
||||
/// MonoBehaviour reference holder for a popup view. Manages the grow-only element cache,
|
||||
/// screen positioning, and visibility. One instance per registered <see cref="PopupCategory"/>.
|
||||
/// Reference-only MonoBehaviour for a popup prefab. Holds serialized scene references
|
||||
/// to the content container, canvas group, and background. All behavior is in
|
||||
/// <see cref="PopupView"/>.
|
||||
/// </summary>
|
||||
public class PopupReference : MonoBehaviour {
|
||||
[SerializeField] RectTransform content;
|
||||
[SerializeField] CanvasGroup canvasGroup;
|
||||
[SerializeField] RectTransform background;
|
||||
|
||||
[Header("Element Prefabs")]
|
||||
[SerializeField] TMP_Text headerPrefab;
|
||||
[SerializeField] TMP_Text textPrefab;
|
||||
[SerializeField] RectTransform statPrefab; // horizontal layout with label + value TMP_Text children
|
||||
[SerializeField] Image imagePrefab;
|
||||
[SerializeField] Image separatorPrefab;
|
||||
|
||||
// Element caches (grow-only)
|
||||
readonly List<TMP_Text> headerCache = new();
|
||||
readonly List<TMP_Text> textCache = new();
|
||||
readonly List<StatEntry> statCache = new();
|
||||
readonly List<Image> imageCache = new();
|
||||
readonly List<Image> separatorCache = new();
|
||||
|
||||
struct StatEntry {
|
||||
public RectTransform root;
|
||||
public TMP_Text label;
|
||||
public TMP_Text value;
|
||||
}
|
||||
|
||||
int headerIndex;
|
||||
int textIndex;
|
||||
int statIndex;
|
||||
int imageIndex;
|
||||
int separatorIndex;
|
||||
|
||||
// Positioning state
|
||||
PopupPositionMode positionMode;
|
||||
RectTransform anchorTarget;
|
||||
AnchorSide anchorSide;
|
||||
Vector2 followOffset;
|
||||
float screenEdgePadding;
|
||||
float maxWidth;
|
||||
bool isVisible;
|
||||
bool layoutDirty;
|
||||
Canvas rootCanvas;
|
||||
Camera canvasCamera;
|
||||
|
||||
public CanvasGroup CanvasGroup => canvasGroup;
|
||||
/// <summary>The content RectTransform where popup elements are parented.</summary>
|
||||
public RectTransform Content => content;
|
||||
public bool IsVisible => isVisible;
|
||||
|
||||
/// <summary>Constrains the popup's horizontal size to the given maximum width in pixels.</summary>
|
||||
public void SetMaxWidth(float maxPopupWidth) {
|
||||
maxWidth = maxPopupWidth;
|
||||
if(maxWidth > 0f) {
|
||||
var rt = (RectTransform)transform;
|
||||
var size = rt.sizeDelta;
|
||||
size.x = Mathf.Min(size.x, maxWidth);
|
||||
rt.sizeDelta = size;
|
||||
}
|
||||
}
|
||||
/// <summary>The CanvasGroup for fade animation control.</summary>
|
||||
public CanvasGroup CanvasGroup => canvasGroup;
|
||||
|
||||
/// <summary>Shows or hides the popup GameObject. Resets alpha to 0 when hiding.</summary>
|
||||
public void SetVisible(bool visible) {
|
||||
isVisible = visible;
|
||||
gameObject.SetActive(visible);
|
||||
if(!visible) {
|
||||
canvasGroup.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deactivates all cached content elements and marks layout as dirty.</summary>
|
||||
public void ClearContent() {
|
||||
DeactivateRange(headerCache, ref headerIndex);
|
||||
DeactivateRange(textCache, ref textIndex);
|
||||
for(int i = 0; i < statIndex; i++) {
|
||||
statCache[i].root.gameObject.SetActive(false);
|
||||
}
|
||||
statIndex = 0;
|
||||
DeactivateRange(imageCache, ref imageIndex);
|
||||
DeactivateRange(separatorCache, ref separatorIndex);
|
||||
layoutDirty = true;
|
||||
}
|
||||
|
||||
private static void DeactivateRange<T>(List<T> cache, ref int index) where T : Component {
|
||||
for(int i = 0; i < index; i++) {
|
||||
cache[i].gameObject.SetActive(false);
|
||||
}
|
||||
index = 0;
|
||||
}
|
||||
|
||||
// --- Element access (grow-only) ---
|
||||
|
||||
/// <summary>Returns the next available header element from the cache, or creates one.</summary>
|
||||
public TMP_Text GetHeader() {
|
||||
return GetOrCreate(headerCache, headerPrefab, ref headerIndex);
|
||||
}
|
||||
|
||||
/// <summary>Returns the next available text element from the cache, or creates one.</summary>
|
||||
public TMP_Text GetText() {
|
||||
return GetOrCreate(textCache, textPrefab, ref textIndex);
|
||||
}
|
||||
|
||||
/// <summary>Returns the next available stat row (label + value pair) from the cache, or creates one.</summary>
|
||||
public (TMP_Text label, TMP_Text value) GetStat() {
|
||||
if(statIndex < statCache.Count) {
|
||||
var existing = statCache[statIndex];
|
||||
existing.root.gameObject.SetActive(true);
|
||||
existing.root.SetAsLastSibling();
|
||||
statIndex++;
|
||||
return (existing.label, existing.value);
|
||||
}
|
||||
|
||||
var created = Instantiate(statPrefab, content);
|
||||
created.gameObject.SetActive(true);
|
||||
created.SetAsLastSibling();
|
||||
var children = created.GetComponentsInChildren<TMP_Text>(true);
|
||||
var entry = new StatEntry { root = created, label = children[0], value = children[1] };
|
||||
statCache.Add(entry);
|
||||
statIndex++;
|
||||
return (entry.label, entry.value);
|
||||
}
|
||||
|
||||
/// <summary>Returns the next available image element from the cache, or creates one.</summary>
|
||||
public Image GetImage() {
|
||||
return GetOrCreate(imageCache, imagePrefab, ref imageIndex);
|
||||
}
|
||||
|
||||
/// <summary>Returns the next available separator element from the cache, or creates one.</summary>
|
||||
public Image GetSeparator() {
|
||||
return GetOrCreate(separatorCache, separatorPrefab, ref separatorIndex);
|
||||
}
|
||||
|
||||
private T GetOrCreate<T>(List<T> cache, T prefab, ref int index) where T : Component {
|
||||
if(index < cache.Count) {
|
||||
var existing = cache[index];
|
||||
existing.gameObject.SetActive(true);
|
||||
existing.transform.SetAsLastSibling();
|
||||
index++;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = Instantiate(prefab, content);
|
||||
created.gameObject.SetActive(true);
|
||||
created.transform.SetAsLastSibling();
|
||||
cache.Add(created);
|
||||
index++;
|
||||
return created;
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
/// <summary>Configures the popup to anchor to a target element on the specified side.</summary>
|
||||
public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
|
||||
positionMode = PopupPositionMode.AnchorToElement;
|
||||
anchorTarget = target;
|
||||
anchorSide = side;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
/// <summary>Configures the popup to follow the mouse cursor with the given offset.</summary>
|
||||
public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
|
||||
positionMode = PopupPositionMode.FollowMouse;
|
||||
followOffset = offset;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
/// <summary>Positions the popup at a fixed screen coordinate with edge clamping.</summary>
|
||||
public void SetFixedPosition(Vector2 screenPos, float edgePadding) {
|
||||
positionMode = PopupPositionMode.AnchorToElement;
|
||||
anchorTarget = null;
|
||||
screenEdgePadding = edgePadding;
|
||||
PositionAtScreenPoint(screenPos);
|
||||
}
|
||||
|
||||
/// <summary>Updates the popup position based on the current mode (follow mouse or anchored).</summary>
|
||||
public void UpdatePosition() {
|
||||
if(positionMode == PopupPositionMode.FollowMouse) {
|
||||
PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
|
||||
}
|
||||
else if(anchorTarget != null) {
|
||||
PositionAnchoredTo(anchorTarget, anchorSide);
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheCanvas() {
|
||||
if(rootCanvas != null) {
|
||||
return;
|
||||
}
|
||||
rootCanvas = GetComponentInParent<Canvas>()?.rootCanvas;
|
||||
canvasCamera = rootCanvas != null && rootCanvas.renderMode != RenderMode.ScreenSpaceOverlay
|
||||
? rootCanvas.worldCamera : null;
|
||||
}
|
||||
|
||||
private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
|
||||
CacheCanvas();
|
||||
var targetRect = GetScreenRect(target, canvasCamera);
|
||||
var popupRect = GetScreenRect((RectTransform)transform, canvasCamera);
|
||||
var popupSize = popupRect.size;
|
||||
|
||||
var pos = side switch {
|
||||
AnchorSide.Below => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMin - popupSize.y),
|
||||
AnchorSide.Above => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMax),
|
||||
AnchorSide.Left => new Vector2(targetRect.xMin - popupSize.x, targetRect.center.y - popupSize.y * 0.5f),
|
||||
AnchorSide.Right => new Vector2(targetRect.xMax, targetRect.center.y - popupSize.y * 0.5f),
|
||||
_ => targetRect.center
|
||||
};
|
||||
|
||||
PositionAtScreenPoint(pos);
|
||||
}
|
||||
|
||||
private void PositionAtScreenPoint(Vector2 screenPos) {
|
||||
CacheCanvas();
|
||||
var rt = (RectTransform)transform;
|
||||
if(layoutDirty) {
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
|
||||
layoutDirty = false;
|
||||
}
|
||||
var popupSize = GetScreenRect(rt, canvasCamera).size;
|
||||
|
||||
// Clamp to screen
|
||||
screenPos.x = Mathf.Clamp(screenPos.x, screenEdgePadding, Screen.width - popupSize.x - screenEdgePadding);
|
||||
screenPos.y = Mathf.Clamp(screenPos.y, screenEdgePadding, Screen.height - popupSize.y - screenEdgePadding);
|
||||
|
||||
// Convert screen position to parent local space
|
||||
var parentRt = rt.parent as RectTransform;
|
||||
if(parentRt != null) {
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(parentRt, screenPos, canvasCamera, out var localPos);
|
||||
rt.localPosition = localPos;
|
||||
}
|
||||
else {
|
||||
rt.position = screenPos;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Vector3[] cornersBuffer = new Vector3[4];
|
||||
|
||||
private static Rect GetScreenRect(RectTransform rt, Camera camera) {
|
||||
rt.GetWorldCorners(cornersBuffer);
|
||||
// Convert world corners to screen space
|
||||
var min = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[0]);
|
||||
var max = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[2]);
|
||||
return new Rect(min, max - min);
|
||||
}
|
||||
/// <summary>The background RectTransform that sizes to content.</summary>
|
||||
public RectTransform Background => background;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user