added code from unity

This commit is contained in:
Sebastian Bularca
2026-04-06 20:45:03 +02:00
parent 0075992205
commit 0f675b9981
56 changed files with 3448 additions and 2 deletions

View File

@@ -0,0 +1,56 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
/// <summary>
/// Default popup animator that fades CanvasGroup alpha. Each category gets its own instance
/// so concurrent show/hide animations don't corrupt each other.
/// </summary>
public sealed class FadePopupAnimator : IPopupAnimator {
private CanvasGroup target;
private float timer;
private float elapsed;
private float startAlpha;
private float endAlpha;
private Action onFinish;
public bool IsAnimating => target != null;
public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) {
target = canvasGroup;
timer = Mathf.Max(duration, 0.001f);
elapsed = 0f;
startAlpha = 0f;
endAlpha = 1f;
onFinish = onComplete;
canvasGroup.alpha = 0f;
}
public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
target = canvasGroup;
timer = Mathf.Max(duration, 0.001f);
elapsed = 0f;
startAlpha = canvasGroup.alpha;
endAlpha = 0f;
onFinish = onComplete;
}
public void Tick(float deltaTime) {
if(target == null) {
return;
}
elapsed += deltaTime;
var t = Mathf.Clamp01(elapsed / timer);
target.alpha = Mathf.Lerp(startAlpha, endAlpha, t);
if(!(t >= 1f)) {
return;
}
var callback = onFinish;
target = null;
onFinish = null;
callback?.Invoke();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 254bb620ce056dd41905258eb0f070d7

37
Runtime/IPopupAnimator.cs Normal file
View File

@@ -0,0 +1,37 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
/// <summary>
/// Interface for popup show/hide animations. Each registered category gets its own instance
/// to prevent state corruption during concurrent animations. Driven by float timers in
/// <see cref="Tick"/>, not coroutines.
/// </summary>
public interface IPopupAnimator {
/// <summary>
/// Begins a show animation on the given CanvasGroup. Typically fades alpha from 0 to 1.
/// </summary>
/// <param name="canvasGroup">The CanvasGroup to animate.</param>
/// <param name="duration">Animation duration in seconds.</param>
/// <param name="onComplete">Callback invoked when the animation finishes. May be null.</param>
void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
/// <summary>
/// Begins a hide animation on the given CanvasGroup. Typically fades alpha to 0.
/// </summary>
/// <param name="canvasGroup">The CanvasGroup to animate.</param>
/// <param name="duration">Animation duration in seconds.</param>
/// <param name="onComplete">Callback invoked when the animation finishes. May be null.</param>
void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
/// <summary>
/// Advances the animation by deltaTime. Call every frame from <see cref="IPopupSystem.Tick"/>.
/// </summary>
void Tick(float deltaTime);
/// <summary>
/// True if an animation is currently in progress.
/// </summary>
bool IsAnimating { get; }
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b146d885db3dcef4fa70abd3f43683d3

79
Runtime/IPopupSystem.cs Normal file
View File

@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using Jovian.PopupSystem.UI;
using UnityEngine;
namespace Jovian.PopupSystem {
/// <summary>
/// Core interface for the popup system. Created per game state, not as a singleton.
/// Manages category registration, trigger discovery, popup display, and lifecycle.
/// </summary>
public interface IPopupSystem {
/// <summary>
/// Scans all <see cref="PopupTrigger"/> components under the given parent and binds them
/// to this system. Each trigger receives a <see cref="PopupTriggerView"/> for behavior.
/// </summary>
void ScanTriggers(Transform parent);
/// <summary>
/// Scans all <see cref="PopupTrigger"/> components under the given parent, binds them,
/// and invokes the configure callback with both the trigger (for hierarchy queries) and
/// its view (for setting content).
/// </summary>
void InitializeTriggersInChildren(Transform parent, Action<PopupTrigger, PopupTriggerView> configureTrigger);
/// <summary>
/// Returns the <see cref="PopupTriggerView"/> for the first registered trigger whose
/// GameObject name matches. Returns null if not found.
/// </summary>
PopupTriggerView GetTriggerHandler(string gameObjectName);
/// <summary>
/// Returns all <see cref="PopupTriggerView"/> instances registered under the given category.
/// </summary>
IReadOnlyList<PopupTriggerView> GetTriggerHandlers(PopupCategory category);
/// <summary>
/// Registers a popup category. Each category gets its own <see cref="PopupReference"/> instance
/// (lazily created on first show) and its own <see cref="IPopupAnimator"/>.
/// </summary>
/// <param name="category">The category to register.</param>
/// <param name="priority">Fallback priority if not defined in <see cref="PopupSettings"/>. Higher dismisses lower.</param>
void RegisterCategory(PopupCategory category, int priority = 0);
/// <summary>
/// Shows a popup for the given category after the configured delay. The build callback
/// populates content via <see cref="PopupContentBuilder"/>. Optionally anchors to a
/// RectTransform or follows the mouse if no anchor is provided.
/// </summary>
void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
RectTransform anchor = null, AnchorSide? anchorSide = null);
/// <summary>
/// Shows a popup for the given category at a fixed screen position.
/// </summary>
void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
Vector2 screenPosition);
/// <summary>
/// Hides the popup for the given category with a fade-out animation.
/// </summary>
void Hide(PopupCategory category);
/// <summary>
/// Hides all visible popups across all categories.
/// </summary>
void HideAll();
/// <summary>
/// Drives delay timers, animations, and follow-mouse positioning. Call every frame.
/// </summary>
void Tick(float deltaTime);
/// <summary>
/// Destroys all popup view GameObjects and clears registered categories.
/// Call when the owning game state exits.
/// </summary>
void Dispose();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65d563f87bb09de48b8edd12101cdd11

View File

@@ -0,0 +1,19 @@
{
"name": "Jovian.PopupSystem",
"rootNamespace": "Jovian.PopupSystem",
"references": [
"Unity.TextMeshPro",
"Unity.InputSystem"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 31bcaef770c58d94db7f78106f15fd4e
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

48
Runtime/PopupCategory.cs Normal file
View File

@@ -0,0 +1,48 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
/// <summary>
/// Value type identifying a popup channel. Each category gets its own popup view instance.
/// Compared by string ID using ordinal comparison. Define custom categories as static fields.
/// </summary>
[Serializable]
public struct PopupCategory : IEquatable<PopupCategory> {
[SerializeField] string id;
public string Id => id;
public PopupCategory(string id) {
this.id = id;
}
public static readonly PopupCategory Character = new("Character");
public static readonly PopupCategory Item = new("Item");
public static readonly PopupCategory Skill = new("Skill");
public static readonly PopupCategory General = new("General");
public bool Equals(PopupCategory other) {
return string.Equals(id, other.id, StringComparison.Ordinal);
}
public override bool Equals(object obj) {
return obj is PopupCategory 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 ==(PopupCategory left, PopupCategory right) {
return left.Equals(right);
}
public static bool operator !=(PopupCategory left, PopupCategory right) {
return !left.Equals(right);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bcd1fb0ef93fed0489a43977bc71594b

View File

@@ -0,0 +1,15 @@
using System;
using Newtonsoft.Json;
namespace Jovian.PopupSystem {
public sealed class PopupCategoryJsonConverter : JsonConverter<PopupCategory> {
public override void WriteJson(JsonWriter writer, PopupCategory value, JsonSerializer serializer) {
writer.WriteValue(value.Id);
}
public override PopupCategory ReadJson(JsonReader reader, Type objectType, PopupCategory existingValue, bool hasExistingValue, JsonSerializer serializer) {
var id = reader.Value as string;
return new PopupCategory(id);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 683182ef8e2aa6b4daffc547c481b768

View File

@@ -0,0 +1,115 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Jovian.PopupSystem {
/// <summary>
/// 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 readonly struct PopupContentBuilder {
readonly PopupView view;
/// <summary>
/// Creates a builder targeting the given popup view.
/// </summary>
public PopupContentBuilder(PopupView view) {
this.view = view;
}
/// <summary>
/// Returns a cached or newly instantiated element by its registered type.
/// Use this for fully custom or game-specific element types.
/// </summary>
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 colored text element using the given element type.
/// Hex color can be with or without the # prefix.
/// </summary>
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] == '#' ? "" : "#";
tmp.text = $"<color={prefix}{hexColor}>{text}</color>";
return this;
}
/// <summary>
/// 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 AddNameValue(string label, int value, PopupElementType elementType) {
return AddNameValueInternal(elementType, label, value.ToString());
}
/// <summary>
/// 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 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, 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 separator element using the given element type.
/// </summary>
public PopupContentBuilder AddSeparator(PopupElementType elementType) {
view.GetElement(elementType);
return this;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b9f4094c471d0464e95e330331acc813

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ba7e78e3d930334a935a600a8f67ded

13
Runtime/PopupEnums.cs Normal file
View File

@@ -0,0 +1,13 @@
namespace Jovian.PopupSystem {
public enum AnchorSide {
Below,
Above,
Left,
Right
}
public enum PopupPositionMode {
AnchorToElement,
FollowMouse
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01e69680e496eca4daebbc1874e3a7d9

96
Runtime/PopupSettings.cs Normal file
View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.PopupSystem {
/// <summary>
/// ScriptableObject holding all popup system configuration. Create via
/// Assets > Create > Jovian > Popup System > Popup Settings.
/// </summary>
[CreateAssetMenu(fileName = "PopupSettings", menuName = "Jovian/Popup System/Popup Settings")]
public class PopupSettings : ScriptableObject {
[Header("General")]
public float popupDelay = 0.4f;
public float fadeDuration = 0.2f;
public AnchorSide defaultAnchorSide = AnchorSide.Below;
public float screenEdgePadding = 10f;
public float maxPopupWidth = 400f;
public int sortingOrder = 100;
[Header("Follow Mouse")]
public Vector2 followMouseOffset = new(15f, -15f);
[Header("Input")]
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>
public int GetPriority(PopupCategory category) {
foreach(var cp in categoryPriorities) {
if(cp.category == category) {
return cp.priority;
}
}
return 0;
}
/// <summary>
/// Returns the configured delay override for a category, or the default popupDelay.
/// </summary>
public float GetDelay(PopupCategory category) {
foreach(var cd in categoryDelayOverrides) {
if(cd.category == category) {
return cd.delay;
}
}
return popupDelay;
}
}
[Serializable]
public sealed class PopupElementEntry {
public PopupElementType type;
public GameObject prefab;
}
[Serializable]
public sealed class CategoryPriority {
public PopupCategory category;
public int priority;
}
[Serializable]
public sealed class CategoryDelay {
public PopupCategory category;
public float delay;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e38e313f3665d464b82b22699b2a4634

282
Runtime/PopupSystem.cs Normal file
View File

@@ -0,0 +1,282 @@
using System;
using System.Collections.Generic;
using Jovian.PopupSystem.UI;
using UnityEngine;
using UnityEngine.UI;
using Object = UnityEngine.Object;
namespace Jovian.PopupSystem {
/// <summary>
/// Core implementation of <see cref="IPopupSystem"/>. Created per game state, not as a singleton.
/// Manages category registration, trigger discovery, popup lifecycle, priority dismissal, and
/// tick-driven delay timers and animations. Pass a canvasParent to auto-scan triggers on construction.
/// </summary>
public sealed class PopupSystem : IPopupSystem {
readonly PopupSettings settings;
readonly PopupReference viewPrefab;
readonly Func<IPopupAnimator> animatorFactory;
readonly Transform canvasParent;
readonly Dictionary<PopupCategory, ViewState> categories = new();
readonly List<PopupTrigger> registeredTriggers = new();
/// <summary>
/// Creates a new popup system instance.
/// </summary>
/// <param name="settings">Configuration ScriptableObject with delays, priorities, and display settings.</param>
/// <param name="viewPrefab">The PopupReference prefab to instantiate per category.</param>
/// <param name="canvasParent">Optional parent Canvas transform. When provided, popup views are parented
/// here (inheriting CanvasScaler) and all PopupTrigger components are auto-scanned.</param>
/// <param name="animatorFactory">Optional factory for custom animators. Defaults to FadePopupAnimator.</param>
public PopupSystem(PopupSettings settings, PopupReference viewPrefab, Transform canvasParent = null, Func<IPopupAnimator> animatorFactory = null) {
this.settings = settings;
this.viewPrefab = viewPrefab;
this.canvasParent = canvasParent;
this.animatorFactory = animatorFactory ?? (() => new FadePopupAnimator());
// Auto-scan if a parent was provided
if(canvasParent != null) {
ScanTriggers(canvasParent);
}
}
/// <inheritdoc />
public void RegisterCategory(PopupCategory category, int priority = 0) {
if(categories.ContainsKey(category)) {
return;
}
var effectivePriority = settings.GetPriority(category);
if(effectivePriority == 0) {
effectivePriority = priority;
}
categories[category] = new ViewState {
priority = effectivePriority,
delay = settings.GetDelay(category),
animator = animatorFactory()
};
}
/// <inheritdoc />
public void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
RectTransform anchor = null, AnchorSide? anchorSide = null) {
if(!categories.TryGetValue(category, out var state)) {
return;
}
DismissLowerPriority(state.priority);
state.pendingBuild = buildContent;
state.pendingAnchor = anchor;
state.pendingAnchorSide = anchorSide ?? settings.defaultAnchorSide;
state.pendingScreenPos = null;
state.delayTimer = state.delay;
state.isPending = true;
}
/// <inheritdoc />
public void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
Vector2 screenPosition) {
if(!categories.TryGetValue(category, out var state)) {
return;
}
DismissLowerPriority(state.priority);
state.pendingBuild = buildContent;
state.pendingAnchor = null;
state.pendingScreenPos = screenPosition;
state.delayTimer = state.delay;
state.isPending = true;
}
/// <inheritdoc />
public void Hide(PopupCategory category) {
if(!categories.TryGetValue(category, out var state)) {
return;
}
state.isPending = false;
if(state.view != null && state.view.IsVisible) {
state.animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
state.view.SetVisible(false);
});
}
}
/// <inheritdoc />
public void HideAll() {
foreach(var kvp in categories) {
Hide(kvp.Key);
}
}
/// <inheritdoc />
public void Tick(float deltaTime) {
foreach(var kvp in categories) {
kvp.Value.animator.Tick(deltaTime);
}
foreach(var kvp in categories) {
var state = kvp.Value;
if(!state.isPending) {
continue;
}
state.delayTimer -= deltaTime;
if(state.delayTimer > 0f) {
continue;
}
state.isPending = false;
ShowImmediate(state);
}
foreach(var kvp in categories) {
var state = kvp.Value;
if(state.view != null && state.view.IsVisible && state.isFollowMouse) {
state.view.UpdatePosition();
}
}
}
/// <inheritdoc />
public void ScanTriggers(Transform parent) {
var triggers = parent.GetComponentsInChildren<PopupTrigger>(true);
foreach(var trigger in triggers) {
if(!registeredTriggers.Contains(trigger)) {
BindTrigger(trigger);
}
}
}
/// <inheritdoc />
public PopupTriggerView GetTriggerHandler(string gameObjectName) {
foreach(var trigger in registeredTriggers) {
if(trigger != null && trigger.gameObject.name == gameObjectName) {
return trigger.Handler;
}
}
return null;
}
/// <inheritdoc />
public IReadOnlyList<PopupTriggerView> GetTriggerHandlers(PopupCategory category) {
var result = new List<PopupTriggerView>();
foreach(var trigger in registeredTriggers) {
if(trigger != null && trigger.Category == category && trigger.Handler != null) {
result.Add(trigger.Handler);
}
}
return result;
}
/// <inheritdoc />
public void InitializeTriggersInChildren(Transform parent, Action<PopupTrigger, PopupTriggerView> configureTrigger) {
var triggers = parent.GetComponentsInChildren<PopupTrigger>(true);
foreach(var trigger in triggers) {
if(!registeredTriggers.Contains(trigger)) {
BindTrigger(trigger);
}
configureTrigger(trigger, trigger.Handler);
}
}
private void BindTrigger(PopupTrigger trigger) {
var handler = new PopupTriggerView(this);
trigger.Bind(handler);
registeredTriggers.Add(trigger);
}
/// <inheritdoc />
public void Dispose() {
foreach(var kvp in categories) {
if(kvp.Value.view?.Reference != null) {
Object.Destroy(kvp.Value.view.Reference.gameObject);
}
}
categories.Clear();
}
private void ShowImmediate(ViewState state) {
EnsureView(state);
state.view.ClearContent();
var builder = new PopupContentBuilder(state.view);
state.pendingBuild?.Invoke(builder);
// Activate before layout rebuild so Unity has an active hierarchy to calculate
state.view.CanvasGroup.alpha = 0f;
state.view.SetVisible(true);
// 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);
if(state.pendingScreenPos.HasValue) {
state.view.SetFixedPosition(state.pendingScreenPos.Value, settings.screenEdgePadding);
state.isFollowMouse = false;
}
else if(state.pendingAnchor != null) {
state.view.SetAnchorMode(state.pendingAnchor, state.pendingAnchorSide, settings.screenEdgePadding);
state.isFollowMouse = false;
}
else {
state.view.SetFollowMouseMode(settings.followMouseOffset, settings.screenEdgePadding);
state.isFollowMouse = true;
}
state.view.UpdatePosition();
state.animator.Show(state.view.CanvasGroup, settings.fadeDuration, null);
}
private void EnsureView(ViewState state) {
if(state.view != null) {
return;
}
PopupReference popupRef;
if(canvasParent != null) {
popupRef = Object.Instantiate(viewPrefab, canvasParent);
}
else {
popupRef = Object.Instantiate(viewPrefab);
}
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);
}
private void DismissLowerPriority(int showingPriority) {
foreach(var kvp in categories) {
var state = kvp.Value;
if(state.priority < showingPriority && state.view != null && state.view.IsVisible) {
state.isPending = false;
state.animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
state.view.SetVisible(false);
});
}
}
}
private sealed class ViewState {
public PopupView view;
public IPopupAnimator animator;
public int priority;
public float delay;
public float delayTimer;
public bool isPending;
public bool isFollowMouse;
public Action<PopupContentBuilder> pendingBuild;
public RectTransform pendingAnchor;
public AnchorSide pendingAnchorSide;
public Vector2? pendingScreenPos;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 056ed88d12add674798ad4e6300bf222

View File

@@ -0,0 +1,54 @@
using System;
using Jovian.PopupSystem.UI;
using UnityEngine;
namespace Jovian.PopupSystem {
/// <summary>
/// Handles popup behavior for a <see cref="PopupTrigger"/>. Holds the content callback and
/// forwards pointer events to the <see cref="IPopupSystem"/>. This is the behavior layer;
/// the MonoBehaviour trigger is the reference holder that forwards events here.
/// </summary>
public sealed class PopupTriggerView {
readonly IPopupSystem popupSystem;
Action<PopupContentBuilder> contentCallback;
/// <summary>
/// Creates a new trigger view bound to the given popup system.
/// </summary>
public PopupTriggerView(IPopupSystem popupSystem) {
this.popupSystem = popupSystem;
}
/// <summary>
/// Sets the content builder callback that will be invoked when the popup is shown.
/// </summary>
public void SetContent(Action<PopupContentBuilder> callback) {
contentCallback = callback;
}
/// <summary>
/// Called by <see cref="PopupTrigger"/> when the pointer enters. Reads the trigger's
/// category, anchor side, and position mode to show the popup.
/// </summary>
public void OnPointerEnter(PopupTrigger trigger) {
if(popupSystem == null || contentCallback == null) {
return;
}
if(trigger.PositionMode == PopupPositionMode.AnchorToElement) {
popupSystem.Show(trigger.Category, contentCallback, (RectTransform)trigger.transform, trigger.AnchorSide);
}
else {
popupSystem.Show(trigger.Category, contentCallback);
}
}
/// <summary>
/// Called by <see cref="PopupTrigger"/> when the pointer exits. Hides the popup
/// for the trigger's category.
/// </summary>
public void OnPointerExit(PopupTrigger trigger) {
popupSystem?.Hide(trigger.Category);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e2351b6a4b8bf046923790d4d09141c

224
Runtime/PopupView.cs Normal file
View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18ebd1b0205e20440aa4c4991b43cc46

8
Runtime/UI.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4459a298af1a37f47af7b7b70655ee0d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,23 @@
using UnityEngine;
namespace Jovian.PopupSystem.UI {
/// <summary>
/// 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;
/// <summary>The content RectTransform where popup elements are parented.</summary>
public RectTransform Content => content;
/// <summary>The CanvasGroup for fade animation control.</summary>
public CanvasGroup CanvasGroup => canvasGroup;
/// <summary>The background RectTransform that sizes to content.</summary>
public RectTransform Background => background;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bc25da4712d7cc4419eb6f364e032431

View File

@@ -0,0 +1,48 @@
using UnityEngine;
using UnityEngine.EventSystems;
namespace Jovian.PopupSystem.UI {
/// <summary>
/// Reference holder MonoBehaviour for popup triggers. Attach to any UI element with a
/// Graphic component (Image, TMP_Text, etc.) that has Raycast Target enabled.
/// Configure category, anchor side, and position mode in the Inspector.
/// Forwards pointer events to the bound <see cref="PopupTriggerView"/>.
/// </summary>
public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
[SerializeField] PopupCategory category;
[SerializeField] AnchorSide anchorSide = AnchorSide.Below;
[SerializeField] PopupPositionMode positionMode = PopupPositionMode.AnchorToElement;
PopupTriggerView handler;
/// <summary>The popup category this trigger belongs to.</summary>
public PopupCategory Category => category;
/// <summary>Which side of this element the popup anchors to.</summary>
public AnchorSide AnchorSide => anchorSide;
/// <summary>Whether the popup anchors to this element or follows the mouse.</summary>
public PopupPositionMode PositionMode => positionMode;
/// <summary>The bound behavior handler. Null until <see cref="Bind"/> is called.</summary>
public PopupTriggerView Handler => handler;
/// <summary>
/// Binds a <see cref="PopupTriggerView"/> to this trigger. Called automatically
/// by <see cref="IPopupSystem.ScanTriggers"/> or <see cref="IPopupSystem.InitializeTriggersInChildren"/>.
/// </summary>
public void Bind(PopupTriggerView view) {
handler = view;
}
/// <summary>Forwards pointer enter to the bound handler.</summary>
public void OnPointerEnter(PointerEventData eventData) {
handler?.OnPointerEnter(this);
}
/// <summary>Forwards pointer exit to the bound handler.</summary>
public void OnPointerExit(PointerEventData eventData) {
handler?.OnPointerExit(this);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ef9d21a19cd4db4f9d5491202547c05