Made the popup system a lot more generic

This commit is contained in:
Sebastian Bularca
2026-04-06 20:38:58 +02:00
parent fa7659d905
commit cfe202da44
21 changed files with 840 additions and 682 deletions

View File

@@ -103,10 +103,10 @@ namespace Nox.Game.UI {
private void BuildCharacterPopup(PopupContentBuilder builder, CharacterDefinition member) {
// Header
builder
.AddHeader(member.Name)
.AddText($"{member.Race} {member.Class}", "#CCCCCC")
.AddText($"Role: {member.Role}")
.AddSeparator();
.AddText(member.Name, PopupElementType.Header)
.AddText($"{member.Race} {member.Class}", "#CCCCCC", PopupElementType.Text)
.AddText($"Role: {member.Role}", PopupElementType.Text)
.AddSeparator(PopupElementType.Separator);
// Stats
if(member.Stats?.stats != null) {
@@ -115,12 +115,12 @@ namespace Nox.Game.UI {
var health = member.Stats.GetValue(StatType.Health);
var mana = member.Stats.GetValue(StatType.Mana);
builder
.AddStat("Level", level)
.AddStat("XP", xp)
.AddSeparator()
.AddStat("Health", health)
.AddStat("Mana", mana)
.AddSeparator();
.AddNameValue("Level", level, PopupElementType.LabelValueText)
.AddNameValue("XP", xp, PopupElementType.LabelValueText)
.AddSeparator(PopupElementType.Separator)
.AddNameValue("Health", health, PopupElementType.LabelValueText)
.AddNameValue("Mana", mana, PopupElementType.LabelValueText)
.AddSeparator(PopupElementType.Separator);
}
// Attributes
@@ -129,26 +129,26 @@ namespace Nox.Game.UI {
if(attr.attribute == AttributeType.None) {
continue;
}
builder.AddStat(attr.attribute.ToString(), attr.value);
builder.AddNameValue(attr.attribute.ToString(), attr.value, PopupElementType.LabelValueText);
}
}
builder.AddSeparator();
builder.AddSeparator(PopupElementType.Separator);
// Perks
if(member.Perks?.perks != null && member.Perks.perks.Count > 0) {
builder.AddText("Perks", "#FFD700");
if(member.Perks?.perks is { Count: > 0 }) {
builder.AddText("Perks", "#FFD700", PopupElementType.Text);
foreach(var perk in member.Perks.perks) {
builder.AddText($" {perk.Name}");
builder.AddText($" {perk.Name}", PopupElementType.Text);
}
builder.AddSeparator();
builder.AddSeparator(PopupElementType.Separator);
}
// Modifiers
if(member.Modifiers?.modifiers is { Count: > 0 }) {
builder.AddText("Modifiers", "#87CEEB");
builder.AddText("Modifiers", "#87CEEB", PopupElementType.Text);
foreach(var mod in member.Modifiers.modifiers) {
var target = mod.Target != null ? mod.Target.ToString() : "";
builder.AddText($" {mod.Name} ({mod.Operation} {mod.Value} {target})");
builder.AddText($" {mod.Name} ({mod.Operation} {mod.Value} {target})", PopupElementType.Text);
}
}
}

View File

@@ -21,6 +21,22 @@ MonoBehaviour:
followMouseOffset: {x: 15, y: -15}
touchHoldDuration: 0.6
gamepadFocusTrigger: 1
elementPrefabs:
- type:
id: header
prefab: {fileID: 7034836061828108288, guid: dfc1bc0bd5b4905409615c3e770a5b77, type: 3}
- type:
id: image
prefab: {fileID: 5887814251614319338, guid: 5e715f4b614d02b4fa0b4d3fcfe3c053, type: 3}
- type:
id: label_value_text
prefab: {fileID: 6246834368258800846, guid: 5882db210c62d8647858933649f64c29, type: 3}
- type:
id: separator
prefab: {fileID: 6770634903822758885, guid: 7ccdfa1a2079db044be4b1684303ec7f, type: 3}
- type:
id: text
prefab: {fileID: 3157287847714375358, guid: bfa97c92d1878cc448ddc7dc456f4b17, type: 3}
categoryPriorities:
- category:
id: Character

View File

@@ -68,11 +68,6 @@ MonoBehaviour:
content: {fileID: 176628901263125209}
canvasGroup: {fileID: 1835601435911948781}
background: {fileID: 8899521584296352500}
headerPrefab: {fileID: 6612787789151041457, guid: dfc1bc0bd5b4905409615c3e770a5b77, type: 3}
textPrefab: {fileID: 2506259255305457008, guid: bfa97c92d1878cc448ddc7dc456f4b17, type: 3}
statPrefab: {fileID: 1843470073663794312, guid: 5882db210c62d8647858933649f64c29, type: 3}
imagePrefab: {fileID: 7093821785826926595, guid: 5e715f4b614d02b4fa0b4d3fcfe3c053, type: 3}
separatorPrefab: {fileID: 4190588985333916705, guid: 7ccdfa1a2079db044be4b1684303ec7f, type: 3}
--- !u!223 &3081303906751693297
Canvas:
m_ObjectHideFlags: 0
@@ -118,7 +113,7 @@ MonoBehaviour:
m_FallbackScreenDPI: 96
m_DefaultSpriteDPI: 96
m_DynamicPixelsPerUnit: 1
m_PresetInfoIsWorld: 0
m_PresetInfoIsWorld: 1
--- !u!114 &7236468329886607693
MonoBehaviour:
m_ObjectHideFlags: 0

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace Jovian.PopupSystem.Editor {
/// <summary>
/// Custom PropertyDrawer for <see cref="PopupElementType"/>. Shows a dropdown with
/// built-in element types plus a "Custom..." option for free-text entry.
/// </summary>
[CustomPropertyDrawer(typeof(PopupElementType))]
public sealed class PopupElementTypeDrawer : PropertyDrawer {
private static readonly string[] builtInIds = {
"header",
"text",
"label_value_text",
"image",
"separator"
};
private const string customLabel = "Custom...";
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var idProp = FindIdProperty(property);
if(idProp == null) {
return EditorGUIUtility.singleLineHeight;
}
var currentValue = idProp.stringValue ?? "";
var isCustom = Array.IndexOf(builtInIds, currentValue) < 0 && !string.IsNullOrEmpty(currentValue);
return isCustom
? EditorGUIUtility.singleLineHeight * 2 + 2
: EditorGUIUtility.singleLineHeight;
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
EditorGUI.BeginProperty(position, label, property);
var idProp = FindIdProperty(property);
if(idProp == null) {
EditorGUI.LabelField(position, label.text, "Cannot resolve PopupElementType id field");
EditorGUI.EndProperty();
return;
}
var currentValue = idProp.stringValue ?? "";
var builtInIndex = Array.IndexOf(builtInIds, currentValue);
var isCustom = builtInIndex < 0 && !string.IsNullOrEmpty(currentValue);
var options = new List<string>(builtInIds);
options.Add(customLabel);
int selectedIndex;
if(builtInIndex >= 0) {
selectedIndex = builtInIndex;
}
else {
selectedIndex = options.Count - 1;
}
var dropdownRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
var newIndex = EditorGUI.Popup(dropdownRect, label.text, selectedIndex, options.ToArray());
if(newIndex != selectedIndex) {
if(newIndex < builtInIds.Length) {
idProp.stringValue = builtInIds[newIndex];
}
else {
if(!isCustom) {
idProp.stringValue = "custom_element";
}
}
}
var finalIsCustom = newIndex >= builtInIds.Length || (newIndex == selectedIndex && isCustom);
if(finalIsCustom) {
var textRect = new Rect(
position.x + EditorGUIUtility.labelWidth + 2,
position.y + EditorGUIUtility.singleLineHeight + 2,
position.width - EditorGUIUtility.labelWidth - 2,
EditorGUIUtility.singleLineHeight);
var newValue = EditorGUI.TextField(textRect, idProp.stringValue);
if(newValue != idProp.stringValue) {
idProp.stringValue = newValue;
}
}
EditorGUI.EndProperty();
}
private static SerializedProperty FindIdProperty(SerializedProperty property) {
var prop = property.FindPropertyRelative("id");
if(prop != null) {
return prop;
}
return property.FindPropertyRelative("<id>k__BackingField");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ea57be8755c60e4bbf156ac2b32c98c

View File

@@ -51,6 +51,10 @@ namespace Jovian.PopupSystem.Editor {
EditorGUILayout.PropertyField(serializedSettings.FindProperty("touchHoldDuration"));
EditorGUILayout.PropertyField(serializedSettings.FindProperty("gamepadFocusTrigger"));
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Element Prefabs", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedSettings.FindProperty("elementPrefabs"), true);
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Priority", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedSettings.FindProperty("categoryPriorities"), true);

View File

@@ -1,6 +1,6 @@
# Jovian Popup System
A lightweight, low-allocation popup and tooltip system for Unity with category-based isolation, a fluent content builder, and extensible animations.
A lightweight, low-allocation popup and tooltip system for Unity with category-based isolation, a generic element cache, type-safe element identifiers, and extensible animations.
## Requirements
@@ -15,11 +15,11 @@ Install via the Unity Package Manager by adding the package from its local path
### 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**.
In the Unity Editor, go to **Assets > Create > Jovian > Popup System > Popup Settings**. Configure element prefabs by adding entries to the Element Prefabs list, mapping `PopupElementType` values to your prefabs.
### 2. Build a PopupReference prefab
See the [Prefab Setup](#prefab-setup) section below for step-by-step instructions.
See the [Prefab Setup](#prefab-setup) section below.
### 3. Create and use PopupSystem
@@ -27,38 +27,78 @@ See the [Prefab Setup](#prefab-setup) section below for step-by-step instruction
using Jovian.PopupSystem;
using Jovian.PopupSystem.UI;
// Create the system. Pass the scene Canvas as canvasRoot to auto-scan all PopupTrigger components.
// Create the system. canvasRoot auto-scans all PopupTrigger components.
var popup = new PopupSystem(settings, viewPrefab, canvasRoot);
// Register categories you intend to use.
popup.RegisterCategory(PopupCategory.Item);
popup.RegisterCategory(PopupCategory.Character);
// Option A: Set content on auto-scanned triggers by name.
var trigger = popup.GetTrigger("ItemSlot");
trigger?.SetContent(builder => {
// Set content on auto-scanned triggers by name.
var handler = popup.GetTriggerHandler("ItemSlot");
handler?.SetContent(builder => {
builder
.AddHeader("Health Potion")
.AddSeparator()
.AddText("Restores a moderate amount of health.")
.AddStat("Heal Amount", 50);
.AddText("Health Potion", PopupElementType.Header)
.AddSeparator(PopupElementType.Separator)
.AddText("Restores health.", PopupElementType.Text)
.AddNameValue("Heal Amount", 50, PopupElementType.LabelValueText);
});
// Option B: Show a popup directly from code.
// Or show a popup directly from code.
popup.Show(PopupCategory.Item, builder => {
builder.AddHeader("Iron Sword").AddStat("Damage", 12);
builder
.AddText("Iron Sword", PopupElementType.Header)
.AddNameValue("Damage", 12, PopupElementType.LabelValueText);
}, anchorRect, AnchorSide.Right);
// Tick each frame (typically in Update or a game-state loop).
// Tick each frame.
popup.Tick(Time.deltaTime);
// Clean up when the game state is torn down.
// Clean up when the game state exits.
popup.Dispose();
```
## PopupElementType
`PopupElementType` is a type-safe struct identifying element prefabs in the registry. The Inspector shows a dropdown with built-in types plus a custom text field for game-specific elements.
### Built-in types
| Static Field | ID | Usage |
|---|---|---|
| `PopupElementType.Header` | `"header"` | Bold header text |
| `PopupElementType.Text` | `"text"` | Body text |
| `PopupElementType.LabelValueText` | `"label_value_text"` | Label + value row (two TMP_Text children) |
| `PopupElementType.Image` | `"image"` | Image/icon element |
| `PopupElementType.Separator` | `"separator"` | Horizontal divider line |
### Custom types
Define game-specific element types as static fields:
```csharp
public static class MyPopupElements {
public static readonly PopupElementType Badge = new("badge");
public static readonly PopupElementType ProgressBar = new("progress_bar");
}
```
### Variants
Create variants of built-in types using `Variant()`:
```csharp
// "header_gold" — a gold-styled header
PopupElementType.Header.Variant("gold")
// "separator_thick" — a thicker separator
PopupElementType.Separator.Variant("thick")
```
Register variant prefabs in PopupSettings with the variant ID (e.g. "header_gold").
## 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.
`PopupCategory` is a readonly struct that acts as a channel for isolating popups. Each category gets its own view instance.
### Built-in categories
@@ -71,47 +111,55 @@ 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**.
ScriptableObject holding all configuration. Create 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. |
| `popupDelay` | float | 0.4 | Seconds before popup appears. |
| `fadeDuration` | float | 0.2 | Fade animation duration. |
| `defaultAnchorSide` | AnchorSide | Below | Default anchor side. |
| `screenEdgePadding` | float | 10 | Minimum pixels from screen edge. |
| `maxPopupWidth` | float | 400 | Maximum popup width in pixels. |
| `sortingOrder` | int | 100 | Canvas sorting order. |
| `followMouseOffset` | Vector2 | (15, -15) | Cursor offset in follow mode. |
| `touchHoldDuration` | float | 0.6 | Touch hold duration. |
| `gamepadFocusTrigger` | bool | true | Trigger on gamepad focus. |
| `elementPrefabs` | List | empty | Element prefab registry (PopupElementType -> prefab). |
| `categoryPriorities` | List | empty | Per-category priority overrides. |
| `categoryDelayOverrides` | List | empty | Per-category delay overrides. |
## 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`.
Fluent API struct for composing popup content. All methods take a `PopupElementType` to identify which prefab to use.
### Available methods
### 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
// Generic — returns raw GameObject for custom elements
builder.Add(PopupElementType.Header);
builder.Add(new PopupElementType("custom_widget"));
// Text — sets TMP_Text on the element
builder.AddText("Fireball", PopupElementType.Header);
builder.AddText("Body text here.", PopupElementType.Text);
builder.AddText("Colored text", "#FFD700", PopupElementType.Text);
// Name/Value — sets two TMP_Text children (label + value)
builder.AddNameValue("Damage", 120, PopupElementType.LabelValueText);
builder.AddNameValue("Range", "15m", PopupElementType.LabelValueText);
// Image — sets sprite and height
builder.AddImage(iconSprite, PopupElementType.Image, 64f);
// Separator
builder.AddSeparator(PopupElementType.Separator);
```
### Full example
@@ -119,331 +167,143 @@ builder.AddSeparator(); // Horizontal divider lin
```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");
.AddText("Fireball", PopupElementType.Header)
.AddText("Hurls a ball of fire.", PopupElementType.Text)
.AddSeparator(PopupElementType.Separator)
.AddNameValue("Damage", 120, PopupElementType.LabelValueText)
.AddNameValue("Mana Cost", 35, PopupElementType.LabelValueText)
.AddSeparator(PopupElementType.Separator)
.AddText("Requires: Level 5", "FF6666", PopupElementType.Text);
}, 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`.
MonoBehaviour attached to UI elements for hover-based popups. Forwards pointer events to a `PopupTriggerView` behavior handler.
### Inspector fields
| Field | Type | Description |
|---|---|---|
| `category` | PopupCategory | Which category channel to use. |
| `anchorSide` | AnchorSide | Which side of this element to anchor the popup. |
| `anchorSide` | AnchorSide | Which side to anchor the popup. |
| `positionMode` | PopupPositionMode | `AnchorToElement` or `FollowMouse`. |
### Approach 1: Auto-scanned triggers (recommended for static UI)
When you pass a `canvasRoot` to the `PopupSystem` constructor, it auto-scans all `PopupTrigger` components and binds them. You only need to set content:
```csharp
var popup = new PopupSystem(settings, viewPrefab, canvasRoot);
popup.RegisterCategory(PopupCategory.Character);
// Find a trigger by its GameObject name and set content
var trigger = popup.GetTrigger("HeroPortrait");
trigger?.SetContent(builder => {
builder.AddHeader("Kael").AddStat("Health", 55);
// GetTriggerHandler returns PopupTriggerView (the behavior handler)
var handler = popup.GetTriggerHandler("HeroPortrait");
handler?.SetContent(builder => {
builder
.AddText("Kael", PopupElementType.Header)
.AddNameValue("Health", 55, PopupElementType.LabelValueText);
});
// Or set content on all triggers of a category
foreach(var t in popup.GetTriggers(PopupCategory.Item)) {
t.SetContent(builder => builder.AddHeader("Item"));
// Or all handlers for a category
foreach(var h in popup.GetTriggerHandlers(PopupCategory.Item)) {
h.SetContent(builder => builder.AddText("Item", PopupElementType.Header));
}
```
### Approach 2: Dynamic triggers (for runtime-created UI)
For UI elements instantiated at runtime (inventory slots, party portraits), use `InitializeTriggersInChildren` after instantiation:
```csharp
// After instantiating slot prefabs under a container:
popup.InitializeTriggersInChildren(slotsContainer, trigger => {
popup.InitializeTriggersInChildren(slotsContainer, (trigger, view) => {
var slotData = trigger.GetComponentInParent<SlotComponent>();
trigger.SetContent(builder => {
builder.AddHeader(slotData.itemName);
view.SetContent(builder => {
builder.AddText(slotData.itemName, PopupElementType.Header);
});
});
```
This scans, binds, and registers all triggers under the parent, then calls the callback on each.
### Approach 3: Full manual initialization
For complete control, bypass auto-scan and initialize triggers directly:
### Approach 3: Code-only (no PopupTrigger needed)
```csharp
var trigger = button.GetComponent<PopupTrigger>();
trigger.Initialize(popupSystem, builder => {
builder.AddHeader("Attack").AddStat("Damage", 25);
});
// Anchored
popupSystem.Show(PopupCategory.Item, builder => { ... }, targetRect, AnchorSide.Right);
// Or with a category override:
trigger.Initialize(popupSystem, PopupCategory.Skill, builder => {
builder.AddHeader("Fireball");
});
```
// Follow mouse
popupSystem.Show(PopupCategory.General, builder => { ... });
### Updating content
// Fixed screen position
popupSystem.ShowAtPosition(PopupCategory.General, builder => { ... }, screenPos);
To change content on an already-initialized trigger without re-binding:
```csharp
trigger.SetContent(builder => {
builder.AddHeader("Attack").AddStat("Damage", newDamageValue);
});
// UpdateContent is an alias for SetContent
trigger.UpdateContent(builder => { ... });
```
## 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
// Hide
popupSystem.Hide(PopupCategory.Item);
popupSystem.HideAll();
```
## 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:
Higher priority categories dismiss lower ones when shown:
```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`.
Settings-based priorities take precedence over registration arguments.
## 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:
Created per game state, not as a singleton:
```csharp
// In your game state initialization:
var guiCanvas = guiReferences.GetComponentInParent<Canvas>().transform;
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab, guiCanvas);
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
// All PopupTrigger components under guiCanvas are auto-scanned and bound.
// Pass popupSystem to views/play modes via constructor DI.
// In your game state's Tick/Update:
// Tick each frame
popupSystem.Tick(Time.deltaTime);
// When the game state exits:
popupSystem.Dispose(); // destroys all popup view GameObjects
// Dispose on state exit
popupSystem.Dispose();
```
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
Extensible show/hide animation interface. Default: `FadePopupAnimator` (alpha lerp). Pass a factory to the constructor:
```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());
var popup = new PopupSystem(settings, viewPrefab, canvasRoot, () => new ScalePopupAnimator());
```
## Prefab Setup
Build the `PopupReference` prefab with the following hierarchy. Reference prefabs are included in the `Samples~/Prefabs` folder.
```
PopupReferencePrefab Canvas, CanvasGroup, CanvasScaler, PopupReference
Background Image (popup background), ContentSizeFitter (V=Preferred)
Content VerticalLayoutGroup (Control Child Size Width, Child Force Expand Width)
Background Image, ContentSizeFitter (V=Preferred)
Content VerticalLayoutGroup (Control Child Width, Force Expand Width)
```
### Step 1: Root GameObject
1. Create a new GameObject named `PopupReferencePrefab`
2. Add a `Canvas` component. Set **Render Mode** to **Screen Space - Overlay**
3. Add a `CanvasScaler` with **Scale With Screen Size**, reference resolution matching your project (e.g. 1920x1080). This is needed for prefab editing; at runtime the nested Canvas inherits the parent's scaler
4. Add a `CanvasGroup` component
5. Add a `PopupReference` component (from `Jovian.PopupSystem.UI`)
6. Set anchors to a single point (e.g. Min 0,1 Max 0,1), Pivot (0,1) for top-left origin
7. Do **not** add a `ContentSizeFitter` to the root
### Step 2: Background
1. Create a child GameObject named `Background`
2. Add an `Image` component. Set the image sprite/color to your desired popup background
3. Add a `ContentSizeFitter` with **Horizontal Fit = Unconstrained** and **Vertical Fit = Preferred Size**
4. Stretch the RectTransform to fill the root (anchors 0,0 to 1,1, offsets 0) or use point anchors with the ContentSizeFitter driving the size
### Step 3: Content container
1. Create a child of `Background` named `Content`
2. Stretch the RectTransform to fill the Background (anchors 0,0 to 1,1, offsets for padding)
3. Add a `VerticalLayoutGroup`:
- **Control Child Size**: Width checked, Height unchecked
- **Child Force Expand**: Width checked, Height unchecked
- **Spacing**: 2 (or to taste)
- **Padding**: as needed
4. Do **not** add a `ContentSizeFitter` to Content. The VerticalLayoutGroup reports its preferred size to the parent Background's ContentSizeFitter
### Step 4: Element prefabs
Create these as separate prefabs. Do not add `ContentSizeFitter` to any element prefab:
| Prefab | Component | Notes |
|---|---|---|
| PopupHeader | `TMP_Text` | Bold, larger font size. Add `LayoutElement` if you need minimum height |
| PopupText | `TMP_Text` | Regular body text, rich text enabled |
| PopupStat | `RectTransform` with `HorizontalLayoutGroup` | Must have exactly two `TMP_Text` children: label (left) and value (right) |
| PopupIcon | `Image` | For icons or artwork. Add `LayoutElement` with preferred height |
| PopupSeparator | `Image` | Thin horizontal line. Add `LayoutElement` with preferred height = 1 or 2 |
### 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 PopupHeader prefab
- **Text Prefab**: your PopupText prefab
- **Stat Prefab**: your PopupStat prefab
- **Image Prefab**: your PopupIcon prefab
- **Separator Prefab**: your PopupSeparator prefab
Save as a prefab. The `maxPopupWidth` from `PopupSettings` is applied at runtime to constrain the popup's horizontal size. At runtime, when parented under a scene Canvas, the popup's own Canvas becomes a nested override Canvas inheriting the parent's scaling.
1. **Root**: Canvas (Screen Space Overlay), CanvasScaler (Scale With Screen Size), CanvasGroup, PopupReference. Point anchors, no ContentSizeFitter.
2. **Background**: Image, ContentSizeFitter (Vertical = Preferred).
3. **Content**: Stretched to Background, VerticalLayoutGroup (Control Child Size Width, Child Force Expand Width), no ContentSizeFitter.
4. **Element prefabs**: Create separate prefabs, register in PopupSettings under Element Prefabs with their `PopupElementType`. No ContentSizeFitter on element prefabs.
5. **Wire PopupReference**: Assign Content, CanvasGroup, and Background fields.
## 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
- `PopupCategory` and `PopupElementType` are value-type structs — zero heap allocation
- `PopupContentBuilder` is a readonly struct operating directly on cached elements
- Content elements use a **grow-only cache** keyed by `PopupElementType` — activate/deactivate, never destroy after warmup
- Delay timers and animation driven by float fields in `Tick()` — no coroutines
- Screen rect uses static `Vector3[4]` buffer — no per-frame allocation
- Layout rebuilds only on content change (dirty flag)
- Each category gets its own `IPopupAnimator` — no concurrent animation corruption
- MonoBehaviour (`PopupReference`, `PopupTrigger`) holds references only; all behavior in plain C# classes (`PopupView`, `PopupTriggerView`)
## Samples
The `Samples~` folder contains ready-to-use reference material:
The `Samples~` folder contains:
- **Prefabs**: PopupReferencePrefab and all element prefabs (header, text, stat, icon, separator) with correct component setup
- **Settings**: Pre-configured PopupSettings asset with sensible defaults
- **Scripts**: Three example scripts demonstrating different integration patterns:
- `PopupSystemExample` -- auto-scanned triggers with `GetTrigger` and `GetTriggers`
- `DynamicTriggersExample` -- runtime-instantiated UI with `InitializeTriggersInChildren`
- `CodeOnlyPopupExample` -- showing popups from code (anchored, fixed position, follow mouse)
- **Prefabs**: PopupReferencePrefab and all element prefabs
- **Settings**: Pre-configured PopupSettings asset
- **Scripts**: Three example scripts (auto-scanned triggers, dynamic triggers, code-only with variants)
Import via Unity Package Manager: select the package, expand Samples, click Import.
@@ -454,17 +314,20 @@ Import via Unity Package Manager: select the package, expand Samples, click Impo
| Type | Namespace | Description |
|---|---|---|
| `IPopupSystem` | `Jovian.PopupSystem` | Main interface for showing/hiding popups. |
| `PopupSystem` | `Jovian.PopupSystem` | Concrete implementation. Constructor: `(PopupSettings, PopupReference, Transform canvasParent, Func<IPopupAnimator>)`. Auto-scans triggers under canvasParent. Methods: `ScanTriggers`, `GetTrigger`, `GetTriggers`, `InitializeTriggersInChildren`. |
| `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. |
| `PopupSystem` | `Jovian.PopupSystem` | Implementation. Constructor: `(PopupSettings, PopupReference, Transform, Func<IPopupAnimator>)`. |
| `PopupView` | `Jovian.PopupSystem` | Behavior class: generic element cache, positioning, visibility. |
| `PopupSettings` | `Jovian.PopupSystem` | ScriptableObject configuration + element prefab registry. |
| `PopupCategory` | `Jovian.PopupSystem` | Struct identifying a popup channel. |
| `PopupElementType` | `Jovian.PopupSystem` | Struct identifying an element prefab type. Built-ins + Variant(). |
| `PopupContentBuilder` | `Jovian.PopupSystem` | Fluent readonly struct for building content. |
### 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. |
| `PopupReference` | `Jovian.PopupSystem.UI` | MonoBehaviour reference holder (content, canvasGroup, background). |
| `PopupTrigger` | `Jovian.PopupSystem.UI` | MonoBehaviour reference holder for hover triggers. |
| `PopupTriggerView` | `Jovian.PopupSystem` | Behavior handler for triggers (SetContent, pointer forwarding). |
### Animation Types
@@ -479,7 +342,3 @@ Import via Unity Package Manager: select the package, expand Samples, click Impo
|---|---|
| `AnchorSide` | `Below`, `Above`, `Left`, `Right` |
| `PopupPositionMode` | `AnchorToElement`, `FollowMouse` |
## License
See the LICENSE file in the package root.

View File

@@ -1,86 +1,114 @@
using Jovian.PopupSystem.UI;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Jovian.PopupSystem {
/// <summary>
/// Fluent API struct for building popup content. Operates directly on cached elements
/// inside a <see cref="PopupReference"/> — no intermediate allocations. Received in
/// the build callback passed to <see cref="IPopupSystem.Show"/>.
/// Fluent API struct for building popup content. Operates on the generic element cache
/// inside a <see cref="PopupView"/>. Received in the build callback passed to
/// <see cref="IPopupSystem.Show"/>. Use <see cref="Add"/> for fully custom elements,
/// or convenience methods for common types.
/// </summary>
public struct PopupContentBuilder {
readonly PopupReference view;
public readonly struct PopupContentBuilder {
readonly PopupView view;
/// <summary>
/// Creates a builder targeting the given popup reference.
/// Creates a builder targeting the given popup view.
/// </summary>
public PopupContentBuilder(PopupReference view) {
public PopupContentBuilder(PopupView view) {
this.view = view;
}
/// <summary>
/// Adds a header text element (bold, larger font).
/// Returns a cached or newly instantiated element by its registered type.
/// Use this for fully custom or game-specific element types.
/// </summary>
public PopupContentBuilder AddHeader(string text) {
var header = view.GetHeader();
header.text = text;
public GameObject Add(PopupElementType elementType) {
return view.GetElement(elementType);
}
/// <summary>
/// Adds a text element using the given element type and sets its TMP_Text content.
/// </summary>
public PopupContentBuilder AddText(string text, PopupElementType elementType) {
var go = view.GetElement(elementType);
if(go == null) {
return this;
}
var tmp = go.GetComponentInChildren<TMP_Text>();
if(tmp) {
tmp.text = text;
}
return this;
}
/// <summary>
/// Adds a body text element.
/// Adds a colored text element using the given element type.
/// Hex color can be with or without the # prefix.
/// </summary>
public PopupContentBuilder AddText(string text) {
var label = view.GetText();
label.text = text;
return this;
}
/// <summary>
/// Adds a colored body text element. Hex color can be with or without the # prefix.
/// </summary>
public PopupContentBuilder AddText(string text, string hexColor) {
var label = view.GetText();
public PopupContentBuilder AddText(string text, string hexColor, PopupElementType elementType) {
var go = view.GetElement(elementType);
if(!go) {
return this;
}
var tmp = go.GetComponentInChildren<TMP_Text>();
if(!tmp) {
return this;
}
var prefix = hexColor.Length > 0 && hexColor[0] == '#' ? "" : "#";
label.text = $"<color={prefix}{hexColor}>{text}</color>";
tmp.text = $"<color={prefix}{hexColor}>{text}</color>";
return this;
}
/// <summary>
/// Adds a stat row with a label and integer value.
/// Adds a name/value row using the given element type. The prefab must have
/// exactly two TMP_Text children (label first, value second).
/// </summary>
public PopupContentBuilder AddStat(string label, int value) {
var (labelText, valueText) = view.GetStat();
labelText.text = label;
valueText.text = value.ToString();
return this;
public PopupContentBuilder AddNameValue(string label, int value, PopupElementType elementType) {
return AddNameValueInternal(elementType, label, value.ToString());
}
/// <summary>
/// Adds a stat row with a label and string value.
/// Adds a name/value row using the given element type. The prefab must have
/// exactly two TMP_Text children (label first, value second).
/// </summary>
public PopupContentBuilder AddStat(string label, string value) {
var (labelText, valueText) = view.GetStat();
labelText.text = label;
valueText.text = value;
public PopupContentBuilder AddNameValue(string label, string value, PopupElementType elementType) {
return AddNameValueInternal(elementType, label, value);
}
private PopupContentBuilder AddNameValueInternal(PopupElementType elementType, string label, string value) {
var go = view.GetElement(elementType);
if(go != null) {
var children = go.GetComponentsInChildren<TMP_Text>(true);
if(children.Length >= 2) {
children[0].text = label;
children[1].text = value;
}
}
return this;
}
/// <summary>
/// Adds an image element with the given sprite and optional height.
/// </summary>
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);
public PopupContentBuilder AddImage(Sprite sprite, PopupElementType elementType, float height = 64f) {
var go = view.GetElement(elementType);
if(go != null) {
var image = go.GetComponentInChildren<Image>();
if(image != null) {
image.sprite = sprite;
var rt = (RectTransform)image.transform;
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
}
}
return this;
}
/// <summary>
/// Adds a horizontal separator line.
/// Adds a separator element using the given element type.
/// </summary>
public PopupContentBuilder AddSeparator() {
view.GetSeparator();
public PopupContentBuilder AddSeparator(PopupElementType elementType) {
view.GetElement(elementType);
return this;
}
}

View File

@@ -0,0 +1,74 @@
using System;
using UnityEngine;
namespace Jovian.PopupSystem {
/// <summary>
/// Value type identifying a popup element prefab. Compared by string ID using ordinal
/// comparison. Built-in types cover common popup elements. Define custom types as static
/// fields or create instances for game-specific elements and variants.
/// </summary>
[Serializable]
public struct PopupElementType : IEquatable<PopupElementType> {
[SerializeField] private string id;
/// <summary>The string identifier for this element type.</summary>
public string Id => id;
public PopupElementType(string id) {
this.id = id;
}
// --- Built-in types ---
/// <summary>Bold header text element.</summary>
public static readonly PopupElementType Header = new("header");
/// <summary>Body text element.</summary>
public static readonly PopupElementType Text = new("text");
/// <summary>Label + value stat row element.</summary>
public static readonly PopupElementType LabelValueText = new("label_value_text");
/// <summary>Image/icon element.</summary>
public static readonly PopupElementType Image = new("image");
/// <summary>Horizontal separator line.</summary>
public static readonly PopupElementType Separator = new("separator");
// --- Variant helper ---
/// <summary>
/// Creates a variant of this element type by appending a suffix.
/// e.g. <c>PopupElementType.Header.Variant("gold")</c> produces <c>"header_gold"</c>.
/// </summary>
public PopupElementType Variant(string variant) {
return new PopupElementType($"{id}_{variant}");
}
// --- Equality ---
public bool Equals(PopupElementType other) {
return string.Equals(id, other.id, StringComparison.Ordinal);
}
public override bool Equals(object obj) {
return obj is PopupElementType 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 ==(PopupElementType left, PopupElementType right) {
return left.Equals(right);
}
public static bool operator !=(PopupElementType left, PopupElementType right) {
return !left.Equals(right);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ba7e78e3d930334a935a600a8f67ded

View File

@@ -24,12 +24,33 @@ namespace Jovian.PopupSystem {
public float touchHoldDuration = 0.6f;
public bool gamepadFocusTrigger = true;
[Header("Element Prefabs")]
public List<PopupElementEntry> elementPrefabs = new();
[Header("Priority")]
public List<CategoryPriority> categoryPriorities = new();
[Header("Per-Category Overrides")]
public List<CategoryDelay> categoryDelayOverrides = new();
private Dictionary<PopupElementType, GameObject> prefabLookup;
/// <summary>
/// Returns the element prefab registered under the given type, or null if not found.
/// </summary>
public GameObject GetPrefab(PopupElementType elementType) {
if(prefabLookup == null) {
prefabLookup = new Dictionary<PopupElementType, GameObject>();
foreach(var entry in elementPrefabs) {
if(!string.IsNullOrEmpty(entry.type.Id) && entry.prefab != null) {
prefabLookup[entry.type] = entry.prefab;
}
}
}
prefabLookup.TryGetValue(elementType, out var result);
return result;
}
/// <summary>
/// Returns the configured priority for a category, or 0 if not configured.
/// </summary>
@@ -55,6 +76,12 @@ namespace Jovian.PopupSystem {
}
}
[Serializable]
public sealed class PopupElementEntry {
public PopupElementType type;
public GameObject prefab;
}
[Serializable]
public sealed class CategoryPriority {
public PopupCategory category;

View File

@@ -189,8 +189,8 @@ namespace Jovian.PopupSystem {
/// <inheritdoc />
public void Dispose() {
foreach(var kvp in categories) {
if(kvp.Value.view != null) {
Object.Destroy(kvp.Value.view.gameObject);
if(kvp.Value.view?.Reference != null) {
Object.Destroy(kvp.Value.view.Reference.gameObject);
}
}
categories.Clear();
@@ -210,7 +210,7 @@ namespace Jovian.PopupSystem {
// Force full layout rebuild so positioning has correct size on first show
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(state.view.Content);
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)state.view.transform);
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)state.view.Transform);
if(state.pendingScreenPos.HasValue) {
state.view.SetFixedPosition(state.pendingScreenPos.Value, settings.screenEdgePadding);
@@ -234,21 +234,21 @@ namespace Jovian.PopupSystem {
return;
}
PopupReference popupRef;
if(canvasParent != null) {
// Parent under existing scene Canvas — nested Canvas inherits CanvasScaler
state.view = Object.Instantiate(viewPrefab, canvasParent);
popupRef = Object.Instantiate(viewPrefab, canvasParent);
}
else {
state.view = Object.Instantiate(viewPrefab);
popupRef = Object.Instantiate(viewPrefab);
}
// Configure Canvas as override sorting so it renders on top
var canvas = state.view.GetComponent<Canvas>();
var canvas = popupRef.GetComponent<Canvas>();
if(canvas != null) {
canvas.overrideSorting = true;
canvas.sortingOrder = settings.sortingOrder;
}
state.view = new PopupView(popupRef, settings);
state.view.SetVisible(false);
state.view.SetMaxWidth(settings.maxPopupWidth);
}
@@ -266,7 +266,7 @@ namespace Jovian.PopupSystem {
}
private sealed class ViewState {
public PopupReference view;
public PopupView view;
public IPopupAnimator animator;
public int priority;
public float delay;

View File

@@ -0,0 +1,224 @@
using System.Collections.Generic;
using Jovian.PopupSystem.UI;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace Jovian.PopupSystem {
/// <summary>
/// Behavior class for a popup view. Manages the generic grow-only element cache,
/// positioning, visibility, and layout. Operates on a <see cref="PopupReference"/>
/// MonoBehaviour for scene references.
/// </summary>
public sealed class PopupView {
readonly PopupReference reference;
readonly PopupSettings settings;
// Generic element cache: keyed by element type
readonly Dictionary<PopupElementType, List<GameObject>> elementCache = new();
readonly Dictionary<PopupElementType, int> elementIndex = new();
// Positioning state
PopupPositionMode positionMode;
RectTransform anchorTarget;
AnchorSide anchorSide;
Vector2 followOffset;
float screenEdgePadding;
float maxWidth;
bool isVisible;
bool layoutDirty;
Canvas rootCanvas;
Camera canvasCamera;
/// <summary>The underlying MonoBehaviour reference holder.</summary>
public PopupReference Reference => reference;
/// <summary>The CanvasGroup for animation control.</summary>
public CanvasGroup CanvasGroup => reference.CanvasGroup;
/// <summary>The content RectTransform where elements are parented.</summary>
public RectTransform Content => reference.Content;
/// <summary>The root Transform of the popup GameObject.</summary>
public Transform Transform => reference.transform;
/// <summary>Whether the popup is currently visible.</summary>
public bool IsVisible => isVisible;
/// <summary>
/// Creates a new popup view wrapping the given reference and settings.
/// </summary>
public PopupView(PopupReference reference, PopupSettings settings) {
this.reference = reference;
this.settings = settings;
}
// --- Element cache (generic, grow-only) ---
/// <summary>
/// Returns the next available cached element for the given type, or instantiates
/// a new one from the settings prefab registry. The element is activated and placed
/// at the end of the content layout.
/// </summary>
public GameObject GetElement(PopupElementType elementType) {
if(!elementCache.TryGetValue(elementType, out var cache)) {
cache = new List<GameObject>();
elementCache[elementType] = cache;
elementIndex[elementType] = 0;
}
var index = elementIndex[elementType];
if(index < cache.Count) {
var existing = cache[index];
existing.SetActive(true);
existing.transform.SetAsLastSibling();
elementIndex[elementType] = index + 1;
return existing;
}
var prefab = settings.GetPrefab(elementType);
if(prefab == null) {
Debug.LogWarning($"[PopupView] No prefab registered for element '{elementType}'");
return null;
}
var created = Object.Instantiate(prefab, reference.Content);
created.SetActive(true);
created.transform.SetAsLastSibling();
cache.Add(created);
elementIndex[elementType] = index + 1;
return created;
}
/// <summary>
/// Deactivates all cached content elements and marks layout as dirty.
/// </summary>
public void ClearContent() {
foreach(var kvp in elementCache) {
var cache = kvp.Value;
var activeCount = elementIndex[kvp.Key];
for(int i = 0; i < activeCount; i++) {
cache[i].SetActive(false);
}
elementIndex[kvp.Key] = 0;
}
layoutDirty = true;
}
// --- Visibility ---
/// <summary>Shows or hides the popup GameObject. Resets alpha to 0 when hiding.</summary>
public void SetVisible(bool visible) {
isVisible = visible;
reference.gameObject.SetActive(visible);
if(!visible) {
reference.CanvasGroup.alpha = 0f;
}
}
/// <summary>Constrains the popup's horizontal size to the given maximum width in pixels.</summary>
public void SetMaxWidth(float maxPopupWidth) {
maxWidth = maxPopupWidth;
if(maxWidth > 0f) {
var rt = (RectTransform)reference.transform;
var size = rt.sizeDelta;
size.x = Mathf.Min(size.x, maxWidth);
rt.sizeDelta = size;
}
}
// --- Positioning ---
/// <summary>Configures the popup to anchor to a target element on the specified side.</summary>
public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
positionMode = PopupPositionMode.AnchorToElement;
anchorTarget = target;
anchorSide = side;
screenEdgePadding = edgePadding;
}
/// <summary>Configures the popup to follow the mouse cursor with the given offset.</summary>
public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
positionMode = PopupPositionMode.FollowMouse;
followOffset = offset;
screenEdgePadding = edgePadding;
}
/// <summary>Positions the popup at a fixed screen coordinate with edge clamping.</summary>
public void SetFixedPosition(Vector2 screenPos, float edgePadding) {
positionMode = PopupPositionMode.AnchorToElement;
anchorTarget = null;
screenEdgePadding = edgePadding;
PositionAtScreenPoint(screenPos);
}
/// <summary>Updates the popup position based on the current mode (follow mouse or anchored).</summary>
public void UpdatePosition() {
if(positionMode == PopupPositionMode.FollowMouse) {
PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
}
else if(anchorTarget != null) {
PositionAnchoredTo(anchorTarget, anchorSide);
}
}
private void CacheCanvas() {
if(rootCanvas != null) {
return;
}
rootCanvas = reference.GetComponentInParent<Canvas>()?.rootCanvas;
canvasCamera = rootCanvas != null && rootCanvas.renderMode != RenderMode.ScreenSpaceOverlay
? rootCanvas.worldCamera : null;
}
private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
CacheCanvas();
var targetRect = GetScreenRect(target, canvasCamera);
var popupRect = GetScreenRect((RectTransform)reference.transform, canvasCamera);
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) {
CacheCanvas();
var rt = (RectTransform)reference.transform;
if(layoutDirty) {
LayoutRebuilder.ForceRebuildLayoutImmediate(reference.Content);
layoutDirty = false;
}
var popupSize = GetScreenRect(rt, canvasCamera).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);
// Convert screen position to parent local space
var parentRt = rt.parent as RectTransform;
if(parentRt != null) {
RectTransformUtility.ScreenPointToLocalPointInRectangle(parentRt, screenPos, canvasCamera, out var localPos);
rt.localPosition = localPos;
}
else {
rt.position = screenPos;
}
}
private static readonly Vector3[] cornersBuffer = new Vector3[4];
private static Rect GetScreenRect(RectTransform rt, Camera camera) {
rt.GetWorldCorners(cornersBuffer);
var min = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[0]);
var max = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[2]);
return new Rect(min, max - min);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18ebd1b0205e20440aa4c4991b43cc46

View File

@@ -1,253 +1,23 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace Jovian.PopupSystem.UI {
/// <summary>
/// MonoBehaviour reference holder for a popup view. Manages the grow-only element cache,
/// screen positioning, and visibility. One instance per registered <see cref="PopupCategory"/>.
/// Reference-only MonoBehaviour for a popup prefab. Holds serialized scene references
/// to the content container, canvas group, and background. All behavior is in
/// <see cref="PopupView"/>.
/// </summary>
public class PopupReference : 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;
Canvas rootCanvas;
Camera canvasCamera;
public CanvasGroup CanvasGroup => canvasGroup;
/// <summary>The content RectTransform where popup elements are parented.</summary>
public RectTransform Content => content;
public bool IsVisible => isVisible;
/// <summary>Constrains the popup's horizontal size to the given maximum width in pixels.</summary>
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;
}
}
/// <summary>The CanvasGroup for fade animation control.</summary>
public CanvasGroup CanvasGroup => canvasGroup;
/// <summary>Shows or hides the popup GameObject. Resets alpha to 0 when hiding.</summary>
public void SetVisible(bool visible) {
isVisible = visible;
gameObject.SetActive(visible);
if(!visible) {
canvasGroup.alpha = 0f;
}
}
/// <summary>Deactivates all cached content elements and marks layout as dirty.</summary>
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) ---
/// <summary>Returns the next available header element from the cache, or creates one.</summary>
public TMP_Text GetHeader() {
return GetOrCreate(headerCache, headerPrefab, ref headerIndex);
}
/// <summary>Returns the next available text element from the cache, or creates one.</summary>
public TMP_Text GetText() {
return GetOrCreate(textCache, textPrefab, ref textIndex);
}
/// <summary>Returns the next available stat row (label + value pair) from the cache, or creates one.</summary>
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);
}
/// <summary>Returns the next available image element from the cache, or creates one.</summary>
public Image GetImage() {
return GetOrCreate(imageCache, imagePrefab, ref imageIndex);
}
/// <summary>Returns the next available separator element from the cache, or creates one.</summary>
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 ---
/// <summary>Configures the popup to anchor to a target element on the specified side.</summary>
public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
positionMode = PopupPositionMode.AnchorToElement;
anchorTarget = target;
anchorSide = side;
screenEdgePadding = edgePadding;
}
/// <summary>Configures the popup to follow the mouse cursor with the given offset.</summary>
public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
positionMode = PopupPositionMode.FollowMouse;
followOffset = offset;
screenEdgePadding = edgePadding;
}
/// <summary>Positions the popup at a fixed screen coordinate with edge clamping.</summary>
public void SetFixedPosition(Vector2 screenPos, float edgePadding) {
positionMode = PopupPositionMode.AnchorToElement;
anchorTarget = null;
screenEdgePadding = edgePadding;
PositionAtScreenPoint(screenPos);
}
/// <summary>Updates the popup position based on the current mode (follow mouse or anchored).</summary>
public void UpdatePosition() {
if(positionMode == PopupPositionMode.FollowMouse) {
PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
}
else if(anchorTarget != null) {
PositionAnchoredTo(anchorTarget, anchorSide);
}
}
private void CacheCanvas() {
if(rootCanvas != null) {
return;
}
rootCanvas = GetComponentInParent<Canvas>()?.rootCanvas;
canvasCamera = rootCanvas != null && rootCanvas.renderMode != RenderMode.ScreenSpaceOverlay
? rootCanvas.worldCamera : null;
}
private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
CacheCanvas();
var targetRect = GetScreenRect(target, canvasCamera);
var popupRect = GetScreenRect((RectTransform)transform, canvasCamera);
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) {
CacheCanvas();
var rt = (RectTransform)transform;
if(layoutDirty) {
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
layoutDirty = false;
}
var popupSize = GetScreenRect(rt, canvasCamera).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);
// Convert screen position to parent local space
var parentRt = rt.parent as RectTransform;
if(parentRt != null) {
RectTransformUtility.ScreenPointToLocalPointInRectangle(parentRt, screenPos, canvasCamera, out var localPos);
rt.localPosition = localPos;
}
else {
rt.position = screenPos;
}
}
private static readonly Vector3[] cornersBuffer = new Vector3[4];
private static Rect GetScreenRect(RectTransform rt, Camera camera) {
rt.GetWorldCorners(cornersBuffer);
// Convert world corners to screen space
var min = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[0]);
var max = RectTransformUtility.WorldToScreenPoint(camera, cornersBuffer[2]);
return new Rect(min, max - min);
}
/// <summary>The background RectTransform that sizes to content.</summary>
public RectTransform Background => background;
}
}

View File

@@ -39,7 +39,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 300, y: 500}
m_SizeDelta: {x: 300, y: 0}
m_Pivot: {x: 0, y: 1}
--- !u!225 &1835601435911948781
CanvasGroup:
@@ -68,11 +68,6 @@ MonoBehaviour:
content: {fileID: 176628901263125209}
canvasGroup: {fileID: 1835601435911948781}
background: {fileID: 8899521584296352500}
headerPrefab: {fileID: 6612787789151041457, guid: dfc1bc0bd5b4905409615c3e770a5b77, type: 3}
textPrefab: {fileID: 2506259255305457008, guid: bfa97c92d1878cc448ddc7dc456f4b17, type: 3}
statPrefab: {fileID: 1843470073663794312, guid: 5882db210c62d8647858933649f64c29, type: 3}
imagePrefab: {fileID: 7093821785826926595, guid: 5e715f4b614d02b4fa0b4d3fcfe3c053, type: 3}
separatorPrefab: {fileID: 4190588985333916705, guid: 7ccdfa1a2079db044be4b1684303ec7f, type: 3}
--- !u!223 &3081303906751693297
Canvas:
m_ObjectHideFlags: 0
@@ -219,14 +214,14 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 0.31132078, g: 0.22780241, b: 0.16006587, a: 1}
m_Color: {r: 0.1509434, g: 0.116711326, b: 0.088999644, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Sprite: {fileID: 21300000, guid: 52125a3c3df558448a5af5a04dbf8d2d, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1

View File

@@ -4,16 +4,18 @@
### Prefabs
Reference prefabs for the popup system. Copy these into your project as a starting point.
Reference prefabs for the popup system. Copy these into your project as a starting point. After copying, add entries to your `PopupSettings` asset's Element Prefabs list mapping `PopupElementType` values to these prefabs.
| Prefab | Description |
|---|---|
| `PopupReferencePrefab` | Main popup container with Canvas, CanvasGroup, Background, and Content |
| `PopupHeader` | Header text element (TMP_Text, bold, larger font) |
| `PopupText` | Body text element (TMP_Text, regular) |
| `PopupStat` | Stat row element (HorizontalLayoutGroup with label + value TMP_Text) |
| `PopupIcon` | Image element for icons or artwork |
| `PopupSeparator` | Horizontal divider line (Image, thin) |
| Prefab | PopupElementType | Description |
|---|---|---|
| `PopupReferencePrefab` | N/A | Main popup container with Canvas, CanvasGroup, Background, and Content |
| `PopupHeader` | `Header` ("header") | Header text element (TMP_Text, bold, larger font) |
| `PopupText` | `Text` ("text") | Body text element (TMP_Text, regular) |
| `PopupStat` | `LabelValueText` ("label_value_text") | Label + value row (HorizontalLayoutGroup with two TMP_Text children) |
| `PopupIcon` | `Image` ("image") | Image element for icons or artwork |
| `PopupSeparator` | `Separator` ("separator") | Horizontal divider line (Image, thin) |
To add variants, duplicate a prefab, style it differently, and register it with a variant type. Use `PopupElementType.Header.Variant("gold")` in code, and set the type to "header_gold" in the PopupSettings Inspector.
### Settings
@@ -25,13 +27,32 @@ Reference prefabs for the popup system. Copy these into your project as a starti
| Script | Description |
|---|---|
| `PopupSystemExample` | Basic setup with auto-scanned triggers, GetTrigger by name, and GetTriggers by category |
| `DynamicTriggersExample` | Setting up triggers on dynamically instantiated UI using InitializeTriggersInChildren |
| `CodeOnlyPopupExample` | Showing popups from code without PopupTrigger (anchored, fixed position, follow mouse) |
| `PopupSystemExample` | Basic setup with auto-scanned triggers, `GetTriggerHandler` by name, and `GetTriggerHandlers` by category |
| `DynamicTriggersExample` | Setting up triggers on dynamically instantiated UI using `InitializeTriggersInChildren` with (trigger, view) callback |
| `CodeOnlyPopupExample` | Showing popups from code (anchored, fixed position, follow mouse), `PopupElementType` variants, and generic `Add()` for custom types |
### Architecture Overview
```
PopupTrigger (MonoBehaviour) -- reference holder, forwards pointer events
|
PopupTriggerView (C# class) -- behavior: calls IPopupSystem.Show/Hide
|
IPopupSystem / PopupSystem -- manages categories, delays, priority, triggers
|
PopupView (C# class) -- behavior: generic element cache, positioning
|
PopupReference (MonoBehaviour) -- reference holder: content, canvasGroup, background
|
PopupSettings (ScriptableObject) -- configuration + element prefab registry (PopupElementType -> prefab)
|
PopupElementType (struct) -- type-safe element identifier (Header, Text, LabelValueText, Image, Separator + custom)
```
## How to use
1. Import the samples via the Unity Package Manager (select the package, expand Samples, click Import)
2. Copy the prefabs into your project's Prefabs folder
3. Create a PopupSettings asset or use the provided one
4. Reference the example scripts for integration patterns
4. Add Element Prefab entries to PopupSettings using the dropdown (Header, Text, LabelValueText, Image, Separator) mapped to the copied prefabs
5. Reference the example scripts for integration patterns

View File

@@ -7,6 +7,7 @@ using UnityEngine;
///
/// Use this approach for confirmation dialogs, tutorial tips, or any popup
/// that is triggered by game logic rather than hover events.
/// Also demonstrates PopupElementType variants and the generic Add() method.
/// </summary>
public class CodeOnlyPopupExample : MonoBehaviour {
[SerializeField] PopupSettings popupSettings;
@@ -14,6 +15,9 @@ public class CodeOnlyPopupExample : MonoBehaviour {
[SerializeField] Transform canvasRoot;
[SerializeField] RectTransform targetElement;
// Custom element type defined in game code
static readonly PopupElementType BadgeElement = new("badge");
IPopupSystem popupSystem;
void Start() {
@@ -24,12 +28,12 @@ public class CodeOnlyPopupExample : MonoBehaviour {
void Update() {
popupSystem?.Tick(Time.deltaTime);
// Show anchored to an element on key press
// Show anchored to an element
if(Input.GetKeyDown(KeyCode.Alpha1)) {
popupSystem.Show(PopupCategory.General, builder => {
builder
.AddHeader("Anchored Popup")
.AddText("This popup is anchored to a UI element.");
.AddText("Anchored Popup", PopupElementType.Header)
.AddText("This popup is anchored to a UI element.", PopupElementType.Text);
}, targetElement, AnchorSide.Right);
}
@@ -37,8 +41,8 @@ public class CodeOnlyPopupExample : MonoBehaviour {
if(Input.GetKeyDown(KeyCode.Alpha2)) {
popupSystem.ShowAtPosition(PopupCategory.General, builder => {
builder
.AddHeader("Fixed Position")
.AddText("This popup appears at the center of the screen.");
.AddText("Fixed Position", PopupElementType.Header)
.AddText("This popup appears at the center of the screen.", PopupElementType.Text);
}, new Vector2(Screen.width * 0.5f, Screen.height * 0.5f));
}
@@ -46,12 +50,36 @@ public class CodeOnlyPopupExample : MonoBehaviour {
if(Input.GetKeyDown(KeyCode.Alpha3)) {
popupSystem.Show(PopupCategory.General, builder => {
builder
.AddHeader("Follow Mouse")
.AddText("This popup follows the cursor.");
.AddText("Follow Mouse", PopupElementType.Header)
.AddText("This popup follows the cursor.", PopupElementType.Text);
});
}
// Hide on key press
// Demonstrate variant elements
// Requires "header_gold" and "separator_thick" entries in PopupSettings.elementPrefabs
if(Input.GetKeyDown(KeyCode.Alpha4)) {
popupSystem.Show(PopupCategory.General, builder => {
builder
.AddText("Legendary Item", PopupElementType.Header.Variant("gold"))
.AddSeparator(PopupElementType.Separator.Variant("thick"))
.AddNameValue("Damage", "150", PopupElementType.LabelValueText)
.AddText("A weapon forged in dragon fire.", "FF6600", PopupElementType.Text);
}, targetElement, AnchorSide.Below);
}
// Demonstrate generic Add() for fully custom elements
// Requires a "badge" entry in PopupSettings.elementPrefabs
if(Input.GetKeyDown(KeyCode.Alpha5)) {
popupSystem.Show(PopupCategory.General, builder => {
builder.AddText("Custom Element", PopupElementType.Header);
var badge = builder.Add(BadgeElement);
if(badge != null) {
// Access any components on the custom prefab
// badge.GetComponent<MyBadgeComponent>().SetData(...);
}
}, targetElement);
}
if(Input.GetKeyDown(KeyCode.Escape)) {
popupSystem.HideAll();
}

View File

@@ -6,7 +6,9 @@ using UnityEngine;
/// Example: Using PopupSystem with dynamically instantiated UI elements.
///
/// When UI elements are created at runtime (e.g. inventory slots, party member portraits),
/// use InitializeTriggersInChildren to scan and bind triggers after instantiation.
/// use InitializeTriggersInChildren to scan, bind, and configure triggers after instantiation.
/// The callback receives both the trigger (MonoBehaviour, for hierarchy queries) and the
/// view (behavior handler, for setting content).
/// </summary>
public class DynamicTriggersExample : MonoBehaviour {
[SerializeField] PopupSettings popupSettings;
@@ -21,21 +23,17 @@ public class DynamicTriggersExample : MonoBehaviour {
popupSystem = new PopupSystem(popupSettings, popupReferencePrefab, canvasRoot);
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
// Simulate creating dynamic UI slots
for(int i = 0; i < 5; i++) {
Instantiate(slotPrefab, slotsContainer);
}
// Scan the container for any PopupTrigger components on the new slots.
// Each trigger is automatically bound to the popup system.
// The configure callback lets you set content per trigger.
popupSystem.InitializeTriggersInChildren(slotsContainer, trigger => {
popupSystem.InitializeTriggersInChildren(slotsContainer, (trigger, view) => {
var slotName = trigger.gameObject.name;
trigger.SetContent(builder => {
view.SetContent(builder => {
builder
.AddHeader(slotName)
.AddSeparator()
.AddText("This is a dynamically created slot.");
.AddText(slotName, PopupElementType.Header)
.AddSeparator(PopupElementType.Separator)
.AddText("This is a dynamically created slot.", PopupElementType.Text);
});
});
}

View File

@@ -9,7 +9,10 @@ using UnityEngine;
/// 2. Assign the PopupSettings asset and PopupReference prefab.
/// 3. Set canvasRoot to the root Canvas that contains your UI and PopupTrigger components.
/// 4. Place PopupTrigger components on any UI elements that should show popups on hover.
/// 5. The system auto-scans triggers on creation. Use SetContent() to provide data.
/// 5. The system auto-scans triggers on creation. Use GetTriggerHandler() to set content.
///
/// Element prefabs are configured in PopupSettings via PopupElementType.
/// Add entries mapping types (header, text, label_value_text, separator, image) to prefabs.
/// </summary>
public class PopupSystemExample : MonoBehaviour {
[SerializeField] PopupSettings popupSettings;
@@ -19,52 +22,46 @@ public class PopupSystemExample : MonoBehaviour {
IPopupSystem popupSystem;
void Start() {
// Create the system. Passing canvasRoot auto-scans all PopupTrigger components.
popupSystem = new PopupSystem(popupSettings, popupReferencePrefab, canvasRoot);
// Register categories before showing popups.
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
popupSystem.RegisterCategory(PopupCategory.General, priority: 1);
// Option A: Set content on an auto-scanned trigger by GameObject name.
var characterTrigger = popupSystem.GetTrigger("CharacterPortrait");
if(characterTrigger != null) {
characterTrigger.SetContent(builder => {
builder
.AddHeader("Kael")
.AddText("Human Warrior", "CCCCCC")
.AddSeparator()
.AddStat("Health", 55)
.AddStat("Mana", 42)
.AddStat("Level", 1)
.AddSeparator()
.AddStat("Might", 8)
.AddStat("Reflex", 2)
.AddStat("Knowledge", 5)
.AddStat("Perception", 1);
});
}
// Set content on an auto-scanned trigger by GameObject name.
var characterHandler = popupSystem.GetTriggerHandler("CharacterPortrait");
characterHandler?.SetContent(builder => {
builder
.AddText("Kael", PopupElementType.Header)
.AddText("Human Warrior", "CCCCCC", PopupElementType.Text)
.AddSeparator(PopupElementType.Separator)
.AddNameValue("Health", 55, PopupElementType.LabelValueText)
.AddNameValue("Mana", 42, PopupElementType.LabelValueText)
.AddNameValue("Level", 1, PopupElementType.LabelValueText)
.AddSeparator(PopupElementType.Separator)
.AddNameValue("Might", 8, PopupElementType.LabelValueText)
.AddNameValue("Reflex", 2, PopupElementType.LabelValueText)
.AddNameValue("Knowledge", 5, PopupElementType.LabelValueText)
.AddNameValue("Perception", 1, PopupElementType.LabelValueText);
});
// Option B: Set content on all triggers of a category.
foreach(var trigger in popupSystem.GetTriggers(PopupCategory.Item)) {
trigger.SetContent(builder => {
// Set content on all triggers of a category.
foreach(var handler in popupSystem.GetTriggerHandlers(PopupCategory.Item)) {
handler.SetContent(builder => {
builder
.AddHeader("Health Potion")
.AddSeparator()
.AddText("Restores a moderate amount of health.")
.AddStat("Heal Amount", 50);
.AddText("Health Potion", PopupElementType.Header)
.AddSeparator(PopupElementType.Separator)
.AddText("Restores a moderate amount of health.", PopupElementType.Text)
.AddNameValue("Heal Amount", 50, PopupElementType.LabelValueText);
});
}
}
void Update() {
// Tick drives delay timers and animations.
popupSystem?.Tick(Time.deltaTime);
}
void OnDestroy() {
// Clean up all popup views.
popupSystem?.Dispose();
}
}

View File

@@ -21,6 +21,22 @@ MonoBehaviour:
followMouseOffset: {x: 15, y: -15}
touchHoldDuration: 0.6
gamepadFocusTrigger: 1
elementPrefabs:
- type:
id: header
prefab: {fileID: 7034836061828108288, guid: dfc1bc0bd5b4905409615c3e770a5b77, type: 3}
- type:
id: image
prefab: {fileID: 5887814251614319338, guid: 5e715f4b614d02b4fa0b4d3fcfe3c053, type: 3}
- type:
id: label_value_text
prefab: {fileID: 6770634903822758885, guid: 7ccdfa1a2079db044be4b1684303ec7f, type: 3}
- type:
id: separator
prefab: {fileID: 6770634903822758885, guid: 7ccdfa1a2079db044be4b1684303ec7f, type: 3}
- type:
id: text
prefab: {fileID: 3157287847714375358, guid: bfa97c92d1878cc448ddc7dc456f4b17, type: 3}
categoryPriorities:
- category:
id: Character