forked from Shardstone/trail-into-darkness
418 lines
15 KiB
Markdown
418 lines
15 KiB
Markdown
# Jovian Popup System
|
|
|
|
A lightweight, low-allocation popup and tooltip system for Unity with category-based isolation, a fluent content builder, and extensible animations.
|
|
|
|
## Requirements
|
|
|
|
- Unity 2022.3 or later
|
|
- TextMeshPro 3.0.6+
|
|
- Input System 1.18.0+
|
|
- Newtonsoft JSON 3.2.1+
|
|
|
|
Install via the Unity Package Manager by adding the package from its local path or from a git URL.
|
|
|
|
## Quick Start
|
|
|
|
### 1. Create a PopupSettings asset
|
|
|
|
In the Unity Editor, go to **Assets > Create > Jovian > Popup System > Popup Settings**. Place the asset somewhere accessible (e.g. `Assets/Settings/PopupSettings.asset`). You can also configure settings via **Project Settings > Jovian > Popup System**.
|
|
|
|
### 2. Build a PopupReference prefab
|
|
|
|
See the [Prefab Setup](#prefab-setup) section below for step-by-step instructions.
|
|
|
|
### 3. Create and use PopupSystem
|
|
|
|
```csharp
|
|
using Jovian.PopupSystem;
|
|
using Jovian.PopupSystem.UI;
|
|
|
|
// Create the system. viewPrefab is a reference to your PopupReference prefab.
|
|
var popup = new PopupSystem(settings, viewPrefab);
|
|
|
|
// Register categories you intend to use.
|
|
popup.RegisterCategory(PopupCategory.Item);
|
|
popup.RegisterCategory(PopupCategory.Character);
|
|
|
|
// Show a popup anchored to a UI element.
|
|
popup.Show(PopupCategory.Item, builder => {
|
|
builder
|
|
.AddHeader("Health Potion")
|
|
.AddSeparator()
|
|
.AddText("Restores a moderate amount of health.")
|
|
.AddStat("Heal Amount", 50)
|
|
.AddStat("Uses", "3 / 3");
|
|
}, anchorRect, AnchorSide.Right);
|
|
|
|
// Tick each frame (typically in Update or a game-state loop).
|
|
popup.Tick(Time.deltaTime);
|
|
|
|
// Clean up when the game state is torn down.
|
|
popup.Dispose();
|
|
```
|
|
|
|
## PopupCategory
|
|
|
|
`PopupCategory` is a readonly struct that acts as a channel for isolating popups. Each category gets its own view instance and can have independent priority and delay settings.
|
|
|
|
### Built-in categories
|
|
|
|
```csharp
|
|
PopupCategory.Character // Character tooltips
|
|
PopupCategory.Item // Item tooltips
|
|
PopupCategory.Skill // Skill tooltips
|
|
PopupCategory.General // General-purpose tooltips
|
|
```
|
|
|
|
### Custom categories
|
|
|
|
Create any number of additional categories:
|
|
|
|
```csharp
|
|
var lootCategory = new PopupCategory("Loot");
|
|
popup.RegisterCategory(lootCategory, priority: 5);
|
|
```
|
|
|
|
Categories are compared by their string ID using ordinal comparison.
|
|
|
|
## PopupSettings
|
|
|
|
`PopupSettings` is a ScriptableObject that holds all configuration. Create one via **Assets > Create > Jovian > Popup System > Popup Settings**.
|
|
|
|
| Field | Type | Default | Description |
|
|
|---|---|---|---|
|
|
| `popupDelay` | float | 0.4 | Seconds before the popup appears after a show request. |
|
|
| `fadeDuration` | float | 0.2 | Duration of the fade-in and fade-out animation. |
|
|
| `defaultAnchorSide` | AnchorSide | Below | Default side to anchor the popup relative to the target element. |
|
|
| `screenEdgePadding` | float | 10 | Minimum pixel distance from screen edges. |
|
|
| `maxPopupWidth` | float | 400 | Maximum width of the popup in pixels. |
|
|
| `sortingOrder` | int | 100 | Sorting order applied to the popup Canvas. |
|
|
| `followMouseOffset` | Vector2 | (15, -15) | Pixel offset from the cursor in follow-mouse mode. |
|
|
| `touchHoldDuration` | float | 0.6 | Seconds a touch must be held before triggering a popup. |
|
|
| `gamepadFocusTrigger` | bool | true | Whether gamepad focus triggers popups. |
|
|
| `categoryPriorities` | List | empty | Per-category priority overrides. Higher priority popups dismiss lower ones. |
|
|
| `categoryDelayOverrides` | List | empty | Per-category delay overrides. Overrides `popupDelay` for specific categories. |
|
|
|
|
## PopupContentBuilder
|
|
|
|
`PopupContentBuilder` is a struct with a fluent API for composing popup content. You receive it in the build callback passed to `Show` or `ShowAtPosition`.
|
|
|
|
### Available methods
|
|
|
|
```csharp
|
|
builder.AddHeader("Fireball"); // Bold header text
|
|
builder.AddText("Deals fire damage to all enemies."); // Body text
|
|
builder.AddText("Rare", "FFD700"); // Colored text (hex, with or without #)
|
|
builder.AddStat("Damage", 120); // Label + integer value row
|
|
builder.AddStat("Range", "15m"); // Label + string value row
|
|
builder.AddImage(iconSprite, 64f); // Sprite with optional height
|
|
builder.AddSeparator(); // Horizontal divider line
|
|
```
|
|
|
|
### Full example
|
|
|
|
```csharp
|
|
popup.Show(PopupCategory.Skill, builder => {
|
|
builder
|
|
.AddHeader("Fireball")
|
|
.AddText("Hurls a ball of fire that explodes on impact.")
|
|
.AddSeparator()
|
|
.AddStat("Damage", 120)
|
|
.AddStat("Mana Cost", 35)
|
|
.AddStat("Range", "15m")
|
|
.AddSeparator()
|
|
.AddText("Requires: Level 5", "FF6666");
|
|
}, targetRect);
|
|
```
|
|
|
|
All elements are drawn from a grow-only pool inside `PopupReference`. No allocations occur once the pool is warmed.
|
|
|
|
## PopupTrigger
|
|
|
|
`PopupTrigger` is a MonoBehaviour you attach to UI elements to get hover-based popup behavior automatically. It implements `IPointerEnterHandler` and `IPointerExitHandler`.
|
|
|
|
### Inspector fields
|
|
|
|
| Field | Type | Description |
|
|
|---|---|---|
|
|
| `category` | PopupCategory | Which category channel to use. |
|
|
| `anchorSide` | AnchorSide | Which side of this element to anchor the popup. |
|
|
| `positionMode` | PopupPositionMode | `AnchorToElement` or `FollowMouse`. |
|
|
|
|
### Initialization
|
|
|
|
`PopupTrigger` must be initialized from code before it will respond to pointer events:
|
|
|
|
```csharp
|
|
var trigger = button.GetComponent<PopupTrigger>();
|
|
trigger.Initialize(popupSystem, builder => {
|
|
builder
|
|
.AddHeader("Attack")
|
|
.AddStat("Damage", 25);
|
|
});
|
|
```
|
|
|
|
You can also override the category at initialization time:
|
|
|
|
```csharp
|
|
trigger.Initialize(popupSystem, PopupCategory.Skill, builder => {
|
|
builder.AddHeader("Fireball");
|
|
});
|
|
```
|
|
|
|
To change content without re-initializing:
|
|
|
|
```csharp
|
|
trigger.UpdateContent(builder => {
|
|
builder
|
|
.AddHeader("Attack")
|
|
.AddStat("Damage", newDamageValue);
|
|
});
|
|
```
|
|
|
|
## Code-Only Triggers
|
|
|
|
You do not need `PopupTrigger` to show popups. Call `IPopupSystem` methods directly:
|
|
|
|
### Anchor to a RectTransform
|
|
|
|
```csharp
|
|
popupSystem.Show(PopupCategory.Item, builder => {
|
|
builder.AddHeader("Iron Sword");
|
|
}, inventorySlotRect, AnchorSide.Right);
|
|
```
|
|
|
|
### Follow the mouse cursor
|
|
|
|
Pass no anchor argument:
|
|
|
|
```csharp
|
|
popupSystem.Show(PopupCategory.General, builder => {
|
|
builder.AddText("Click to interact");
|
|
});
|
|
```
|
|
|
|
### Show at a fixed screen position
|
|
|
|
```csharp
|
|
popupSystem.ShowAtPosition(PopupCategory.General, builder => {
|
|
builder.AddText("Tutorial tip");
|
|
}, new Vector2(Screen.width * 0.5f, Screen.height * 0.5f));
|
|
```
|
|
|
|
### Hide
|
|
|
|
```csharp
|
|
popupSystem.Hide(PopupCategory.Item); // Hide a specific category
|
|
popupSystem.HideAll(); // Hide all categories
|
|
```
|
|
|
|
## Priority System
|
|
|
|
When a popup is shown, any visible popup from a lower-priority category is automatically dismissed. Priority is configured per category via `PopupSettings.categoryPriorities` or at registration time:
|
|
|
|
```csharp
|
|
popup.RegisterCategory(PopupCategory.Character, priority: 10);
|
|
popup.RegisterCategory(PopupCategory.Item, priority: 5);
|
|
popup.RegisterCategory(PopupCategory.General, priority: 1);
|
|
```
|
|
|
|
If the player hovers a character portrait (priority 10) while an item tooltip (priority 5) is visible, the item tooltip is dismissed. Popups of equal or higher priority are not affected.
|
|
|
|
Settings-based priorities (from `PopupSettings.categoryPriorities`) take precedence over the `priority` argument in `RegisterCategory`.
|
|
|
|
## Lifecycle Integration
|
|
|
|
The popup system is designed to be created per game state, not as a global singleton. Each game state owns its own `IPopupSystem` instance:
|
|
|
|
```csharp
|
|
// In your game state constructor or initialization:
|
|
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab);
|
|
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
|
|
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
|
|
|
|
// Pass to views/play modes via constructor DI
|
|
|
|
// In your game state's Tick/Update:
|
|
popupSystem.Tick(Time.deltaTime);
|
|
|
|
// When the game state exits:
|
|
popupSystem.Dispose(); // destroys all popup view GameObjects
|
|
```
|
|
|
|
Each category lazily creates its own `PopupReference` instance on first `Show` call. On `Dispose`, all views are destroyed. This ensures no leaked GameObjects when transitioning between game states.
|
|
|
|
## IPopupAnimator
|
|
|
|
The popup system uses `IPopupAnimator` for show/hide transitions. The default implementation is `FadePopupAnimator`, which lerps the `CanvasGroup.alpha` over the configured `fadeDuration`.
|
|
|
|
### 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; }
|
|
}
|
|
```
|
|
|
|
### Custom animator example
|
|
|
|
```csharp
|
|
public sealed class ScalePopupAnimator : IPopupAnimator {
|
|
private CanvasGroup target;
|
|
private float duration;
|
|
private float elapsed;
|
|
private float startScale;
|
|
private float endScale;
|
|
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;
|
|
startScale = 0f;
|
|
endScale = 1f;
|
|
this.onComplete = onComplete;
|
|
canvasGroup.transform.localScale = Vector3.zero;
|
|
canvasGroup.alpha = 1f;
|
|
}
|
|
|
|
public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
|
|
target = canvasGroup;
|
|
this.duration = Mathf.Max(duration, 0.001f);
|
|
elapsed = 0f;
|
|
startScale = 1f;
|
|
endScale = 0f;
|
|
this.onComplete = onComplete;
|
|
}
|
|
|
|
public void Tick(float deltaTime) {
|
|
if(target == null) {
|
|
return;
|
|
}
|
|
|
|
elapsed += deltaTime;
|
|
var t = Mathf.Clamp01(elapsed / duration);
|
|
var scale = Mathf.Lerp(startScale, endScale, t);
|
|
target.transform.localScale = Vector3.one * scale;
|
|
|
|
if(t >= 1f) {
|
|
var callback = onComplete;
|
|
target = null;
|
|
onComplete = null;
|
|
callback?.Invoke();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Pass a factory function to the constructor so each category gets its own animator instance:
|
|
|
|
```csharp
|
|
var popup = new PopupSystem(settings, viewPrefab, () => new ScalePopupAnimator());
|
|
```
|
|
|
|
## Prefab Setup
|
|
|
|
Build the `PopupReference` prefab with the following hierarchy:
|
|
|
|
### Step 1: Root GameObject
|
|
|
|
1. Create a new GameObject named `PopupReference`.
|
|
2. Add a `Canvas` component. Set **Render Mode** to **Screen Space - Overlay**. Set **Sort Order** to match `PopupSettings.sortingOrder` (default 100).
|
|
3. Add a `CanvasGroup` component.
|
|
4. Add a `PopupReference` component (from `Jovian.PopupSystem.UI`).
|
|
|
|
### Step 2: Background
|
|
|
|
1. Create a child GameObject named `Background`.
|
|
2. Add a `RectTransform` and an `Image` component. Set the image color/sprite to your desired popup background.
|
|
3. Add a `ContentSizeFitter` with **Vertical Fit** set to **Preferred Size**.
|
|
|
|
### Step 3: Content container
|
|
|
|
1. Create a child of `Background` named `Content`.
|
|
2. Add a `RectTransform`. Stretch it to fill the background with appropriate padding.
|
|
3. Add a `VerticalLayoutGroup`. Configure padding and spacing to taste.
|
|
4. Add a `ContentSizeFitter` with **Vertical Fit** set to **Preferred Size**.
|
|
|
|
### Step 4: Element prefabs
|
|
|
|
Create these as child prefabs (or separate prefabs). Each must be a prefab reference, not an in-hierarchy instance:
|
|
|
|
| Prefab | Component | Notes |
|
|
|---|---|---|
|
|
| Header | `TMP_Text` | Bold, larger font size |
|
|
| Text | `TMP_Text` | Regular body text |
|
|
| Stat | `RectTransform` with `HorizontalLayoutGroup` | Must have exactly two `TMP_Text` children: label and value |
|
|
| Image | `Image` | For icons or artwork |
|
|
| Separator | `Image` | Thin horizontal line |
|
|
|
|
### Step 5: Wire references
|
|
|
|
On the `PopupReference` component, assign:
|
|
|
|
- **Content** - the Content RectTransform
|
|
- **Canvas Group** - the root CanvasGroup
|
|
- **Background** - the Background RectTransform
|
|
- **Header Prefab** - your header TMP_Text prefab
|
|
- **Text Prefab** - your body text TMP_Text prefab
|
|
- **Stat Prefab** - your stat row RectTransform prefab
|
|
- **Image Prefab** - your image prefab
|
|
- **Separator Prefab** - your separator Image prefab
|
|
|
|
Save as a prefab. The `maxPopupWidth` from `PopupSettings` is applied at runtime to constrain the popup's horizontal size.
|
|
|
|
## Optimization Notes
|
|
|
|
The popup system is designed for minimal runtime allocation:
|
|
|
|
- `PopupCategory` is a readonly struct with value semantics -- zero heap allocation on pass or comparison
|
|
- `PopupContentBuilder` is a struct that operates directly on cached UI elements -- no intermediate data structures
|
|
- Content elements use a **grow-only cache**: elements are activated/deactivated, never instantiated or destroyed after warmup. `GetStat` caches TMP_Text references in a struct to avoid `GetComponentsInChildren` on reuse
|
|
- Delay timers and animation are driven by `float` fields in `Tick()` -- no coroutines, no `WaitForSeconds`
|
|
- Screen rect calculation uses a static `Vector3[4]` buffer -- no per-frame array allocation
|
|
- Layout rebuilds only occur when content changes (dirty flag), not on every position update
|
|
- Each registered category gets its own `IPopupAnimator` instance, preventing state corruption during concurrent show/hide animations
|
|
|
|
## API Reference
|
|
|
|
### Core Types
|
|
|
|
| Type | Namespace | Description |
|
|
|---|---|---|
|
|
| `IPopupSystem` | `Jovian.PopupSystem` | Main interface for showing/hiding popups. |
|
|
| `PopupSystem` | `Jovian.PopupSystem` | Concrete implementation. Constructor: `(PopupSettings, PopupReference, Func<IPopupAnimator>)`. |
|
|
| `PopupSettings` | `Jovian.PopupSystem` | ScriptableObject with all configuration fields. |
|
|
| `PopupCategory` | `Jovian.PopupSystem` | Readonly struct identifying a popup channel. |
|
|
| `PopupContentBuilder` | `Jovian.PopupSystem` | Fluent struct for building popup content in callbacks. |
|
|
|
|
### UI Types
|
|
|
|
| Type | Namespace | Description |
|
|
|---|---|---|
|
|
| `PopupReference` | `Jovian.PopupSystem.UI` | MonoBehaviour managing popup layout, pooling, and positioning. |
|
|
| `PopupTrigger` | `Jovian.PopupSystem.UI` | MonoBehaviour for hover-based popup triggers on UI elements. |
|
|
|
|
### Animation Types
|
|
|
|
| Type | Namespace | Description |
|
|
|---|---|---|
|
|
| `IPopupAnimator` | `Jovian.PopupSystem` | Interface for custom show/hide animations. |
|
|
| `FadePopupAnimator` | `Jovian.PopupSystem` | Default fade animation via CanvasGroup alpha. |
|
|
|
|
### Enums
|
|
|
|
| Type | Values |
|
|
|---|---|
|
|
| `AnchorSide` | `Below`, `Above`, `Left`, `Right` |
|
|
| `PopupPositionMode` | `AnchorToElement`, `FollowMouse` |
|
|
|
|
## License
|
|
|
|
See the LICENSE file in the package root.
|