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

@@ -22,7 +22,8 @@
"Bash(xxd Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs)",
"Bash(find D:/repos/trail-into-darkness/Assets -name *.asmdef)",
"Bash(grep -E \"\\\\.\\(prefab|unity\\)$\")",
"Bash(python3:*)"
"Bash(python3:*)",
"WebSearch"
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
{
"name": "Jovian.PopupSystem.Editor",
"rootNamespace": "Jovian.PopupSystem.Editor",
"references": [
"Jovian.PopupSystem"
],
"includePlatforms": ["Editor"],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace Jovian.PopupSystem.Editor {
public sealed class PopupSettingsProvider : SettingsProvider {
private PopupSettings settings;
private PopupSettingsProvider(string path, SettingsScope scope)
: base(path, scope) { }
private SerializedObject serializedSettings;
public override void OnActivate(string searchContext, UnityEngine.UIElements.VisualElement rootElement) {
var guids = AssetDatabase.FindAssets("t:PopupSettings");
if(guids.Length > 0) {
var path = AssetDatabase.GUIDToAssetPath(guids[0]);
settings = AssetDatabase.LoadAssetAtPath<PopupSettings>(path);
}
if(settings != null) {
serializedSettings = new SerializedObject(settings);
}
}
public override void OnGUI(string searchContext) {
if(settings == null) {
EditorGUILayout.HelpBox("No PopupSettings asset found. Create one via Assets > Create > Jovian > Popup System > Popup Settings.", MessageType.Warning);
return;
}
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Popup System Settings", EditorStyles.boldLabel);
EditorGUILayout.Space(5);
serializedSettings.Update();
EditorGUI.BeginChangeCheck();
EditorGUILayout.PropertyField(serializedSettings.FindProperty("popupDelay"));
EditorGUILayout.PropertyField(serializedSettings.FindProperty("fadeDuration"));
EditorGUILayout.PropertyField(serializedSettings.FindProperty("defaultAnchorSide"));
EditorGUILayout.PropertyField(serializedSettings.FindProperty("screenEdgePadding"));
EditorGUILayout.PropertyField(serializedSettings.FindProperty("maxPopupWidth"));
EditorGUILayout.PropertyField(serializedSettings.FindProperty("sortingOrder"));
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Follow Mouse", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedSettings.FindProperty("followMouseOffset"));
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Input", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedSettings.FindProperty("touchHoldDuration"));
EditorGUILayout.PropertyField(serializedSettings.FindProperty("gamepadFocusTrigger"));
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Priority", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedSettings.FindProperty("categoryPriorities"), true);
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Per-Category Overrides", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedSettings.FindProperty("categoryDelayOverrides"), true);
if(EditorGUI.EndChangeCheck()) {
serializedSettings.ApplyModifiedProperties();
EditorUtility.SetDirty(settings);
}
}
[SettingsProvider]
public static SettingsProvider CreateProvider() {
var provider = new PopupSettingsProvider("Project/Jovian/Popup System", SettingsScope.Project) {
keywords = new HashSet<string>(new[] { "popup", "tooltip", "hover", "delay" })
};
return provider;
}
}
}

View File

@@ -0,0 +1,417 @@
# 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 PopupView 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 PopupView 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 `PopupView`. 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 `PopupView` 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 `PopupView` prefab with the following hierarchy:
### Step 1: Root GameObject
1. Create a new GameObject named `PopupView`.
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 `PopupView` 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 `PopupView` 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, PopupView, 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 |
|---|---|---|
| `PopupView` | `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.

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

View File

@@ -0,0 +1,3 @@
# Samples
This folder is reserved for sample scenes and scripts demonstrating the Popup System.

View File

@@ -0,0 +1,20 @@
{
"name": "com.jovian.popup-system",
"version": "0.1.0",
"displayName": "Jovian Popup System",
"description": "A lightweight, low-allocation popup and tooltip system with category-based isolation, fluent content builder, and extensible animations.",
"unity": "2022.3",
"dependencies": {
"com.unity.textmeshpro": "3.0.6",
"com.unity.inputsystem": "1.18.0",
"com.unity.nuget.newtonsoft-json": "3.2.1"
},
"keywords": [
"popup",
"tooltip",
"ui"
],
"author": {
"name": "Jovian"
}
}

View 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)

View File

@@ -0,0 +1,999 @@
# Popup System Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build `com.jovian.popup-system`, a low-allocation popup/tooltip package with category-based isolation, fluent content builder, grow-only element cache, screen edge clamping, and extensible animations.
**Architecture:** Per-game-state `IPopupSystem` instances with registered categories. Each category lazily creates one `PopupView` MonoBehaviour that reuses cached content elements. `PopupTrigger` MonoBehaviour handles hover detection. `PopupContentBuilder` struct operates directly on cached elements. Float-based timers in `Tick()` — no coroutines.
**Tech Stack:** Unity 6 / C# 9, TextMeshPro, Unity Input System, Newtonsoft.Json
---
### Task 1: Package scaffold
**Files:**
- Create: `Packages/com.jovian.popup-system/package.json`
- Create: `Packages/com.jovian.popup-system/Runtime/Jovian.PopupSystem.asmdef`
- Create: `Packages/com.jovian.popup-system/Editor/Jovian.PopupSystem.Editor.asmdef`
- Create: `Packages/com.jovian.popup-system/Samples~/README.md`
**Step 1: Create package.json**
```json
{
"name": "com.jovian.popup-system",
"version": "0.1.0",
"displayName": "Jovian Popup System",
"description": "A lightweight, low-allocation popup and tooltip system with category-based isolation, fluent content builder, and extensible animations.",
"unity": "2022.3",
"dependencies": {
"com.unity.textmeshpro": "3.0.6",
"com.unity.inputsystem": "1.18.0",
"com.unity.nuget.newtonsoft-json": "3.2.1"
},
"keywords": [
"popup",
"tooltip",
"ui"
],
"author": {
"name": "Jovian"
}
}
```
**Step 2: Create Runtime asmdef**
```json
{
"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
}
```
**Step 3: Create Editor asmdef**
```json
{
"name": "Jovian.PopupSystem.Editor",
"rootNamespace": "Jovian.PopupSystem.Editor",
"references": [
"Jovian.PopupSystem"
],
"includePlatforms": ["Editor"],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
```
**Step 4: Create Samples~/README.md**
```markdown
# Samples
This folder is reserved for sample scenes and scripts demonstrating the Popup System.
```
**Step 5: Commit**
```bash
git add Packages/com.jovian.popup-system/
git commit -m "feat: scaffold com.jovian.popup-system package"
```
---
### Task 2: PopupCategory + JSON converter + enums
**Files:**
- Create: `Packages/com.jovian.popup-system/Runtime/PopupCategory.cs`
- Create: `Packages/com.jovian.popup-system/Runtime/PopupCategoryJsonConverter.cs`
- Create: `Packages/com.jovian.popup-system/Runtime/PopupEnums.cs`
**Step 1: Implement PopupCategory** (same pattern as LogChannel)
```csharp
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);
}
}
}
```
**Step 2: Implement JSON converter**
```csharp
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);
}
}
}
```
**Step 3: Implement enums**
```csharp
namespace Jovian.PopupSystem {
public enum AnchorSide {
Below,
Above,
Left,
Right
}
public enum PopupPositionMode {
AnchorToElement,
FollowMouse
}
}
```
**Step 4: Commit**
```bash
git add Packages/com.jovian.popup-system/Runtime/
git commit -m "feat: add PopupCategory, enums, and JSON converter"
```
---
### Task 3: PopupSettings ScriptableObject
**Files:**
- Create: `Packages/com.jovian.popup-system/Runtime/PopupSettings.cs`
**Step 1: Implement settings**
```csharp
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;
}
}
```
**Step 2: Commit**
```bash
git add Packages/com.jovian.popup-system/Runtime/PopupSettings.cs
git commit -m "feat: add PopupSettings ScriptableObject with full configuration"
```
---
### Task 4: IPopupAnimator + FadePopupAnimator
**Files:**
- Create: `Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs`
- Create: `Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs`
**Step 1: Implement interface**
```csharp
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; }
}
}
```
**Step 2: Implement FadePopupAnimator**
```csharp
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();
}
}
}
}
```
**Step 3: Commit**
```bash
git add Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs
git commit -m "feat: add IPopupAnimator interface and FadePopupAnimator"
```
---
### Task 5: PopupView MonoBehaviour with element cache
**Files:**
- Create: `Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs`
**Step 1: Implement PopupView with grow-only element cache, positioning, and screen clamping**
```csharp
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; // contains label + value TMP_Text
[SerializeField] Image imagePrefab;
[SerializeField] Image separatorPrefab;
// Element caches (grow-only)
readonly List<TMP_Text> headerCache = new();
readonly List<TMP_Text> textCache = new();
readonly List<RectTransform> statCache = new();
readonly List<Image> imageCache = new();
readonly List<Image> separatorCache = new();
int headerIndex;
int textIndex;
int statIndex;
int imageIndex;
int separatorIndex;
// State
PopupPositionMode positionMode;
RectTransform anchorTarget;
AnchorSide anchorSide;
Vector2 followOffset;
float screenEdgePadding;
bool isVisible;
public CanvasGroup CanvasGroup => canvasGroup;
public RectTransform Content => content;
public bool IsVisible => isVisible;
public void SetVisible(bool visible) {
isVisible = visible;
gameObject.SetActive(visible);
if(!visible) {
canvasGroup.alpha = 0f;
}
}
public void ClearContent() {
for(int i = 0; i < headerIndex; i++) {
headerCache[i].gameObject.SetActive(false);
}
for(int i = 0; i < textIndex; i++) {
textCache[i].gameObject.SetActive(false);
}
for(int i = 0; i < statIndex; i++) {
statCache[i].gameObject.SetActive(false);
}
for(int i = 0; i < imageIndex; i++) {
imageCache[i].gameObject.SetActive(false);
}
for(int i = 0; i < separatorIndex; i++) {
separatorCache[i].gameObject.SetActive(false);
}
headerIndex = 0;
textIndex = 0;
statIndex = 0;
imageIndex = 0;
separatorIndex = 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() {
var rt = GetOrCreate(statCache, statPrefab, ref statIndex);
var children = rt.GetComponentsInChildren<TMP_Text>(true);
return (children[0], children[1]);
}
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 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 popupRect = GetScreenRect((RectTransform)transform);
var popupSize = popupRect.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;
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
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 Rect GetScreenRect(RectTransform rt) {
var corners = new Vector3[4];
rt.GetWorldCorners(corners);
var min = new Vector2(corners[0].x, corners[0].y);
var max = new Vector2(corners[2].x, corners[2].y);
return new Rect(min, max - min);
}
}
}
```
**Step 2: Commit**
```bash
git add Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs
git commit -m "feat: add PopupView with grow-only element cache and positioning"
```
---
### Task 6: PopupContentBuilder struct
**Files:**
- Create: `Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs`
**Step 1: Implement fluent builder**
```csharp
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 = text.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;
}
}
}
```
**Step 2: Commit**
```bash
git add Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs
git commit -m "feat: add PopupContentBuilder fluent API"
```
---
### Task 7: IPopupSystem + PopupSystem implementation
**Files:**
- Create: `Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs`
- Create: `Packages/com.jovian.popup-system/Runtime/PopupSystem.cs`
**Step 1: Implement interface**
```csharp
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();
}
}
```
**Step 2: Implement PopupSystem**
```csharp
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 IPopupAnimator animator;
readonly Dictionary<PopupCategory, ViewState> categories = new();
public PopupSystem(PopupSettings settings, PopupView viewPrefab, IPopupAnimator animator = null) {
this.settings = settings;
this.viewPrefab = viewPrefab;
this.animator = animator ?? 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)
};
}
public void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
RectTransform anchor = null, AnchorSide? anchorSide = null) {
if(!categories.TryGetValue(category, out var state)) {
return;
}
// Dismiss lower-priority visible popups
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) {
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) {
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);
}
// Update follow-mouse positions
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.SetFollowMouseMode(settings.followMouseOffset, settings.screenEdgePadding);
state.isFollowMouse = false; // positioned at fixed screen point
}
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();
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);
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;
animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
state.view.SetVisible(false);
});
}
}
}
private sealed class ViewState {
public PopupView view;
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;
}
}
}
```
**Step 3: Commit**
```bash
git add Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs Packages/com.jovian.popup-system/Runtime/PopupSystem.cs
git commit -m "feat: add IPopupSystem and PopupSystem with priority and delay"
```
---
### Task 8: PopupTrigger MonoBehaviour
**Files:**
- Create: `Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs`
**Step 1: Implement trigger**
```csharp
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);
}
}
}
```
**Step 2: Commit**
```bash
git add Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs
git commit -m "feat: add PopupTrigger MonoBehaviour for hover detection"
```
---
### Task 9: README.md
**Files:**
- Create: `Packages/com.jovian.popup-system/README.md`
**Step 1: Write README** covering: quick start, PopupCategory, PopupSettings, content builder API, PopupTrigger setup, code-only triggers, prefab setup, IPopupAnimator extensibility.
**Step 2: Commit**
```bash
git add Packages/com.jovian.popup-system/README.md
git commit -m "feat: add README for popup system package"
```
---
### Task 10: Editor — PopupSettingsProvider
**Files:**
- Create: `Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs`
**Step 1: Implement settings provider** for Project Settings > Jovian > Popup System (same pattern as SaveSystemSettingsProvider).
**Step 2: Commit**
```bash
git add Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs
git commit -m "feat: add PopupSettingsProvider for Project Settings"
```
---
## File Summary
| File | Type | Purpose |
|------|------|---------|
| `Packages/com.jovian.popup-system/package.json` | Create | Package manifest |
| `Packages/com.jovian.popup-system/Runtime/Jovian.PopupSystem.asmdef` | Create | Runtime assembly |
| `Packages/com.jovian.popup-system/Editor/Jovian.PopupSystem.Editor.asmdef` | Create | Editor assembly |
| `Packages/com.jovian.popup-system/Samples~/README.md` | Create | Samples placeholder |
| `Packages/com.jovian.popup-system/Runtime/PopupCategory.cs` | Create | Category readonly struct |
| `Packages/com.jovian.popup-system/Runtime/PopupCategoryJsonConverter.cs` | Create | Newtonsoft converter |
| `Packages/com.jovian.popup-system/Runtime/PopupEnums.cs` | Create | AnchorSide, PopupPositionMode |
| `Packages/com.jovian.popup-system/Runtime/PopupSettings.cs` | Create | ScriptableObject config |
| `Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs` | Create | Animation interface |
| `Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs` | Create | Default fade animation |
| `Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs` | Create | Fluent builder struct |
| `Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs` | Create | System interface |
| `Packages/com.jovian.popup-system/Runtime/PopupSystem.cs` | Create | System implementation |
| `Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs` | Create | MonoBehaviour with element cache |
| `Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs` | Create | Hover trigger MonoBehaviour |
| `Packages/com.jovian.popup-system/README.md` | Create | Package documentation |
| `Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs` | Create | Project Settings UI |