Added a popup system

This commit is contained in:
Sebastian Bularca
2026-04-06 10:06:09 +02:00
parent a807405585
commit 61ca3701ae
26 changed files with 2638 additions and 1363 deletions

View File

@@ -0,0 +1,51 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
public sealed class FadePopupAnimator : IPopupAnimator {
private CanvasGroup target;
private float duration;
private float elapsed;
private float startAlpha;
private float endAlpha;
private Action onComplete;
public bool IsAnimating => target != null;
public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) {
target = canvasGroup;
this.duration = Mathf.Max(duration, 0.001f);
elapsed = 0f;
startAlpha = 0f;
endAlpha = 1f;
this.onComplete = onComplete;
canvasGroup.alpha = 0f;
}
public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
target = canvasGroup;
this.duration = Mathf.Max(duration, 0.001f);
elapsed = 0f;
startAlpha = canvasGroup.alpha;
endAlpha = 0f;
this.onComplete = onComplete;
}
public void Tick(float deltaTime) {
if(target == null) {
return;
}
elapsed += deltaTime;
var t = Mathf.Clamp01(elapsed / duration);
target.alpha = Mathf.Lerp(startAlpha, endAlpha, t);
if(t >= 1f) {
var callback = onComplete;
target = null;
onComplete = null;
callback?.Invoke();
}
}
}
}

View File

@@ -0,0 +1,11 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
public interface IPopupAnimator {
void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
void Tick(float deltaTime);
bool IsAnimating { get; }
}
}

View File

@@ -0,0 +1,16 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
public interface IPopupSystem {
void RegisterCategory(PopupCategory category, int priority = 0);
void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
RectTransform anchor = null, AnchorSide? anchorSide = null);
void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
Vector2 screenPosition);
void Hide(PopupCategory category);
void HideAll();
void Tick(float deltaTime);
void Dispose();
}
}

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,44 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
[Serializable]
public readonly struct PopupCategory : IEquatable<PopupCategory> {
[SerializeField] readonly 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,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,58 @@
using Jovian.PopupSystem.UI;
using UnityEngine;
namespace Jovian.PopupSystem {
public struct PopupContentBuilder {
readonly PopupView view;
public PopupContentBuilder(PopupView view) {
this.view = view;
}
public PopupContentBuilder AddHeader(string text) {
var header = view.GetHeader();
header.text = text;
return this;
}
public PopupContentBuilder AddText(string text) {
var label = view.GetText();
label.text = text;
return this;
}
public PopupContentBuilder AddText(string text, string hexColor) {
var label = view.GetText();
var prefix = hexColor.Length > 0 && hexColor[0] == '#' ? "" : "#";
label.text = $"<color={prefix}{hexColor}>{text}</color>";
return this;
}
public PopupContentBuilder AddStat(string label, int value) {
var (labelText, valueText) = view.GetStat();
labelText.text = label;
valueText.text = value.ToString();
return this;
}
public PopupContentBuilder AddStat(string label, string value) {
var (labelText, valueText) = view.GetStat();
labelText.text = label;
valueText.text = value;
return this;
}
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);
return this;
}
public PopupContentBuilder AddSeparator() {
view.GetSeparator();
return this;
}
}
}

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,59 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.PopupSystem {
[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("Priority")]
public List<CategoryPriority> categoryPriorities = new();
[Header("Per-Category Overrides")]
public List<CategoryDelay> categoryDelayOverrides = new();
public int GetPriority(PopupCategory category) {
foreach(var cp in categoryPriorities) {
if(cp.category == category) {
return cp.priority;
}
}
return 0;
}
public float GetDelay(PopupCategory category) {
foreach(var cd in categoryDelayOverrides) {
if(cd.category == category) {
return cd.delay;
}
}
return popupDelay;
}
}
[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,187 @@
using System;
using System.Collections.Generic;
using Jovian.PopupSystem.UI;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Jovian.PopupSystem {
public sealed class PopupSystem : IPopupSystem {
readonly PopupSettings settings;
readonly PopupView viewPrefab;
readonly Func<IPopupAnimator> animatorFactory;
readonly Dictionary<PopupCategory, ViewState> categories = new();
public PopupSystem(PopupSettings settings, PopupView viewPrefab, Func<IPopupAnimator> animatorFactory = null) {
this.settings = settings;
this.viewPrefab = viewPrefab;
this.animatorFactory = animatorFactory ?? (() => new FadePopupAnimator());
}
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()
};
}
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;
}
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;
}
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);
});
}
}
public void HideAll() {
foreach(var kvp in categories) {
Hide(kvp.Key);
}
}
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();
}
}
}
public void Dispose() {
foreach(var kvp in categories) {
if(kvp.Value.view != null) {
Object.Destroy(kvp.Value.view.gameObject);
}
}
categories.Clear();
}
private void ShowImmediate(ViewState state) {
EnsureView(state);
state.view.ClearContent();
var builder = new PopupContentBuilder(state.view);
state.pendingBuild?.Invoke(builder);
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.SetVisible(true);
state.view.UpdatePosition();
state.animator.Show(state.view.CanvasGroup, settings.fadeDuration, null);
}
private void EnsureView(ViewState state) {
if(state.view != null) {
return;
}
state.view = Object.Instantiate(viewPrefab);
state.view.SetVisible(false);
state.view.SetMaxWidth(settings.maxPopupWidth);
var canvas = state.view.GetComponent<Canvas>();
if(canvas != null) {
canvas.sortingOrder = settings.sortingOrder;
}
}
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,54 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
namespace Jovian.PopupSystem.UI {
public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
[SerializeField] PopupCategory category;
[SerializeField] AnchorSide anchorSide = AnchorSide.Below;
[SerializeField] PopupPositionMode positionMode = PopupPositionMode.AnchorToElement;
IPopupSystem popupSystem;
Action<PopupContentBuilder> contentCallback;
bool initialized;
public PopupCategory Category => category;
public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> contentCallback) {
this.popupSystem = popupSystem;
this.contentCallback = contentCallback;
initialized = true;
}
public void Initialize(IPopupSystem popupSystem, PopupCategory category, Action<PopupContentBuilder> contentCallback) {
this.popupSystem = popupSystem;
this.category = category;
this.contentCallback = contentCallback;
initialized = true;
}
public void UpdateContent(Action<PopupContentBuilder> contentCallback) {
this.contentCallback = contentCallback;
}
public void OnPointerEnter(PointerEventData eventData) {
if(!initialized || popupSystem == null || contentCallback == null) {
return;
}
if(positionMode == PopupPositionMode.AnchorToElement) {
popupSystem.Show(category, contentCallback, (RectTransform)transform, anchorSide);
}
else {
popupSystem.Show(category, contentCallback);
}
}
public void OnPointerExit(PointerEventData eventData) {
if(!initialized || popupSystem == null) {
return;
}
popupSystem.Hide(category);
}
}
}

View File

@@ -0,0 +1,214 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace Jovian.PopupSystem.UI {
public class PopupView : 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;
public CanvasGroup CanvasGroup => canvasGroup;
public RectTransform Content => content;
public bool IsVisible => isVisible;
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;
}
}
public void SetVisible(bool visible) {
isVisible = visible;
gameObject.SetActive(visible);
if(!visible) {
canvasGroup.alpha = 0f;
}
}
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) ---
public TMP_Text GetHeader() {
return GetOrCreate(headerCache, headerPrefab, ref headerIndex);
}
public TMP_Text GetText() {
return GetOrCreate(textCache, textPrefab, ref textIndex);
}
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);
}
public Image GetImage() {
return GetOrCreate(imageCache, imagePrefab, ref imageIndex);
}
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 ---
public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
positionMode = PopupPositionMode.AnchorToElement;
anchorTarget = target;
anchorSide = side;
screenEdgePadding = edgePadding;
}
public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
positionMode = PopupPositionMode.FollowMouse;
followOffset = offset;
screenEdgePadding = edgePadding;
}
public void SetFixedPosition(Vector2 screenPos, float edgePadding) {
positionMode = PopupPositionMode.AnchorToElement;
anchorTarget = null;
screenEdgePadding = edgePadding;
PositionAtScreenPoint(screenPos);
}
public void UpdatePosition() {
if(positionMode == PopupPositionMode.FollowMouse) {
PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
}
else if(anchorTarget != null) {
PositionAnchoredTo(anchorTarget, anchorSide);
}
}
private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
var targetRect = GetScreenRect(target);
var popupSize = GetScreenRect((RectTransform)transform).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) {
var rt = (RectTransform)transform;
if(layoutDirty) {
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
layoutDirty = false;
}
var popupSize = rt.rect.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);
rt.position = screenPos;
}
private static readonly Vector3[] cornersBuffer = new Vector3[4];
private static Rect GetScreenRect(RectTransform rt) {
rt.GetWorldCorners(cornersBuffer);
var min = new Vector2(cornersBuffer[0].x, cornersBuffer[0].y);
var max = new Vector2(cornersBuffer[2].x, cornersBuffer[2].y);
return new Rect(min, max - min);
}
}
}