forked from Shardstone/trail-into-darkness
Added a popup system
This commit is contained in:
306
docs/plans/2026-04-06-popup-system-design.md
Normal file
306
docs/plans/2026-04-06-popup-system-design.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# 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 PopupView per category (lazy-created on first Show)
|
||||
│ Priority: higher priority category dismisses lower on show
|
||||
│ Tick()-driven delay timers and animations (no coroutines)
|
||||
▼
|
||||
PopupView (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 PopupView
|
||||
```
|
||||
|
||||
## Core Types
|
||||
|
||||
### PopupCategory (readonly struct)
|
||||
|
||||
Same pattern as LogChannel. Value type, string-keyed, zero-alloc comparisons.
|
||||
|
||||
```csharp
|
||||
[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)
|
||||
|
||||
```csharp
|
||||
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, PopupViewState>` — O(1) lookup by category
|
||||
- `PopupViewState` holds: PopupView 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
|
||||
- PopupView clears content (deactivates cached elements), runs builder callback, positions, animates in
|
||||
- On `Hide()`:
|
||||
- Animate out, deactivate
|
||||
- On `Dispose()`:
|
||||
- Destroy all PopupView GameObjects
|
||||
|
||||
### PopupView (MonoBehaviour)
|
||||
|
||||
Single prefab, instantiated once per category:
|
||||
|
||||
```
|
||||
PopupView (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)
|
||||
|
||||
```csharp
|
||||
public struct PopupContentBuilder {
|
||||
readonly PopupView 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 PopupView, 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<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)
|
||||
|
||||
```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/
|
||||
│ ├── PopupView.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>("PopupSettings").WaitForCompletion();
|
||||
var popupViewPrefab = Addressables.LoadAssetAsync<PopupView>("PopupViewPrefab").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
|
||||
|
||||
### PopupView 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)
|
||||
Reference in New Issue
Block a user