Files
trail-into-darkness/docs/plans/2026-04-06-popup-system-design.md
Sebastian Bularca cbf9f384d9 popup changes
2026-04-06 10:44:16 +02:00

11 KiB

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.

[Serializable]
public readonly struct PopupCategory : IEquatable<PopupCategory> {
    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<CategoryPriority>
    - category: PopupCategory
    - priority: int           Higher value = higher priority

Per-Category Overrides:
  categoryDelayOverrides  List<CategoryDelay>
    - category: PopupCategory
    - delay: float

IPopupSystem (interface)

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

PopupSystem (implementation)

  • Dictionary<PopupCategory, PopupReferenceState> — 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<T>(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)

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.

public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
    [SerializeField] PopupCategory category;
    [SerializeField] AnchorSide anchorSide;
    [SerializeField] PopupPositionMode positionMode; // AnchorToElement or FollowMouse

    IPopupSystem popupSystem;
    Action<PopupContentBuilder> contentCallback;

    public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> 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)

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

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

// In GameModeGameState or similar:
var popupSettings = Addressables.LoadAssetAsync<PopupSettings>("PopupSettings").WaitForCompletion();
var popupViewPrefab = Addressables.LoadAssetAsync<PopupReference>("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<PopupTrigger>();
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)