diff --git a/Assets/Code/GameState/UI/PartyGuiView.cs b/Assets/Code/GameState/UI/PartyGuiView.cs index 05bcaa2..905e843 100644 --- a/Assets/Code/GameState/UI/PartyGuiView.cs +++ b/Assets/Code/GameState/UI/PartyGuiView.cs @@ -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); } } } diff --git a/Assets/Database/UI/PopupSettings.asset b/Assets/Database/UI/PopupSettings.asset index 300fcc1..e8a3267 100644 --- a/Assets/Database/UI/PopupSettings.asset +++ b/Assets/Database/UI/PopupSettings.asset @@ -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 diff --git a/Assets/Prefabs/UI/PopupSystem/PopupReferencePrefab.prefab b/Assets/Prefabs/UI/PopupSystem/PopupReferencePrefab.prefab index 15f696e..3524044 100644 --- a/Assets/Prefabs/UI/PopupSystem/PopupReferencePrefab.prefab +++ b/Assets/Prefabs/UI/PopupSystem/PopupReferencePrefab.prefab @@ -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 diff --git a/Packages/com.jovian.popup-system/Editor/PopupElementTypeDrawer.cs b/Packages/com.jovian.popup-system/Editor/PopupElementTypeDrawer.cs new file mode 100644 index 0000000..dd12062 --- /dev/null +++ b/Packages/com.jovian.popup-system/Editor/PopupElementTypeDrawer.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace Jovian.PopupSystem.Editor { + /// + /// Custom PropertyDrawer for . Shows a dropdown with + /// built-in element types plus a "Custom..." option for free-text entry. + /// + [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(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("k__BackingField"); + } + } +} diff --git a/Packages/com.jovian.popup-system/Editor/PopupElementTypeDrawer.cs.meta b/Packages/com.jovian.popup-system/Editor/PopupElementTypeDrawer.cs.meta new file mode 100644 index 0000000..562f232 --- /dev/null +++ b/Packages/com.jovian.popup-system/Editor/PopupElementTypeDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ea57be8755c60e4bbf156ac2b32c98c \ No newline at end of file diff --git a/Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs b/Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs index 91b1b60..8753416 100644 --- a/Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs +++ b/Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs @@ -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); diff --git a/Packages/com.jovian.popup-system/README.md b/Packages/com.jovian.popup-system/README.md index d48f602..68a01c1 100644 --- a/Packages/com.jovian.popup-system/README.md +++ b/Packages/com.jovian.popup-system/README.md @@ -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(); - 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(); -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().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)`. 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)`. | +| `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. diff --git a/Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs b/Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs index b08876b..e70beae 100644 --- a/Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs +++ b/Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs @@ -1,86 +1,114 @@ -using Jovian.PopupSystem.UI; +using TMPro; using UnityEngine; +using UnityEngine.UI; namespace Jovian.PopupSystem { /// - /// Fluent API struct for building popup content. Operates directly on cached elements - /// inside a — no intermediate allocations. Received in - /// the build callback passed to . + /// Fluent API struct for building popup content. Operates on the generic element cache + /// inside a . Received in the build callback passed to + /// . Use for fully custom elements, + /// or convenience methods for common types. /// - public struct PopupContentBuilder { - readonly PopupReference view; + public readonly struct PopupContentBuilder { + readonly PopupView view; /// - /// Creates a builder targeting the given popup reference. + /// Creates a builder targeting the given popup view. /// - public PopupContentBuilder(PopupReference view) { + public PopupContentBuilder(PopupView view) { this.view = view; } /// - /// 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. /// - public PopupContentBuilder AddHeader(string text) { - var header = view.GetHeader(); - header.text = text; + public GameObject Add(PopupElementType elementType) { + return view.GetElement(elementType); + } + + /// + /// Adds a text element using the given element type and sets its TMP_Text content. + /// + public PopupContentBuilder AddText(string text, PopupElementType elementType) { + var go = view.GetElement(elementType); + if(go == null) { + return this; + } + var tmp = go.GetComponentInChildren(); + if(tmp) { + tmp.text = text; + } return this; } /// - /// Adds a body text element. + /// Adds a colored text element using the given element type. + /// Hex color can be with or without the # prefix. /// - public PopupContentBuilder AddText(string text) { - var label = view.GetText(); - label.text = text; - return this; - } - - /// - /// Adds a colored body text element. Hex color can be with or without the # prefix. - /// - 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(); + if(!tmp) { + return this; + } var prefix = hexColor.Length > 0 && hexColor[0] == '#' ? "" : "#"; - label.text = $"{text}"; + tmp.text = $"{text}"; return this; } /// - /// 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). /// - 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()); } /// - /// 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). /// - 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(true); + if(children.Length >= 2) { + children[0].text = label; + children[1].text = value; + } + } return this; } /// /// Adds an image element with the given sprite and optional height. /// - 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(); + if(image != null) { + image.sprite = sprite; + var rt = (RectTransform)image.transform; + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height); + } + } return this; } /// - /// Adds a horizontal separator line. + /// Adds a separator element using the given element type. /// - public PopupContentBuilder AddSeparator() { - view.GetSeparator(); + public PopupContentBuilder AddSeparator(PopupElementType elementType) { + view.GetElement(elementType); return this; } } diff --git a/Packages/com.jovian.popup-system/Runtime/PopupElementType.cs b/Packages/com.jovian.popup-system/Runtime/PopupElementType.cs new file mode 100644 index 0000000..78637a8 --- /dev/null +++ b/Packages/com.jovian.popup-system/Runtime/PopupElementType.cs @@ -0,0 +1,74 @@ +using System; +using UnityEngine; + +namespace Jovian.PopupSystem { + /// + /// 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. + /// + [Serializable] + public struct PopupElementType : IEquatable { + [SerializeField] private string id; + + /// The string identifier for this element type. + public string Id => id; + + public PopupElementType(string id) { + this.id = id; + } + + // --- Built-in types --- + + /// Bold header text element. + public static readonly PopupElementType Header = new("header"); + + /// Body text element. + public static readonly PopupElementType Text = new("text"); + + /// Label + value stat row element. + public static readonly PopupElementType LabelValueText = new("label_value_text"); + + /// Image/icon element. + public static readonly PopupElementType Image = new("image"); + + /// Horizontal separator line. + public static readonly PopupElementType Separator = new("separator"); + + // --- Variant helper --- + + /// + /// Creates a variant of this element type by appending a suffix. + /// e.g. PopupElementType.Header.Variant("gold") produces "header_gold". + /// + 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); + } + } +} diff --git a/Packages/com.jovian.popup-system/Runtime/PopupElementType.cs.meta b/Packages/com.jovian.popup-system/Runtime/PopupElementType.cs.meta new file mode 100644 index 0000000..edfe16d --- /dev/null +++ b/Packages/com.jovian.popup-system/Runtime/PopupElementType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ba7e78e3d930334a935a600a8f67ded \ No newline at end of file diff --git a/Packages/com.jovian.popup-system/Runtime/PopupSettings.cs b/Packages/com.jovian.popup-system/Runtime/PopupSettings.cs index 5d0f5a7..f7b6081 100644 --- a/Packages/com.jovian.popup-system/Runtime/PopupSettings.cs +++ b/Packages/com.jovian.popup-system/Runtime/PopupSettings.cs @@ -24,12 +24,33 @@ namespace Jovian.PopupSystem { public float touchHoldDuration = 0.6f; public bool gamepadFocusTrigger = true; + [Header("Element Prefabs")] + public List elementPrefabs = new(); + [Header("Priority")] public List categoryPriorities = new(); [Header("Per-Category Overrides")] public List categoryDelayOverrides = new(); + private Dictionary prefabLookup; + + /// + /// Returns the element prefab registered under the given type, or null if not found. + /// + public GameObject GetPrefab(PopupElementType elementType) { + if(prefabLookup == null) { + prefabLookup = new Dictionary(); + 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; + } + /// /// Returns the configured priority for a category, or 0 if not configured. /// @@ -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; diff --git a/Packages/com.jovian.popup-system/Runtime/PopupSystem.cs b/Packages/com.jovian.popup-system/Runtime/PopupSystem.cs index 69180b8..00ae63e 100644 --- a/Packages/com.jovian.popup-system/Runtime/PopupSystem.cs +++ b/Packages/com.jovian.popup-system/Runtime/PopupSystem.cs @@ -189,8 +189,8 @@ namespace Jovian.PopupSystem { /// 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(); + var canvas = popupRef.GetComponent(); 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; diff --git a/Packages/com.jovian.popup-system/Runtime/PopupView.cs b/Packages/com.jovian.popup-system/Runtime/PopupView.cs new file mode 100644 index 0000000..19d60a3 --- /dev/null +++ b/Packages/com.jovian.popup-system/Runtime/PopupView.cs @@ -0,0 +1,224 @@ +using System.Collections.Generic; +using Jovian.PopupSystem.UI; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.UI; + +namespace Jovian.PopupSystem { + /// + /// Behavior class for a popup view. Manages the generic grow-only element cache, + /// positioning, visibility, and layout. Operates on a + /// MonoBehaviour for scene references. + /// + public sealed class PopupView { + readonly PopupReference reference; + readonly PopupSettings settings; + + // Generic element cache: keyed by element type + readonly Dictionary> elementCache = new(); + readonly Dictionary elementIndex = new(); + + // Positioning state + PopupPositionMode positionMode; + RectTransform anchorTarget; + AnchorSide anchorSide; + Vector2 followOffset; + float screenEdgePadding; + float maxWidth; + bool isVisible; + bool layoutDirty; + Canvas rootCanvas; + Camera canvasCamera; + + /// The underlying MonoBehaviour reference holder. + public PopupReference Reference => reference; + + /// The CanvasGroup for animation control. + public CanvasGroup CanvasGroup => reference.CanvasGroup; + + /// The content RectTransform where elements are parented. + public RectTransform Content => reference.Content; + + /// The root Transform of the popup GameObject. + public Transform Transform => reference.transform; + + /// Whether the popup is currently visible. + public bool IsVisible => isVisible; + + /// + /// Creates a new popup view wrapping the given reference and settings. + /// + public PopupView(PopupReference reference, PopupSettings settings) { + this.reference = reference; + this.settings = settings; + } + + // --- Element cache (generic, grow-only) --- + + /// + /// 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. + /// + public GameObject GetElement(PopupElementType elementType) { + if(!elementCache.TryGetValue(elementType, out var cache)) { + cache = new List(); + 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; + } + + /// + /// Deactivates all cached content elements and marks layout as dirty. + /// + 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 --- + + /// Shows or hides the popup GameObject. Resets alpha to 0 when hiding. + public void SetVisible(bool visible) { + isVisible = visible; + reference.gameObject.SetActive(visible); + if(!visible) { + reference.CanvasGroup.alpha = 0f; + } + } + + /// Constrains the popup's horizontal size to the given maximum width in pixels. + 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 --- + + /// Configures the popup to anchor to a target element on the specified side. + public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) { + positionMode = PopupPositionMode.AnchorToElement; + anchorTarget = target; + anchorSide = side; + screenEdgePadding = edgePadding; + } + + /// Configures the popup to follow the mouse cursor with the given offset. + public void SetFollowMouseMode(Vector2 offset, float edgePadding) { + positionMode = PopupPositionMode.FollowMouse; + followOffset = offset; + screenEdgePadding = edgePadding; + } + + /// Positions the popup at a fixed screen coordinate with edge clamping. + public void SetFixedPosition(Vector2 screenPos, float edgePadding) { + positionMode = PopupPositionMode.AnchorToElement; + anchorTarget = null; + screenEdgePadding = edgePadding; + PositionAtScreenPoint(screenPos); + } + + /// Updates the popup position based on the current mode (follow mouse or anchored). + 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()?.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); + } + } +} diff --git a/Packages/com.jovian.popup-system/Runtime/PopupView.cs.meta b/Packages/com.jovian.popup-system/Runtime/PopupView.cs.meta new file mode 100644 index 0000000..d818321 --- /dev/null +++ b/Packages/com.jovian.popup-system/Runtime/PopupView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 18ebd1b0205e20440aa4c4991b43cc46 \ No newline at end of file diff --git a/Packages/com.jovian.popup-system/Runtime/UI/PopupReference.cs b/Packages/com.jovian.popup-system/Runtime/UI/PopupReference.cs index f688d86..2418fc8 100644 --- a/Packages/com.jovian.popup-system/Runtime/UI/PopupReference.cs +++ b/Packages/com.jovian.popup-system/Runtime/UI/PopupReference.cs @@ -1,253 +1,23 @@ -using System.Collections.Generic; -using TMPro; using UnityEngine; -using UnityEngine.InputSystem; -using UnityEngine.UI; namespace Jovian.PopupSystem.UI { /// - /// MonoBehaviour reference holder for a popup view. Manages the grow-only element cache, - /// screen positioning, and visibility. One instance per registered . + /// Reference-only MonoBehaviour for a popup prefab. Holds serialized scene references + /// to the content container, canvas group, and background. All behavior is in + /// . /// 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 headerCache = new(); - readonly List textCache = new(); - readonly List statCache = new(); - readonly List imageCache = new(); - readonly List 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; + /// The content RectTransform where popup elements are parented. public RectTransform Content => content; - public bool IsVisible => isVisible; - /// Constrains the popup's horizontal size to the given maximum width in pixels. - 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; - } - } + /// The CanvasGroup for fade animation control. + public CanvasGroup CanvasGroup => canvasGroup; - /// Shows or hides the popup GameObject. Resets alpha to 0 when hiding. - public void SetVisible(bool visible) { - isVisible = visible; - gameObject.SetActive(visible); - if(!visible) { - canvasGroup.alpha = 0f; - } - } - - /// Deactivates all cached content elements and marks layout as dirty. - 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(List 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) --- - - /// Returns the next available header element from the cache, or creates one. - public TMP_Text GetHeader() { - return GetOrCreate(headerCache, headerPrefab, ref headerIndex); - } - - /// Returns the next available text element from the cache, or creates one. - public TMP_Text GetText() { - return GetOrCreate(textCache, textPrefab, ref textIndex); - } - - /// Returns the next available stat row (label + value pair) from the cache, or creates one. - 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(true); - var entry = new StatEntry { root = created, label = children[0], value = children[1] }; - statCache.Add(entry); - statIndex++; - return (entry.label, entry.value); - } - - /// Returns the next available image element from the cache, or creates one. - public Image GetImage() { - return GetOrCreate(imageCache, imagePrefab, ref imageIndex); - } - - /// Returns the next available separator element from the cache, or creates one. - public Image GetSeparator() { - return GetOrCreate(separatorCache, separatorPrefab, ref separatorIndex); - } - - private T GetOrCreate(List 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 --- - - /// Configures the popup to anchor to a target element on the specified side. - public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) { - positionMode = PopupPositionMode.AnchorToElement; - anchorTarget = target; - anchorSide = side; - screenEdgePadding = edgePadding; - } - - /// Configures the popup to follow the mouse cursor with the given offset. - public void SetFollowMouseMode(Vector2 offset, float edgePadding) { - positionMode = PopupPositionMode.FollowMouse; - followOffset = offset; - screenEdgePadding = edgePadding; - } - - /// Positions the popup at a fixed screen coordinate with edge clamping. - public void SetFixedPosition(Vector2 screenPos, float edgePadding) { - positionMode = PopupPositionMode.AnchorToElement; - anchorTarget = null; - screenEdgePadding = edgePadding; - PositionAtScreenPoint(screenPos); - } - - /// Updates the popup position based on the current mode (follow mouse or anchored). - 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()?.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); - } + /// The background RectTransform that sizes to content. + public RectTransform Background => background; } } diff --git a/Packages/com.jovian.popup-system/Samples~/Prefabs/PopupReferencePrefab.prefab b/Packages/com.jovian.popup-system/Samples~/Prefabs/PopupReferencePrefab.prefab index e567ad8..3524044 100644 --- a/Packages/com.jovian.popup-system/Samples~/Prefabs/PopupReferencePrefab.prefab +++ b/Packages/com.jovian.popup-system/Samples~/Prefabs/PopupReferencePrefab.prefab @@ -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 diff --git a/Packages/com.jovian.popup-system/Samples~/README.md b/Packages/com.jovian.popup-system/Samples~/README.md index 806f184..fc6d567 100644 --- a/Packages/com.jovian.popup-system/Samples~/README.md +++ b/Packages/com.jovian.popup-system/Samples~/README.md @@ -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 diff --git a/Packages/com.jovian.popup-system/Samples~/Scripts/CodeOnlyPopupExample.cs b/Packages/com.jovian.popup-system/Samples~/Scripts/CodeOnlyPopupExample.cs index 689a8f3..1cc2bae 100644 --- a/Packages/com.jovian.popup-system/Samples~/Scripts/CodeOnlyPopupExample.cs +++ b/Packages/com.jovian.popup-system/Samples~/Scripts/CodeOnlyPopupExample.cs @@ -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. /// 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().SetData(...); + } + }, targetElement); + } + if(Input.GetKeyDown(KeyCode.Escape)) { popupSystem.HideAll(); } diff --git a/Packages/com.jovian.popup-system/Samples~/Scripts/DynamicTriggersExample.cs b/Packages/com.jovian.popup-system/Samples~/Scripts/DynamicTriggersExample.cs index f61d9e7..db26570 100644 --- a/Packages/com.jovian.popup-system/Samples~/Scripts/DynamicTriggersExample.cs +++ b/Packages/com.jovian.popup-system/Samples~/Scripts/DynamicTriggersExample.cs @@ -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). /// 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); }); }); } diff --git a/Packages/com.jovian.popup-system/Samples~/Scripts/PopupSystemExample.cs b/Packages/com.jovian.popup-system/Samples~/Scripts/PopupSystemExample.cs index 619cba0..68cbafc 100644 --- a/Packages/com.jovian.popup-system/Samples~/Scripts/PopupSystemExample.cs +++ b/Packages/com.jovian.popup-system/Samples~/Scripts/PopupSystemExample.cs @@ -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. /// 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(); } } diff --git a/Packages/com.jovian.popup-system/Samples~/Settings/PopupSettings.asset b/Packages/com.jovian.popup-system/Samples~/Settings/PopupSettings.asset index 300fcc1..8d3bd5b 100644 --- a/Packages/com.jovian.popup-system/Samples~/Settings/PopupSettings.asset +++ b/Packages/com.jovian.popup-system/Samples~/Settings/PopupSettings.asset @@ -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