# Popup System Design **Package:** `com.jovian.popup-system` **Date:** 2026-04-06 ## Purpose A lightweight, low-allocation popup/tooltip system for Unity UGUI. Supports hover tooltips, anchored popups, and follow-mouse modes with dynamic content built via a fluent API. Per-game-state instances with category-based isolation and priority dismissal. ## Architecture ``` Game Code / PopupTrigger MonoBehaviour │ popupSystem.Show(category, builder, anchor) │ popupSystem.Hide(category) ▼ IPopupSystem (per-game-state instance, injected via constructor DI) │ RegisterCategory(PopupCategory, priority) │ One PopupReference per category (lazy-created on first Show) │ Priority: higher priority category dismisses lower on show │ Tick()-driven delay timers and animations (no coroutines) ▼ PopupReference (MonoBehaviour — one instance per registered category) │ Canvas + CanvasGroup for transitions │ Content parent (VerticalLayoutGroup + ContentSizeFitter) │ Grow-only element cache (reuse, never destroy) │ Screen edge clamping │ Anchor-to-element or follow-mouse positioning ▼ IPopupAnimator (extensible interface for show/hide transitions) │ Default: FadePopupAnimator (CanvasGroup.alpha lerp) │ Custom implementations for scale, slide, etc. ▼ PopupContentBuilder (struct, fluent API) .AddHeader(text) .AddText(text) .AddStat(label, value) .AddImage(sprite) .AddSeparator() → activates pre-existing child elements in PopupReference ``` ## Core Types ### PopupCategory (readonly struct) Same pattern as LogChannel. Value type, string-keyed, zero-alloc comparisons. ```csharp [Serializable] public readonly struct PopupCategory : IEquatable { 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"); } ``` ### PopupSettings (ScriptableObject) Full configuration loaded via Addressables. ``` General: popupDelay float 0.4s Time before popup appears after hover fadeDuration float 0.2s Default animation duration defaultAnchorSide enum Below Below/Above/Left/Right screenEdgePadding float 10px Minimum distance from screen edge maxPopupWidth float 400px Maximum content width sortingOrder int 100 Canvas sorting order Follow Mouse: followMouseOffset Vector2 (15, -15) Offset from cursor in follow mode Input: touchHoldDuration float 0.6s Press-and-hold duration for touch gamepadFocusTrigger bool true Trigger on gamepad focus events Priority: categoryPriorities List - category: PopupCategory - priority: int Higher value = higher priority Per-Category Overrides: categoryDelayOverrides List - category: PopupCategory - delay: float ``` ### IPopupSystem (interface) ```csharp public interface IPopupSystem { void RegisterCategory(PopupCategory category, int priority = 0); void Show(PopupCategory category, Action buildContent, RectTransform anchor = null, AnchorSide? anchorSide = null); void ShowAtPosition(PopupCategory category, Action buildContent, Vector2 screenPosition); void Hide(PopupCategory category); void HideAll(); void Tick(float deltaTime); void Dispose(); } ``` ### PopupSystem (implementation) - `Dictionary` — O(1) lookup by category - `PopupReferenceState` holds: PopupReference instance (null until first show), priority, delay timer, pending show data - `Tick(deltaTime)` drives delay countdown and animation lerp — no coroutines - On `Show()`: - If a higher-priority popup is already visible, queue or cancel - Start delay timer; on expiry, activate the view - PopupReference clears content (deactivates cached elements), runs builder callback, positions, animates in - On `Hide()`: - Animate out, deactivate - On `Dispose()`: - Destroy all PopupReference GameObjects ### PopupReference (MonoBehaviour) Single prefab, instantiated once per category: ``` PopupReference (Canvas, CanvasGroup, RectMask2D) ├── Content (RectTransform, VerticalLayoutGroup, ContentSizeFitter) │ ├── [cached] HeaderElement (TMP_Text, deactivated) │ ├── [cached] TextElement x N (TMP_Text, deactivated) │ ├── [cached] StatElement x N (TMP_Text label + TMP_Text value, deactivated) │ ├── [cached] ImageElement x N (Image, deactivated) │ └── [cached] SeparatorElement x N (Image, deactivated) └── Background (Image, optional) ``` **Grow-only element cache:** - Pre-creates a small set of each element type (e.g. 3 text, 2 stat, 1 image, 2 separator) - `GetOrCreateElement(type)` returns next available deactivated element, or Instantiates if none free - New elements persist and are reused on next Show - `ClearContent()` deactivates all elements and resets the reuse index — no Destroy calls **Positioning:** - `AnchorToElement(RectTransform target, AnchorSide side)` — positions relative to target element - `FollowMouse(Vector2 offset)` — updates position each Tick to track cursor - `ClampToScreen(float padding)` — adjusts position if popup overflows screen bounds, flips anchor side if needed **Animation:** - Calls `IPopupAnimator.Show(canvasGroup, duration, onComplete)` and `Hide(...)` - Default `FadePopupAnimator` lerps `canvasGroup.alpha` 0→1 / 1→0 over duration - Driven by float timer in Tick, not coroutines ### PopupContentBuilder (struct) ```csharp public struct PopupContentBuilder { readonly PopupReference view; public PopupContentBuilder AddHeader(string text); public PopupContentBuilder AddText(string text); public PopupContentBuilder AddText(string text, string hexColor); public PopupContentBuilder AddStat(string label, int value); public PopupContentBuilder AddStat(string label, string value); public PopupContentBuilder AddImage(Sprite sprite, float height = 64f); public PopupContentBuilder AddSeparator(); } ``` Each method activates a cached element from PopupReference, sets its data, calls `SetAsLastSibling()` for ordering. Returns `this` for chaining. No allocations. ### PopupTrigger (MonoBehaviour) Attach to any UI element. Handles hover detection automatically. ```csharp public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler { [SerializeField] PopupCategory category; [SerializeField] AnchorSide anchorSide; [SerializeField] PopupPositionMode positionMode; // AnchorToElement or FollowMouse IPopupSystem popupSystem; Action contentCallback; public void Initialize(IPopupSystem popupSystem, Action contentCallback); // IPointerEnterHandler — calls popupSystem.Show(category, contentCallback, rectTransform) // IPointerExitHandler — calls popupSystem.Hide(category) } ``` - Category, anchor side, position mode configurable in Inspector - `Initialize()` called from code to inject the popup system and content builder callback - No per-frame cost when not hovered - The `contentCallback` is set once and reused — no allocation per hover ### IPopupAnimator (interface) ```csharp 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; } } ``` Default implementation: `FadePopupAnimator` — lerps alpha. Custom implementations can do scale, slide, bounce, etc. ### PopupCategoryJsonConverter Newtonsoft converter for serializing PopupCategory as string id (same pattern as LogChannelJsonConverter). ## Enums ```csharp public enum AnchorSide { Below, Above, Left, Right } public enum PopupPositionMode { AnchorToElement, FollowMouse } ``` ## Optimization Summary | Concern | Approach | |---------|----------| | Hot path (Show/Hide) | Struct category, dict lookup, no alloc | | Content elements | Grow-only cache, activate/deactivate, never Destroy | | Timers | Float countdown in Tick(), no coroutines | | Builder | Struct, operates directly on cached elements | | Per-frame | Zero alloc when idle; follow-mouse only reads Input position | | Trigger | IPointerEnter/Exit interfaces, no delegates allocated per hover | ## Package Structure ``` Packages/com.jovian.popup-system/ ├── package.json ├── README.md ├── Runtime/ │ ├── Jovian.PopupSystem.asmdef │ ├── PopupCategory.cs │ ├── PopupCategoryJsonConverter.cs │ ├── PopupSettings.cs │ ├── IPopupSystem.cs │ ├── PopupSystem.cs │ ├── IPopupAnimator.cs │ ├── FadePopupAnimator.cs │ ├── PopupContentBuilder.cs │ └── UI/ │ ├── PopupReference.cs │ └── PopupTrigger.cs ├── Editor/ │ ├── Jovian.PopupSystem.Editor.asmdef │ └── PopupSettingsProvider.cs └── Samples~/ └── README.md ``` ### Dependencies - `com.unity.textmeshpro` (TMP_Text for content elements) - `com.unity.inputsystem` (for pointer position in follow-mouse mode) - `com.unity.nuget.newtonsoft-json` (for PopupCategory serialization) ## Integration Example ```csharp // In GameModeGameState or similar: var popupSettings = Addressables.LoadAssetAsync("PopupSettings").WaitForCompletion(); var popupViewPrefab = Addressables.LoadAssetAsync("PopupReferencePrefab").WaitForCompletion(); var popupSystem = new PopupSystem(popupSettings, popupViewPrefab); popupSystem.RegisterCategory(PopupCategory.Character, priority: 10); popupSystem.RegisterCategory(PopupCategory.Item, priority: 5); // Pass popupSystem to views/play modes via constructor DI // In PartyGuiView — attach PopupTrigger to each slot: var trigger = slot.GetComponent(); trigger.Initialize(popupSystem, builder => { builder.AddHeader(member.Name) .AddSeparator() .AddStat("Health", member.Stats.GetValue(StatType.Health)) .AddStat("Mana", member.Stats.GetValue(StatType.Mana)) .AddStat("Level", member.Stats.GetValue(StatType.Level)) .AddText($"{member.Race} {member.Class}"); }); // In Tick: popupSystem.Tick(Time.deltaTime); // On state exit: popupSystem.Dispose(); ``` ## Prefab Setup ### PopupReference prefab 1. Root: Canvas (Screen Space Overlay, sorting order from settings), CanvasGroup (alpha=0) 2. Child "Content": RectTransform, VerticalLayoutGroup (Child Force Expand Width: true, Height: false, Spacing: 4, Padding: 8), ContentSizeFitter (Vertical Fit: Preferred Size, Horizontal Fit: Preferred Size up to maxPopupWidth) 3. Background Image behind Content 4. Optional: RectMask2D on root Pre-create cached elements as children of Content (all deactivated): - 2x Header (TMP_Text, bold, larger font) - 4x Text (TMP_Text, normal) - 4x Stat (horizontal layout: TMP_Text label + TMP_Text value) - 2x Image (Image component) - 3x Separator (Image, 1px height, horizontal stretch)