forked from Shardstone/trail-into-darkness
Added a popup system
This commit is contained in:
@@ -22,7 +22,8 @@
|
||||
"Bash(xxd Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs)",
|
||||
"Bash(find D:/repos/trail-into-darkness/Assets -name *.asmdef)",
|
||||
"Bash(grep -E \"\\\\.\\(prefab|unity\\)$\")",
|
||||
"Bash(python3:*)"
|
||||
"Bash(python3:*)",
|
||||
"WebSearch"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Jovian.PopupSystem.Editor",
|
||||
"rootNamespace": "Jovian.PopupSystem.Editor",
|
||||
"references": [
|
||||
"Jovian.PopupSystem"
|
||||
],
|
||||
"includePlatforms": ["Editor"],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem.Editor {
|
||||
public sealed class PopupSettingsProvider : SettingsProvider {
|
||||
private PopupSettings settings;
|
||||
|
||||
private PopupSettingsProvider(string path, SettingsScope scope)
|
||||
: base(path, scope) { }
|
||||
|
||||
private SerializedObject serializedSettings;
|
||||
|
||||
public override void OnActivate(string searchContext, UnityEngine.UIElements.VisualElement rootElement) {
|
||||
var guids = AssetDatabase.FindAssets("t:PopupSettings");
|
||||
if(guids.Length > 0) {
|
||||
var path = AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
settings = AssetDatabase.LoadAssetAtPath<PopupSettings>(path);
|
||||
}
|
||||
if(settings != null) {
|
||||
serializedSettings = new SerializedObject(settings);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnGUI(string searchContext) {
|
||||
if(settings == null) {
|
||||
EditorGUILayout.HelpBox("No PopupSettings asset found. Create one via Assets > Create > Jovian > Popup System > Popup Settings.", MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
EditorGUILayout.LabelField("Popup System Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
serializedSettings.Update();
|
||||
EditorGUI.BeginChangeCheck();
|
||||
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("popupDelay"));
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("fadeDuration"));
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("defaultAnchorSide"));
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("screenEdgePadding"));
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("maxPopupWidth"));
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("sortingOrder"));
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
EditorGUILayout.LabelField("Follow Mouse", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("followMouseOffset"));
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
EditorGUILayout.LabelField("Input", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("touchHoldDuration"));
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("gamepadFocusTrigger"));
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
EditorGUILayout.LabelField("Priority", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("categoryPriorities"), true);
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
EditorGUILayout.LabelField("Per-Category Overrides", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(serializedSettings.FindProperty("categoryDelayOverrides"), true);
|
||||
|
||||
if(EditorGUI.EndChangeCheck()) {
|
||||
serializedSettings.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(settings);
|
||||
}
|
||||
}
|
||||
|
||||
[SettingsProvider]
|
||||
public static SettingsProvider CreateProvider() {
|
||||
var provider = new PopupSettingsProvider("Project/Jovian/Popup System", SettingsScope.Project) {
|
||||
keywords = new HashSet<string>(new[] { "popup", "tooltip", "hover", "delay" })
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
417
Packages/com.jovian.popup-system/README.md
Normal file
417
Packages/com.jovian.popup-system/README.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Jovian Popup System
|
||||
|
||||
A lightweight, low-allocation popup and tooltip system for Unity with category-based isolation, a fluent content builder, and extensible animations.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Unity 2022.3 or later
|
||||
- TextMeshPro 3.0.6+
|
||||
- Input System 1.18.0+
|
||||
- Newtonsoft JSON 3.2.1+
|
||||
|
||||
Install via the Unity Package Manager by adding the package from its local path or from a git URL.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a PopupSettings asset
|
||||
|
||||
In the Unity Editor, go to **Assets > Create > Jovian > Popup System > Popup Settings**. Place the asset somewhere accessible (e.g. `Assets/Settings/PopupSettings.asset`). You can also configure settings via **Project Settings > Jovian > Popup System**.
|
||||
|
||||
### 2. Build a PopupView prefab
|
||||
|
||||
See the [Prefab Setup](#prefab-setup) section below for step-by-step instructions.
|
||||
|
||||
### 3. Create and use PopupSystem
|
||||
|
||||
```csharp
|
||||
using Jovian.PopupSystem;
|
||||
using Jovian.PopupSystem.UI;
|
||||
|
||||
// Create the system. viewPrefab is a reference to your PopupView prefab.
|
||||
var popup = new PopupSystem(settings, viewPrefab);
|
||||
|
||||
// Register categories you intend to use.
|
||||
popup.RegisterCategory(PopupCategory.Item);
|
||||
popup.RegisterCategory(PopupCategory.Character);
|
||||
|
||||
// Show a popup anchored to a UI element.
|
||||
popup.Show(PopupCategory.Item, builder => {
|
||||
builder
|
||||
.AddHeader("Health Potion")
|
||||
.AddSeparator()
|
||||
.AddText("Restores a moderate amount of health.")
|
||||
.AddStat("Heal Amount", 50)
|
||||
.AddStat("Uses", "3 / 3");
|
||||
}, anchorRect, AnchorSide.Right);
|
||||
|
||||
// Tick each frame (typically in Update or a game-state loop).
|
||||
popup.Tick(Time.deltaTime);
|
||||
|
||||
// Clean up when the game state is torn down.
|
||||
popup.Dispose();
|
||||
```
|
||||
|
||||
## PopupCategory
|
||||
|
||||
`PopupCategory` is a readonly struct that acts as a channel for isolating popups. Each category gets its own view instance and can have independent priority and delay settings.
|
||||
|
||||
### Built-in categories
|
||||
|
||||
```csharp
|
||||
PopupCategory.Character // Character tooltips
|
||||
PopupCategory.Item // Item tooltips
|
||||
PopupCategory.Skill // Skill tooltips
|
||||
PopupCategory.General // General-purpose tooltips
|
||||
```
|
||||
|
||||
### Custom categories
|
||||
|
||||
Create any number of additional categories:
|
||||
|
||||
```csharp
|
||||
var lootCategory = new PopupCategory("Loot");
|
||||
popup.RegisterCategory(lootCategory, priority: 5);
|
||||
```
|
||||
|
||||
Categories are compared by their string ID using ordinal comparison.
|
||||
|
||||
## PopupSettings
|
||||
|
||||
`PopupSettings` is a ScriptableObject that holds all configuration. Create one via **Assets > Create > Jovian > Popup System > Popup Settings**.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `popupDelay` | float | 0.4 | Seconds before the popup appears after a show request. |
|
||||
| `fadeDuration` | float | 0.2 | Duration of the fade-in and fade-out animation. |
|
||||
| `defaultAnchorSide` | AnchorSide | Below | Default side to anchor the popup relative to the target element. |
|
||||
| `screenEdgePadding` | float | 10 | Minimum pixel distance from screen edges. |
|
||||
| `maxPopupWidth` | float | 400 | Maximum width of the popup in pixels. |
|
||||
| `sortingOrder` | int | 100 | Sorting order applied to the popup Canvas. |
|
||||
| `followMouseOffset` | Vector2 | (15, -15) | Pixel offset from the cursor in follow-mouse mode. |
|
||||
| `touchHoldDuration` | float | 0.6 | Seconds a touch must be held before triggering a popup. |
|
||||
| `gamepadFocusTrigger` | bool | true | Whether gamepad focus triggers popups. |
|
||||
| `categoryPriorities` | List | empty | Per-category priority overrides. Higher priority popups dismiss lower ones. |
|
||||
| `categoryDelayOverrides` | List | empty | Per-category delay overrides. Overrides `popupDelay` for specific categories. |
|
||||
|
||||
## PopupContentBuilder
|
||||
|
||||
`PopupContentBuilder` is a struct with a fluent API for composing popup content. You receive it in the build callback passed to `Show` or `ShowAtPosition`.
|
||||
|
||||
### Available methods
|
||||
|
||||
```csharp
|
||||
builder.AddHeader("Fireball"); // Bold header text
|
||||
builder.AddText("Deals fire damage to all enemies."); // Body text
|
||||
builder.AddText("Rare", "FFD700"); // Colored text (hex, with or without #)
|
||||
builder.AddStat("Damage", 120); // Label + integer value row
|
||||
builder.AddStat("Range", "15m"); // Label + string value row
|
||||
builder.AddImage(iconSprite, 64f); // Sprite with optional height
|
||||
builder.AddSeparator(); // Horizontal divider line
|
||||
```
|
||||
|
||||
### Full example
|
||||
|
||||
```csharp
|
||||
popup.Show(PopupCategory.Skill, builder => {
|
||||
builder
|
||||
.AddHeader("Fireball")
|
||||
.AddText("Hurls a ball of fire that explodes on impact.")
|
||||
.AddSeparator()
|
||||
.AddStat("Damage", 120)
|
||||
.AddStat("Mana Cost", 35)
|
||||
.AddStat("Range", "15m")
|
||||
.AddSeparator()
|
||||
.AddText("Requires: Level 5", "FF6666");
|
||||
}, targetRect);
|
||||
```
|
||||
|
||||
All elements are drawn from a grow-only pool inside `PopupView`. No allocations occur once the pool is warmed.
|
||||
|
||||
## PopupTrigger
|
||||
|
||||
`PopupTrigger` is a MonoBehaviour you attach to UI elements to get hover-based popup behavior automatically. It implements `IPointerEnterHandler` and `IPointerExitHandler`.
|
||||
|
||||
### Inspector fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `category` | PopupCategory | Which category channel to use. |
|
||||
| `anchorSide` | AnchorSide | Which side of this element to anchor the popup. |
|
||||
| `positionMode` | PopupPositionMode | `AnchorToElement` or `FollowMouse`. |
|
||||
|
||||
### Initialization
|
||||
|
||||
`PopupTrigger` must be initialized from code before it will respond to pointer events:
|
||||
|
||||
```csharp
|
||||
var trigger = button.GetComponent<PopupTrigger>();
|
||||
trigger.Initialize(popupSystem, builder => {
|
||||
builder
|
||||
.AddHeader("Attack")
|
||||
.AddStat("Damage", 25);
|
||||
});
|
||||
```
|
||||
|
||||
You can also override the category at initialization time:
|
||||
|
||||
```csharp
|
||||
trigger.Initialize(popupSystem, PopupCategory.Skill, builder => {
|
||||
builder.AddHeader("Fireball");
|
||||
});
|
||||
```
|
||||
|
||||
To change content without re-initializing:
|
||||
|
||||
```csharp
|
||||
trigger.UpdateContent(builder => {
|
||||
builder
|
||||
.AddHeader("Attack")
|
||||
.AddStat("Damage", newDamageValue);
|
||||
});
|
||||
```
|
||||
|
||||
## Code-Only Triggers
|
||||
|
||||
You do not need `PopupTrigger` to show popups. Call `IPopupSystem` methods directly:
|
||||
|
||||
### Anchor to a RectTransform
|
||||
|
||||
```csharp
|
||||
popupSystem.Show(PopupCategory.Item, builder => {
|
||||
builder.AddHeader("Iron Sword");
|
||||
}, inventorySlotRect, AnchorSide.Right);
|
||||
```
|
||||
|
||||
### Follow the mouse cursor
|
||||
|
||||
Pass no anchor argument:
|
||||
|
||||
```csharp
|
||||
popupSystem.Show(PopupCategory.General, builder => {
|
||||
builder.AddText("Click to interact");
|
||||
});
|
||||
```
|
||||
|
||||
### Show at a fixed screen position
|
||||
|
||||
```csharp
|
||||
popupSystem.ShowAtPosition(PopupCategory.General, builder => {
|
||||
builder.AddText("Tutorial tip");
|
||||
}, new Vector2(Screen.width * 0.5f, Screen.height * 0.5f));
|
||||
```
|
||||
|
||||
### Hide
|
||||
|
||||
```csharp
|
||||
popupSystem.Hide(PopupCategory.Item); // Hide a specific category
|
||||
popupSystem.HideAll(); // Hide all categories
|
||||
```
|
||||
|
||||
## Priority System
|
||||
|
||||
When a popup is shown, any visible popup from a lower-priority category is automatically dismissed. Priority is configured per category via `PopupSettings.categoryPriorities` or at registration time:
|
||||
|
||||
```csharp
|
||||
popup.RegisterCategory(PopupCategory.Character, priority: 10);
|
||||
popup.RegisterCategory(PopupCategory.Item, priority: 5);
|
||||
popup.RegisterCategory(PopupCategory.General, priority: 1);
|
||||
```
|
||||
|
||||
If the player hovers a character portrait (priority 10) while an item tooltip (priority 5) is visible, the item tooltip is dismissed. Popups of equal or higher priority are not affected.
|
||||
|
||||
Settings-based priorities (from `PopupSettings.categoryPriorities`) take precedence over the `priority` argument in `RegisterCategory`.
|
||||
|
||||
## Lifecycle Integration
|
||||
|
||||
The popup system is designed to be created per game state, not as a global singleton. Each game state owns its own `IPopupSystem` instance:
|
||||
|
||||
```csharp
|
||||
// In your game state constructor or initialization:
|
||||
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab);
|
||||
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
|
||||
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
|
||||
|
||||
// Pass to views/play modes via constructor DI
|
||||
|
||||
// In your game state's Tick/Update:
|
||||
popupSystem.Tick(Time.deltaTime);
|
||||
|
||||
// When the game state exits:
|
||||
popupSystem.Dispose(); // destroys all popup view GameObjects
|
||||
```
|
||||
|
||||
Each category lazily creates its own `PopupView` instance on first `Show` call. On `Dispose`, all views are destroyed. This ensures no leaked GameObjects when transitioning between game states.
|
||||
|
||||
## IPopupAnimator
|
||||
|
||||
The popup system uses `IPopupAnimator` for show/hide transitions. The default implementation is `FadePopupAnimator`, which lerps the `CanvasGroup.alpha` over the configured `fadeDuration`.
|
||||
|
||||
### Interface
|
||||
|
||||
```csharp
|
||||
public interface IPopupAnimator {
|
||||
void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Tick(float deltaTime);
|
||||
bool IsAnimating { get; }
|
||||
}
|
||||
```
|
||||
|
||||
### Custom animator example
|
||||
|
||||
```csharp
|
||||
public sealed class ScalePopupAnimator : IPopupAnimator {
|
||||
private CanvasGroup target;
|
||||
private float duration;
|
||||
private float elapsed;
|
||||
private float startScale;
|
||||
private float endScale;
|
||||
private Action onComplete;
|
||||
|
||||
public bool IsAnimating => target != null;
|
||||
|
||||
public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) {
|
||||
target = canvasGroup;
|
||||
this.duration = Mathf.Max(duration, 0.001f);
|
||||
elapsed = 0f;
|
||||
startScale = 0f;
|
||||
endScale = 1f;
|
||||
this.onComplete = onComplete;
|
||||
canvasGroup.transform.localScale = Vector3.zero;
|
||||
canvasGroup.alpha = 1f;
|
||||
}
|
||||
|
||||
public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
|
||||
target = canvasGroup;
|
||||
this.duration = Mathf.Max(duration, 0.001f);
|
||||
elapsed = 0f;
|
||||
startScale = 1f;
|
||||
endScale = 0f;
|
||||
this.onComplete = onComplete;
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime) {
|
||||
if(target == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
elapsed += deltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / duration);
|
||||
var scale = Mathf.Lerp(startScale, endScale, t);
|
||||
target.transform.localScale = Vector3.one * scale;
|
||||
|
||||
if(t >= 1f) {
|
||||
var callback = onComplete;
|
||||
target = null;
|
||||
onComplete = null;
|
||||
callback?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pass a factory function to the constructor so each category gets its own animator instance:
|
||||
|
||||
```csharp
|
||||
var popup = new PopupSystem(settings, viewPrefab, () => new ScalePopupAnimator());
|
||||
```
|
||||
|
||||
## Prefab Setup
|
||||
|
||||
Build the `PopupView` prefab with the following hierarchy:
|
||||
|
||||
### Step 1: Root GameObject
|
||||
|
||||
1. Create a new GameObject named `PopupView`.
|
||||
2. Add a `Canvas` component. Set **Render Mode** to **Screen Space - Overlay**. Set **Sort Order** to match `PopupSettings.sortingOrder` (default 100).
|
||||
3. Add a `CanvasGroup` component.
|
||||
4. Add a `PopupView` component (from `Jovian.PopupSystem.UI`).
|
||||
|
||||
### Step 2: Background
|
||||
|
||||
1. Create a child GameObject named `Background`.
|
||||
2. Add a `RectTransform` and an `Image` component. Set the image color/sprite to your desired popup background.
|
||||
3. Add a `ContentSizeFitter` with **Vertical Fit** set to **Preferred Size**.
|
||||
|
||||
### Step 3: Content container
|
||||
|
||||
1. Create a child of `Background` named `Content`.
|
||||
2. Add a `RectTransform`. Stretch it to fill the background with appropriate padding.
|
||||
3. Add a `VerticalLayoutGroup`. Configure padding and spacing to taste.
|
||||
4. Add a `ContentSizeFitter` with **Vertical Fit** set to **Preferred Size**.
|
||||
|
||||
### Step 4: Element prefabs
|
||||
|
||||
Create these as child prefabs (or separate prefabs). Each must be a prefab reference, not an in-hierarchy instance:
|
||||
|
||||
| Prefab | Component | Notes |
|
||||
|---|---|---|
|
||||
| Header | `TMP_Text` | Bold, larger font size |
|
||||
| Text | `TMP_Text` | Regular body text |
|
||||
| Stat | `RectTransform` with `HorizontalLayoutGroup` | Must have exactly two `TMP_Text` children: label and value |
|
||||
| Image | `Image` | For icons or artwork |
|
||||
| Separator | `Image` | Thin horizontal line |
|
||||
|
||||
### Step 5: Wire references
|
||||
|
||||
On the `PopupView` component, assign:
|
||||
|
||||
- **Content** - the Content RectTransform
|
||||
- **Canvas Group** - the root CanvasGroup
|
||||
- **Background** - the Background RectTransform
|
||||
- **Header Prefab** - your header TMP_Text prefab
|
||||
- **Text Prefab** - your body text TMP_Text prefab
|
||||
- **Stat Prefab** - your stat row RectTransform prefab
|
||||
- **Image Prefab** - your image prefab
|
||||
- **Separator Prefab** - your separator Image prefab
|
||||
|
||||
Save as a prefab. The `maxPopupWidth` from `PopupSettings` is applied at runtime to constrain the popup's horizontal size.
|
||||
|
||||
## Optimization Notes
|
||||
|
||||
The popup system is designed for minimal runtime allocation:
|
||||
|
||||
- `PopupCategory` is a readonly struct with value semantics -- zero heap allocation on pass or comparison
|
||||
- `PopupContentBuilder` is a struct that operates directly on cached UI elements -- no intermediate data structures
|
||||
- Content elements use a **grow-only cache**: elements are activated/deactivated, never instantiated or destroyed after warmup. `GetStat` caches TMP_Text references in a struct to avoid `GetComponentsInChildren` on reuse
|
||||
- Delay timers and animation are driven by `float` fields in `Tick()` -- no coroutines, no `WaitForSeconds`
|
||||
- Screen rect calculation uses a static `Vector3[4]` buffer -- no per-frame array allocation
|
||||
- Layout rebuilds only occur when content changes (dirty flag), not on every position update
|
||||
- Each registered category gets its own `IPopupAnimator` instance, preventing state corruption during concurrent show/hide animations
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Types
|
||||
|
||||
| Type | Namespace | Description |
|
||||
|---|---|---|
|
||||
| `IPopupSystem` | `Jovian.PopupSystem` | Main interface for showing/hiding popups. |
|
||||
| `PopupSystem` | `Jovian.PopupSystem` | Concrete implementation. Constructor: `(PopupSettings, PopupView, Func<IPopupAnimator>)`. |
|
||||
| `PopupSettings` | `Jovian.PopupSystem` | ScriptableObject with all configuration fields. |
|
||||
| `PopupCategory` | `Jovian.PopupSystem` | Readonly struct identifying a popup channel. |
|
||||
| `PopupContentBuilder` | `Jovian.PopupSystem` | Fluent struct for building popup content in callbacks. |
|
||||
|
||||
### UI Types
|
||||
|
||||
| Type | Namespace | Description |
|
||||
|---|---|---|
|
||||
| `PopupView` | `Jovian.PopupSystem.UI` | MonoBehaviour managing popup layout, pooling, and positioning. |
|
||||
| `PopupTrigger` | `Jovian.PopupSystem.UI` | MonoBehaviour for hover-based popup triggers on UI elements. |
|
||||
|
||||
### Animation Types
|
||||
|
||||
| Type | Namespace | Description |
|
||||
|---|---|---|
|
||||
| `IPopupAnimator` | `Jovian.PopupSystem` | Interface for custom show/hide animations. |
|
||||
| `FadePopupAnimator` | `Jovian.PopupSystem` | Default fade animation via CanvasGroup alpha. |
|
||||
|
||||
### Enums
|
||||
|
||||
| Type | Values |
|
||||
|---|---|
|
||||
| `AnchorSide` | `Below`, `Above`, `Left`, `Right` |
|
||||
| `PopupPositionMode` | `AnchorToElement`, `FollowMouse` |
|
||||
|
||||
## License
|
||||
|
||||
See the LICENSE file in the package root.
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public sealed class FadePopupAnimator : IPopupAnimator {
|
||||
private CanvasGroup target;
|
||||
private float duration;
|
||||
private float elapsed;
|
||||
private float startAlpha;
|
||||
private float endAlpha;
|
||||
private Action onComplete;
|
||||
|
||||
public bool IsAnimating => target != null;
|
||||
|
||||
public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) {
|
||||
target = canvasGroup;
|
||||
this.duration = Mathf.Max(duration, 0.001f);
|
||||
elapsed = 0f;
|
||||
startAlpha = 0f;
|
||||
endAlpha = 1f;
|
||||
this.onComplete = onComplete;
|
||||
canvasGroup.alpha = 0f;
|
||||
}
|
||||
|
||||
public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
|
||||
target = canvasGroup;
|
||||
this.duration = Mathf.Max(duration, 0.001f);
|
||||
elapsed = 0f;
|
||||
startAlpha = canvasGroup.alpha;
|
||||
endAlpha = 0f;
|
||||
this.onComplete = onComplete;
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime) {
|
||||
if(target == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
elapsed += deltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / duration);
|
||||
target.alpha = Mathf.Lerp(startAlpha, endAlpha, t);
|
||||
|
||||
if(t >= 1f) {
|
||||
var callback = onComplete;
|
||||
target = null;
|
||||
onComplete = null;
|
||||
callback?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs
Normal file
11
Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public interface IPopupAnimator {
|
||||
void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Tick(float deltaTime);
|
||||
bool IsAnimating { get; }
|
||||
}
|
||||
}
|
||||
16
Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs
Normal file
16
Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public interface IPopupSystem {
|
||||
void RegisterCategory(PopupCategory category, int priority = 0);
|
||||
void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
RectTransform anchor = null, AnchorSide? anchorSide = null);
|
||||
void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
Vector2 screenPosition);
|
||||
void Hide(PopupCategory category);
|
||||
void HideAll();
|
||||
void Tick(float deltaTime);
|
||||
void Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Jovian.PopupSystem",
|
||||
"rootNamespace": "Jovian.PopupSystem",
|
||||
"references": [
|
||||
"Unity.TextMeshPro",
|
||||
"Unity.InputSystem"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"Newtonsoft.Json.dll"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
44
Packages/com.jovian.popup-system/Runtime/PopupCategory.cs
Normal file
44
Packages/com.jovian.popup-system/Runtime/PopupCategory.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
[Serializable]
|
||||
public readonly struct PopupCategory : IEquatable<PopupCategory> {
|
||||
[SerializeField] readonly string id;
|
||||
|
||||
public string Id => id;
|
||||
|
||||
public PopupCategory(string id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public static readonly PopupCategory Character = new("Character");
|
||||
public static readonly PopupCategory Item = new("Item");
|
||||
public static readonly PopupCategory Skill = new("Skill");
|
||||
public static readonly PopupCategory General = new("General");
|
||||
|
||||
public bool Equals(PopupCategory other) {
|
||||
return string.Equals(id, other.id, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) {
|
||||
return obj is PopupCategory other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return id != null ? id.GetHashCode() : 0;
|
||||
}
|
||||
|
||||
public override string ToString() {
|
||||
return id ?? string.Empty;
|
||||
}
|
||||
|
||||
public static bool operator ==(PopupCategory left, PopupCategory right) {
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(PopupCategory left, PopupCategory right) {
|
||||
return !left.Equals(right);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public sealed class PopupCategoryJsonConverter : JsonConverter<PopupCategory> {
|
||||
public override void WriteJson(JsonWriter writer, PopupCategory value, JsonSerializer serializer) {
|
||||
writer.WriteValue(value.Id);
|
||||
}
|
||||
|
||||
public override PopupCategory ReadJson(JsonReader reader, Type objectType, PopupCategory existingValue, bool hasExistingValue, JsonSerializer serializer) {
|
||||
var id = reader.Value as string;
|
||||
return new PopupCategory(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Jovian.PopupSystem.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public struct PopupContentBuilder {
|
||||
readonly PopupView view;
|
||||
|
||||
public PopupContentBuilder(PopupView view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddHeader(string text) {
|
||||
var header = view.GetHeader();
|
||||
header.text = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddText(string text) {
|
||||
var label = view.GetText();
|
||||
label.text = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddText(string text, string hexColor) {
|
||||
var label = view.GetText();
|
||||
var prefix = hexColor.Length > 0 && hexColor[0] == '#' ? "" : "#";
|
||||
label.text = $"<color={prefix}{hexColor}>{text}</color>";
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddStat(string label, int value) {
|
||||
var (labelText, valueText) = view.GetStat();
|
||||
labelText.text = label;
|
||||
valueText.text = value.ToString();
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddStat(string label, string value) {
|
||||
var (labelText, valueText) = view.GetStat();
|
||||
labelText.text = label;
|
||||
valueText.text = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddImage(Sprite sprite, float height = 64f) {
|
||||
var image = view.GetImage();
|
||||
image.sprite = sprite;
|
||||
var rt = (RectTransform)image.transform;
|
||||
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddSeparator() {
|
||||
view.GetSeparator();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Packages/com.jovian.popup-system/Runtime/PopupEnums.cs
Normal file
13
Packages/com.jovian.popup-system/Runtime/PopupEnums.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Jovian.PopupSystem {
|
||||
public enum AnchorSide {
|
||||
Below,
|
||||
Above,
|
||||
Left,
|
||||
Right
|
||||
}
|
||||
|
||||
public enum PopupPositionMode {
|
||||
AnchorToElement,
|
||||
FollowMouse
|
||||
}
|
||||
}
|
||||
59
Packages/com.jovian.popup-system/Runtime/PopupSettings.cs
Normal file
59
Packages/com.jovian.popup-system/Runtime/PopupSettings.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
[CreateAssetMenu(fileName = "PopupSettings", menuName = "Jovian/Popup System/Popup Settings")]
|
||||
public class PopupSettings : ScriptableObject {
|
||||
[Header("General")]
|
||||
public float popupDelay = 0.4f;
|
||||
public float fadeDuration = 0.2f;
|
||||
public AnchorSide defaultAnchorSide = AnchorSide.Below;
|
||||
public float screenEdgePadding = 10f;
|
||||
public float maxPopupWidth = 400f;
|
||||
public int sortingOrder = 100;
|
||||
|
||||
[Header("Follow Mouse")]
|
||||
public Vector2 followMouseOffset = new(15f, -15f);
|
||||
|
||||
[Header("Input")]
|
||||
public float touchHoldDuration = 0.6f;
|
||||
public bool gamepadFocusTrigger = true;
|
||||
|
||||
[Header("Priority")]
|
||||
public List<CategoryPriority> categoryPriorities = new();
|
||||
|
||||
[Header("Per-Category Overrides")]
|
||||
public List<CategoryDelay> categoryDelayOverrides = new();
|
||||
|
||||
public int GetPriority(PopupCategory category) {
|
||||
foreach(var cp in categoryPriorities) {
|
||||
if(cp.category == category) {
|
||||
return cp.priority;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public float GetDelay(PopupCategory category) {
|
||||
foreach(var cd in categoryDelayOverrides) {
|
||||
if(cd.category == category) {
|
||||
return cd.delay;
|
||||
}
|
||||
}
|
||||
return popupDelay;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class CategoryPriority {
|
||||
public PopupCategory category;
|
||||
public int priority;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class CategoryDelay {
|
||||
public PopupCategory category;
|
||||
public float delay;
|
||||
}
|
||||
}
|
||||
187
Packages/com.jovian.popup-system/Runtime/PopupSystem.cs
Normal file
187
Packages/com.jovian.popup-system/Runtime/PopupSystem.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jovian.PopupSystem.UI;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public sealed class PopupSystem : IPopupSystem {
|
||||
readonly PopupSettings settings;
|
||||
readonly PopupView viewPrefab;
|
||||
readonly Func<IPopupAnimator> animatorFactory;
|
||||
readonly Dictionary<PopupCategory, ViewState> categories = new();
|
||||
|
||||
public PopupSystem(PopupSettings settings, PopupView viewPrefab, Func<IPopupAnimator> animatorFactory = null) {
|
||||
this.settings = settings;
|
||||
this.viewPrefab = viewPrefab;
|
||||
this.animatorFactory = animatorFactory ?? (() => new FadePopupAnimator());
|
||||
}
|
||||
|
||||
public void RegisterCategory(PopupCategory category, int priority = 0) {
|
||||
if(categories.ContainsKey(category)) {
|
||||
return;
|
||||
}
|
||||
var effectivePriority = settings.GetPriority(category);
|
||||
if(effectivePriority == 0) {
|
||||
effectivePriority = priority;
|
||||
}
|
||||
categories[category] = new ViewState {
|
||||
priority = effectivePriority,
|
||||
delay = settings.GetDelay(category),
|
||||
animator = animatorFactory()
|
||||
};
|
||||
}
|
||||
|
||||
public void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
RectTransform anchor = null, AnchorSide? anchorSide = null) {
|
||||
if(!categories.TryGetValue(category, out var state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DismissLowerPriority(state.priority);
|
||||
|
||||
state.pendingBuild = buildContent;
|
||||
state.pendingAnchor = anchor;
|
||||
state.pendingAnchorSide = anchorSide ?? settings.defaultAnchorSide;
|
||||
state.pendingScreenPos = null;
|
||||
state.delayTimer = state.delay;
|
||||
state.isPending = true;
|
||||
}
|
||||
|
||||
public void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
Vector2 screenPosition) {
|
||||
if(!categories.TryGetValue(category, out var state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DismissLowerPriority(state.priority);
|
||||
|
||||
state.pendingBuild = buildContent;
|
||||
state.pendingAnchor = null;
|
||||
state.pendingScreenPos = screenPosition;
|
||||
state.delayTimer = state.delay;
|
||||
state.isPending = true;
|
||||
}
|
||||
|
||||
public void Hide(PopupCategory category) {
|
||||
if(!categories.TryGetValue(category, out var state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.isPending = false;
|
||||
if(state.view != null && state.view.IsVisible) {
|
||||
state.animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
|
||||
state.view.SetVisible(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void HideAll() {
|
||||
foreach(var kvp in categories) {
|
||||
Hide(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime) {
|
||||
foreach(var kvp in categories) {
|
||||
kvp.Value.animator.Tick(deltaTime);
|
||||
}
|
||||
|
||||
foreach(var kvp in categories) {
|
||||
var state = kvp.Value;
|
||||
if(!state.isPending) {
|
||||
continue;
|
||||
}
|
||||
|
||||
state.delayTimer -= deltaTime;
|
||||
if(state.delayTimer > 0f) {
|
||||
continue;
|
||||
}
|
||||
|
||||
state.isPending = false;
|
||||
ShowImmediate(state);
|
||||
}
|
||||
|
||||
foreach(var kvp in categories) {
|
||||
var state = kvp.Value;
|
||||
if(state.view != null && state.view.IsVisible && state.isFollowMouse) {
|
||||
state.view.UpdatePosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
foreach(var kvp in categories) {
|
||||
if(kvp.Value.view != null) {
|
||||
Object.Destroy(kvp.Value.view.gameObject);
|
||||
}
|
||||
}
|
||||
categories.Clear();
|
||||
}
|
||||
|
||||
private void ShowImmediate(ViewState state) {
|
||||
EnsureView(state);
|
||||
state.view.ClearContent();
|
||||
|
||||
var builder = new PopupContentBuilder(state.view);
|
||||
state.pendingBuild?.Invoke(builder);
|
||||
|
||||
if(state.pendingScreenPos.HasValue) {
|
||||
state.view.SetFixedPosition(state.pendingScreenPos.Value, settings.screenEdgePadding);
|
||||
state.isFollowMouse = false;
|
||||
}
|
||||
else if(state.pendingAnchor != null) {
|
||||
state.view.SetAnchorMode(state.pendingAnchor, state.pendingAnchorSide, settings.screenEdgePadding);
|
||||
state.isFollowMouse = false;
|
||||
}
|
||||
else {
|
||||
state.view.SetFollowMouseMode(settings.followMouseOffset, settings.screenEdgePadding);
|
||||
state.isFollowMouse = true;
|
||||
}
|
||||
|
||||
state.view.SetVisible(true);
|
||||
state.view.UpdatePosition();
|
||||
state.animator.Show(state.view.CanvasGroup, settings.fadeDuration, null);
|
||||
}
|
||||
|
||||
private void EnsureView(ViewState state) {
|
||||
if(state.view != null) {
|
||||
return;
|
||||
}
|
||||
state.view = Object.Instantiate(viewPrefab);
|
||||
state.view.SetVisible(false);
|
||||
state.view.SetMaxWidth(settings.maxPopupWidth);
|
||||
|
||||
var canvas = state.view.GetComponent<Canvas>();
|
||||
if(canvas != null) {
|
||||
canvas.sortingOrder = settings.sortingOrder;
|
||||
}
|
||||
}
|
||||
|
||||
private void DismissLowerPriority(int showingPriority) {
|
||||
foreach(var kvp in categories) {
|
||||
var state = kvp.Value;
|
||||
if(state.priority < showingPriority && state.view != null && state.view.IsVisible) {
|
||||
state.isPending = false;
|
||||
state.animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
|
||||
state.view.SetVisible(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ViewState {
|
||||
public PopupView view;
|
||||
public IPopupAnimator animator;
|
||||
public int priority;
|
||||
public float delay;
|
||||
public float delayTimer;
|
||||
public bool isPending;
|
||||
public bool isFollowMouse;
|
||||
public Action<PopupContentBuilder> pendingBuild;
|
||||
public RectTransform pendingAnchor;
|
||||
public AnchorSide pendingAnchorSide;
|
||||
public Vector2? pendingScreenPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs
Normal file
54
Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace Jovian.PopupSystem.UI {
|
||||
public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
|
||||
[SerializeField] PopupCategory category;
|
||||
[SerializeField] AnchorSide anchorSide = AnchorSide.Below;
|
||||
[SerializeField] PopupPositionMode positionMode = PopupPositionMode.AnchorToElement;
|
||||
|
||||
IPopupSystem popupSystem;
|
||||
Action<PopupContentBuilder> contentCallback;
|
||||
bool initialized;
|
||||
|
||||
public PopupCategory Category => category;
|
||||
|
||||
public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> contentCallback) {
|
||||
this.popupSystem = popupSystem;
|
||||
this.contentCallback = contentCallback;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public void Initialize(IPopupSystem popupSystem, PopupCategory category, Action<PopupContentBuilder> contentCallback) {
|
||||
this.popupSystem = popupSystem;
|
||||
this.category = category;
|
||||
this.contentCallback = contentCallback;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public void UpdateContent(Action<PopupContentBuilder> contentCallback) {
|
||||
this.contentCallback = contentCallback;
|
||||
}
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData) {
|
||||
if(!initialized || popupSystem == null || contentCallback == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(positionMode == PopupPositionMode.AnchorToElement) {
|
||||
popupSystem.Show(category, contentCallback, (RectTransform)transform, anchorSide);
|
||||
}
|
||||
else {
|
||||
popupSystem.Show(category, contentCallback);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData) {
|
||||
if(!initialized || popupSystem == null) {
|
||||
return;
|
||||
}
|
||||
popupSystem.Hide(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
214
Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs
Normal file
214
Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Jovian.PopupSystem.UI {
|
||||
public class PopupView : MonoBehaviour {
|
||||
[SerializeField] RectTransform content;
|
||||
[SerializeField] CanvasGroup canvasGroup;
|
||||
[SerializeField] RectTransform background;
|
||||
|
||||
[Header("Element Prefabs")]
|
||||
[SerializeField] TMP_Text headerPrefab;
|
||||
[SerializeField] TMP_Text textPrefab;
|
||||
[SerializeField] RectTransform statPrefab; // horizontal layout with label + value TMP_Text children
|
||||
[SerializeField] Image imagePrefab;
|
||||
[SerializeField] Image separatorPrefab;
|
||||
|
||||
// Element caches (grow-only)
|
||||
readonly List<TMP_Text> headerCache = new();
|
||||
readonly List<TMP_Text> textCache = new();
|
||||
readonly List<StatEntry> statCache = new();
|
||||
readonly List<Image> imageCache = new();
|
||||
readonly List<Image> separatorCache = new();
|
||||
|
||||
struct StatEntry {
|
||||
public RectTransform root;
|
||||
public TMP_Text label;
|
||||
public TMP_Text value;
|
||||
}
|
||||
|
||||
int headerIndex;
|
||||
int textIndex;
|
||||
int statIndex;
|
||||
int imageIndex;
|
||||
int separatorIndex;
|
||||
|
||||
// Positioning state
|
||||
PopupPositionMode positionMode;
|
||||
RectTransform anchorTarget;
|
||||
AnchorSide anchorSide;
|
||||
Vector2 followOffset;
|
||||
float screenEdgePadding;
|
||||
float maxWidth;
|
||||
bool isVisible;
|
||||
bool layoutDirty;
|
||||
|
||||
public CanvasGroup CanvasGroup => canvasGroup;
|
||||
public RectTransform Content => content;
|
||||
public bool IsVisible => isVisible;
|
||||
|
||||
public void SetMaxWidth(float maxPopupWidth) {
|
||||
maxWidth = maxPopupWidth;
|
||||
if(maxWidth > 0f) {
|
||||
var rt = (RectTransform)transform;
|
||||
var size = rt.sizeDelta;
|
||||
size.x = Mathf.Min(size.x, maxWidth);
|
||||
rt.sizeDelta = size;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetVisible(bool visible) {
|
||||
isVisible = visible;
|
||||
gameObject.SetActive(visible);
|
||||
if(!visible) {
|
||||
canvasGroup.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearContent() {
|
||||
DeactivateRange(headerCache, ref headerIndex);
|
||||
DeactivateRange(textCache, ref textIndex);
|
||||
for(int i = 0; i < statIndex; i++) {
|
||||
statCache[i].root.gameObject.SetActive(false);
|
||||
}
|
||||
statIndex = 0;
|
||||
DeactivateRange(imageCache, ref imageIndex);
|
||||
DeactivateRange(separatorCache, ref separatorIndex);
|
||||
layoutDirty = true;
|
||||
}
|
||||
|
||||
private static void DeactivateRange<T>(List<T> cache, ref int index) where T : Component {
|
||||
for(int i = 0; i < index; i++) {
|
||||
cache[i].gameObject.SetActive(false);
|
||||
}
|
||||
index = 0;
|
||||
}
|
||||
|
||||
// --- Element access (grow-only) ---
|
||||
|
||||
public TMP_Text GetHeader() {
|
||||
return GetOrCreate(headerCache, headerPrefab, ref headerIndex);
|
||||
}
|
||||
|
||||
public TMP_Text GetText() {
|
||||
return GetOrCreate(textCache, textPrefab, ref textIndex);
|
||||
}
|
||||
|
||||
public (TMP_Text label, TMP_Text value) GetStat() {
|
||||
if(statIndex < statCache.Count) {
|
||||
var existing = statCache[statIndex];
|
||||
existing.root.gameObject.SetActive(true);
|
||||
existing.root.SetAsLastSibling();
|
||||
statIndex++;
|
||||
return (existing.label, existing.value);
|
||||
}
|
||||
|
||||
var created = Instantiate(statPrefab, content);
|
||||
created.gameObject.SetActive(true);
|
||||
created.SetAsLastSibling();
|
||||
var children = created.GetComponentsInChildren<TMP_Text>(true);
|
||||
var entry = new StatEntry { root = created, label = children[0], value = children[1] };
|
||||
statCache.Add(entry);
|
||||
statIndex++;
|
||||
return (entry.label, entry.value);
|
||||
}
|
||||
|
||||
public Image GetImage() {
|
||||
return GetOrCreate(imageCache, imagePrefab, ref imageIndex);
|
||||
}
|
||||
|
||||
public Image GetSeparator() {
|
||||
return GetOrCreate(separatorCache, separatorPrefab, ref separatorIndex);
|
||||
}
|
||||
|
||||
private T GetOrCreate<T>(List<T> cache, T prefab, ref int index) where T : Component {
|
||||
if(index < cache.Count) {
|
||||
var existing = cache[index];
|
||||
existing.gameObject.SetActive(true);
|
||||
existing.transform.SetAsLastSibling();
|
||||
index++;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = Instantiate(prefab, content);
|
||||
created.gameObject.SetActive(true);
|
||||
created.transform.SetAsLastSibling();
|
||||
cache.Add(created);
|
||||
index++;
|
||||
return created;
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
|
||||
positionMode = PopupPositionMode.AnchorToElement;
|
||||
anchorTarget = target;
|
||||
anchorSide = side;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
|
||||
positionMode = PopupPositionMode.FollowMouse;
|
||||
followOffset = offset;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
public void SetFixedPosition(Vector2 screenPos, float edgePadding) {
|
||||
positionMode = PopupPositionMode.AnchorToElement;
|
||||
anchorTarget = null;
|
||||
screenEdgePadding = edgePadding;
|
||||
PositionAtScreenPoint(screenPos);
|
||||
}
|
||||
|
||||
public void UpdatePosition() {
|
||||
if(positionMode == PopupPositionMode.FollowMouse) {
|
||||
PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
|
||||
}
|
||||
else if(anchorTarget != null) {
|
||||
PositionAnchoredTo(anchorTarget, anchorSide);
|
||||
}
|
||||
}
|
||||
|
||||
private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
|
||||
var targetRect = GetScreenRect(target);
|
||||
var popupSize = GetScreenRect((RectTransform)transform).size;
|
||||
|
||||
var pos = side switch {
|
||||
AnchorSide.Below => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMin - popupSize.y),
|
||||
AnchorSide.Above => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMax),
|
||||
AnchorSide.Left => new Vector2(targetRect.xMin - popupSize.x, targetRect.center.y - popupSize.y * 0.5f),
|
||||
AnchorSide.Right => new Vector2(targetRect.xMax, targetRect.center.y - popupSize.y * 0.5f),
|
||||
_ => targetRect.center
|
||||
};
|
||||
|
||||
PositionAtScreenPoint(pos);
|
||||
}
|
||||
|
||||
private void PositionAtScreenPoint(Vector2 screenPos) {
|
||||
var rt = (RectTransform)transform;
|
||||
if(layoutDirty) {
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
|
||||
layoutDirty = false;
|
||||
}
|
||||
var popupSize = rt.rect.size;
|
||||
|
||||
// Clamp to screen
|
||||
screenPos.x = Mathf.Clamp(screenPos.x, screenEdgePadding, Screen.width - popupSize.x - screenEdgePadding);
|
||||
screenPos.y = Mathf.Clamp(screenPos.y, screenEdgePadding, Screen.height - popupSize.y - screenEdgePadding);
|
||||
|
||||
rt.position = screenPos;
|
||||
}
|
||||
|
||||
private static readonly Vector3[] cornersBuffer = new Vector3[4];
|
||||
|
||||
private static Rect GetScreenRect(RectTransform rt) {
|
||||
rt.GetWorldCorners(cornersBuffer);
|
||||
var min = new Vector2(cornersBuffer[0].x, cornersBuffer[0].y);
|
||||
var max = new Vector2(cornersBuffer[2].x, cornersBuffer[2].y);
|
||||
return new Rect(min, max - min);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Packages/com.jovian.popup-system/Samples~/README.md
Normal file
3
Packages/com.jovian.popup-system/Samples~/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Samples
|
||||
|
||||
This folder is reserved for sample scenes and scripts demonstrating the Popup System.
|
||||
20
Packages/com.jovian.popup-system/package.json
Normal file
20
Packages/com.jovian.popup-system/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "com.jovian.popup-system",
|
||||
"version": "0.1.0",
|
||||
"displayName": "Jovian Popup System",
|
||||
"description": "A lightweight, low-allocation popup and tooltip system with category-based isolation, fluent content builder, and extensible animations.",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.unity.textmeshpro": "3.0.6",
|
||||
"com.unity.inputsystem": "1.18.0",
|
||||
"com.unity.nuget.newtonsoft-json": "3.2.1"
|
||||
},
|
||||
"keywords": [
|
||||
"popup",
|
||||
"tooltip",
|
||||
"ui"
|
||||
],
|
||||
"author": {
|
||||
"name": "Jovian"
|
||||
}
|
||||
}
|
||||
306
docs/plans/2026-04-06-popup-system-design.md
Normal file
306
docs/plans/2026-04-06-popup-system-design.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Popup System Design
|
||||
|
||||
**Package:** `com.jovian.popup-system`
|
||||
**Date:** 2026-04-06
|
||||
|
||||
## Purpose
|
||||
|
||||
A lightweight, low-allocation popup/tooltip system for Unity UGUI. Supports hover tooltips, anchored popups, and follow-mouse modes with dynamic content built via a fluent API. Per-game-state instances with category-based isolation and priority dismissal.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Game Code / PopupTrigger MonoBehaviour
|
||||
│ popupSystem.Show(category, builder, anchor)
|
||||
│ popupSystem.Hide(category)
|
||||
▼
|
||||
IPopupSystem (per-game-state instance, injected via constructor DI)
|
||||
│ RegisterCategory(PopupCategory, priority)
|
||||
│ One PopupView per category (lazy-created on first Show)
|
||||
│ Priority: higher priority category dismisses lower on show
|
||||
│ Tick()-driven delay timers and animations (no coroutines)
|
||||
▼
|
||||
PopupView (MonoBehaviour — one instance per registered category)
|
||||
│ Canvas + CanvasGroup for transitions
|
||||
│ Content parent (VerticalLayoutGroup + ContentSizeFitter)
|
||||
│ Grow-only element cache (reuse, never destroy)
|
||||
│ Screen edge clamping
|
||||
│ Anchor-to-element or follow-mouse positioning
|
||||
▼
|
||||
IPopupAnimator (extensible interface for show/hide transitions)
|
||||
│ Default: FadePopupAnimator (CanvasGroup.alpha lerp)
|
||||
│ Custom implementations for scale, slide, etc.
|
||||
▼
|
||||
PopupContentBuilder (struct, fluent API)
|
||||
.AddHeader(text) .AddText(text) .AddStat(label, value)
|
||||
.AddImage(sprite) .AddSeparator()
|
||||
→ activates pre-existing child elements in PopupView
|
||||
```
|
||||
|
||||
## Core Types
|
||||
|
||||
### PopupCategory (readonly struct)
|
||||
|
||||
Same pattern as LogChannel. Value type, string-keyed, zero-alloc comparisons.
|
||||
|
||||
```csharp
|
||||
[Serializable]
|
||||
public readonly struct PopupCategory : IEquatable<PopupCategory> {
|
||||
readonly string id;
|
||||
public string Id => id;
|
||||
public PopupCategory(string id) => this.id = id;
|
||||
|
||||
public static readonly PopupCategory Character = new("Character");
|
||||
public static readonly PopupCategory Item = new("Item");
|
||||
public static readonly PopupCategory Skill = new("Skill");
|
||||
public static readonly PopupCategory General = new("General");
|
||||
}
|
||||
```
|
||||
|
||||
### PopupSettings (ScriptableObject)
|
||||
|
||||
Full configuration loaded via Addressables.
|
||||
|
||||
```
|
||||
General:
|
||||
popupDelay float 0.4s Time before popup appears after hover
|
||||
fadeDuration float 0.2s Default animation duration
|
||||
defaultAnchorSide enum Below Below/Above/Left/Right
|
||||
screenEdgePadding float 10px Minimum distance from screen edge
|
||||
maxPopupWidth float 400px Maximum content width
|
||||
sortingOrder int 100 Canvas sorting order
|
||||
|
||||
Follow Mouse:
|
||||
followMouseOffset Vector2 (15, -15) Offset from cursor in follow mode
|
||||
|
||||
Input:
|
||||
touchHoldDuration float 0.6s Press-and-hold duration for touch
|
||||
gamepadFocusTrigger bool true Trigger on gamepad focus events
|
||||
|
||||
Priority:
|
||||
categoryPriorities List<CategoryPriority>
|
||||
- category: PopupCategory
|
||||
- priority: int Higher value = higher priority
|
||||
|
||||
Per-Category Overrides:
|
||||
categoryDelayOverrides List<CategoryDelay>
|
||||
- category: PopupCategory
|
||||
- delay: float
|
||||
```
|
||||
|
||||
### IPopupSystem (interface)
|
||||
|
||||
```csharp
|
||||
public interface IPopupSystem {
|
||||
void RegisterCategory(PopupCategory category, int priority = 0);
|
||||
void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
RectTransform anchor = null, AnchorSide? anchorSide = null);
|
||||
void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
Vector2 screenPosition);
|
||||
void Hide(PopupCategory category);
|
||||
void HideAll();
|
||||
void Tick(float deltaTime);
|
||||
void Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### PopupSystem (implementation)
|
||||
|
||||
- `Dictionary<PopupCategory, PopupViewState>` — O(1) lookup by category
|
||||
- `PopupViewState` holds: PopupView instance (null until first show), priority, delay timer, pending show data
|
||||
- `Tick(deltaTime)` drives delay countdown and animation lerp — no coroutines
|
||||
- On `Show()`:
|
||||
- If a higher-priority popup is already visible, queue or cancel
|
||||
- Start delay timer; on expiry, activate the view
|
||||
- PopupView clears content (deactivates cached elements), runs builder callback, positions, animates in
|
||||
- On `Hide()`:
|
||||
- Animate out, deactivate
|
||||
- On `Dispose()`:
|
||||
- Destroy all PopupView GameObjects
|
||||
|
||||
### PopupView (MonoBehaviour)
|
||||
|
||||
Single prefab, instantiated once per category:
|
||||
|
||||
```
|
||||
PopupView (Canvas, CanvasGroup, RectMask2D)
|
||||
├── Content (RectTransform, VerticalLayoutGroup, ContentSizeFitter)
|
||||
│ ├── [cached] HeaderElement (TMP_Text, deactivated)
|
||||
│ ├── [cached] TextElement x N (TMP_Text, deactivated)
|
||||
│ ├── [cached] StatElement x N (TMP_Text label + TMP_Text value, deactivated)
|
||||
│ ├── [cached] ImageElement x N (Image, deactivated)
|
||||
│ └── [cached] SeparatorElement x N (Image, deactivated)
|
||||
└── Background (Image, optional)
|
||||
```
|
||||
|
||||
**Grow-only element cache:**
|
||||
- Pre-creates a small set of each element type (e.g. 3 text, 2 stat, 1 image, 2 separator)
|
||||
- `GetOrCreateElement<T>(type)` returns next available deactivated element, or Instantiates if none free
|
||||
- New elements persist and are reused on next Show
|
||||
- `ClearContent()` deactivates all elements and resets the reuse index — no Destroy calls
|
||||
|
||||
**Positioning:**
|
||||
- `AnchorToElement(RectTransform target, AnchorSide side)` — positions relative to target element
|
||||
- `FollowMouse(Vector2 offset)` — updates position each Tick to track cursor
|
||||
- `ClampToScreen(float padding)` — adjusts position if popup overflows screen bounds, flips anchor side if needed
|
||||
|
||||
**Animation:**
|
||||
- Calls `IPopupAnimator.Show(canvasGroup, duration, onComplete)` and `Hide(...)`
|
||||
- Default `FadePopupAnimator` lerps `canvasGroup.alpha` 0→1 / 1→0 over duration
|
||||
- Driven by float timer in Tick, not coroutines
|
||||
|
||||
### PopupContentBuilder (struct)
|
||||
|
||||
```csharp
|
||||
public struct PopupContentBuilder {
|
||||
readonly PopupView view;
|
||||
|
||||
public PopupContentBuilder AddHeader(string text);
|
||||
public PopupContentBuilder AddText(string text);
|
||||
public PopupContentBuilder AddText(string text, string hexColor);
|
||||
public PopupContentBuilder AddStat(string label, int value);
|
||||
public PopupContentBuilder AddStat(string label, string value);
|
||||
public PopupContentBuilder AddImage(Sprite sprite, float height = 64f);
|
||||
public PopupContentBuilder AddSeparator();
|
||||
}
|
||||
```
|
||||
|
||||
Each method activates a cached element from PopupView, sets its data, calls `SetAsLastSibling()` for ordering. Returns `this` for chaining. No allocations.
|
||||
|
||||
### PopupTrigger (MonoBehaviour)
|
||||
|
||||
Attach to any UI element. Handles hover detection automatically.
|
||||
|
||||
```csharp
|
||||
public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
|
||||
[SerializeField] PopupCategory category;
|
||||
[SerializeField] AnchorSide anchorSide;
|
||||
[SerializeField] PopupPositionMode positionMode; // AnchorToElement or FollowMouse
|
||||
|
||||
IPopupSystem popupSystem;
|
||||
Action<PopupContentBuilder> contentCallback;
|
||||
|
||||
public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> contentCallback);
|
||||
|
||||
// IPointerEnterHandler — calls popupSystem.Show(category, contentCallback, rectTransform)
|
||||
// IPointerExitHandler — calls popupSystem.Hide(category)
|
||||
}
|
||||
```
|
||||
|
||||
- Category, anchor side, position mode configurable in Inspector
|
||||
- `Initialize()` called from code to inject the popup system and content builder callback
|
||||
- No per-frame cost when not hovered
|
||||
- The `contentCallback` is set once and reused — no allocation per hover
|
||||
|
||||
### IPopupAnimator (interface)
|
||||
|
||||
```csharp
|
||||
public interface IPopupAnimator {
|
||||
void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Tick(float deltaTime);
|
||||
bool IsAnimating { get; }
|
||||
}
|
||||
```
|
||||
|
||||
Default implementation: `FadePopupAnimator` — lerps alpha. Custom implementations can do scale, slide, bounce, etc.
|
||||
|
||||
### PopupCategoryJsonConverter
|
||||
|
||||
Newtonsoft converter for serializing PopupCategory as string id (same pattern as LogChannelJsonConverter).
|
||||
|
||||
## Enums
|
||||
|
||||
```csharp
|
||||
public enum AnchorSide { Below, Above, Left, Right }
|
||||
public enum PopupPositionMode { AnchorToElement, FollowMouse }
|
||||
```
|
||||
|
||||
## Optimization Summary
|
||||
|
||||
| Concern | Approach |
|
||||
|---------|----------|
|
||||
| Hot path (Show/Hide) | Struct category, dict lookup, no alloc |
|
||||
| Content elements | Grow-only cache, activate/deactivate, never Destroy |
|
||||
| Timers | Float countdown in Tick(), no coroutines |
|
||||
| Builder | Struct, operates directly on cached elements |
|
||||
| Per-frame | Zero alloc when idle; follow-mouse only reads Input position |
|
||||
| Trigger | IPointerEnter/Exit interfaces, no delegates allocated per hover |
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
Packages/com.jovian.popup-system/
|
||||
├── package.json
|
||||
├── README.md
|
||||
├── Runtime/
|
||||
│ ├── Jovian.PopupSystem.asmdef
|
||||
│ ├── PopupCategory.cs
|
||||
│ ├── PopupCategoryJsonConverter.cs
|
||||
│ ├── PopupSettings.cs
|
||||
│ ├── IPopupSystem.cs
|
||||
│ ├── PopupSystem.cs
|
||||
│ ├── IPopupAnimator.cs
|
||||
│ ├── FadePopupAnimator.cs
|
||||
│ ├── PopupContentBuilder.cs
|
||||
│ └── UI/
|
||||
│ ├── PopupView.cs
|
||||
│ └── PopupTrigger.cs
|
||||
├── Editor/
|
||||
│ ├── Jovian.PopupSystem.Editor.asmdef
|
||||
│ └── PopupSettingsProvider.cs
|
||||
└── Samples~/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `com.unity.textmeshpro` (TMP_Text for content elements)
|
||||
- `com.unity.inputsystem` (for pointer position in follow-mouse mode)
|
||||
- `com.unity.nuget.newtonsoft-json` (for PopupCategory serialization)
|
||||
|
||||
## Integration Example
|
||||
|
||||
```csharp
|
||||
// In GameModeGameState or similar:
|
||||
var popupSettings = Addressables.LoadAssetAsync<PopupSettings>("PopupSettings").WaitForCompletion();
|
||||
var popupViewPrefab = Addressables.LoadAssetAsync<PopupView>("PopupViewPrefab").WaitForCompletion();
|
||||
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab);
|
||||
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
|
||||
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
|
||||
|
||||
// Pass popupSystem to views/play modes via constructor DI
|
||||
|
||||
// In PartyGuiView — attach PopupTrigger to each slot:
|
||||
var trigger = slot.GetComponent<PopupTrigger>();
|
||||
trigger.Initialize(popupSystem, builder => {
|
||||
builder.AddHeader(member.Name)
|
||||
.AddSeparator()
|
||||
.AddStat("Health", member.Stats.GetValue(StatType.Health))
|
||||
.AddStat("Mana", member.Stats.GetValue(StatType.Mana))
|
||||
.AddStat("Level", member.Stats.GetValue(StatType.Level))
|
||||
.AddText($"{member.Race} {member.Class}");
|
||||
});
|
||||
|
||||
// In Tick:
|
||||
popupSystem.Tick(Time.deltaTime);
|
||||
|
||||
// On state exit:
|
||||
popupSystem.Dispose();
|
||||
```
|
||||
|
||||
## Prefab Setup
|
||||
|
||||
### PopupView prefab
|
||||
|
||||
1. Root: Canvas (Screen Space Overlay, sorting order from settings), CanvasGroup (alpha=0)
|
||||
2. Child "Content": RectTransform, VerticalLayoutGroup (Child Force Expand Width: true, Height: false, Spacing: 4, Padding: 8), ContentSizeFitter (Vertical Fit: Preferred Size, Horizontal Fit: Preferred Size up to maxPopupWidth)
|
||||
3. Background Image behind Content
|
||||
4. Optional: RectMask2D on root
|
||||
|
||||
Pre-create cached elements as children of Content (all deactivated):
|
||||
- 2x Header (TMP_Text, bold, larger font)
|
||||
- 4x Text (TMP_Text, normal)
|
||||
- 4x Stat (horizontal layout: TMP_Text label + TMP_Text value)
|
||||
- 2x Image (Image component)
|
||||
- 3x Separator (Image, 1px height, horizontal stretch)
|
||||
999
docs/plans/2026-04-06-popup-system-implementation.md
Normal file
999
docs/plans/2026-04-06-popup-system-implementation.md
Normal file
@@ -0,0 +1,999 @@
|
||||
# Popup System Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build `com.jovian.popup-system`, a low-allocation popup/tooltip package with category-based isolation, fluent content builder, grow-only element cache, screen edge clamping, and extensible animations.
|
||||
|
||||
**Architecture:** Per-game-state `IPopupSystem` instances with registered categories. Each category lazily creates one `PopupView` MonoBehaviour that reuses cached content elements. `PopupTrigger` MonoBehaviour handles hover detection. `PopupContentBuilder` struct operates directly on cached elements. Float-based timers in `Tick()` — no coroutines.
|
||||
|
||||
**Tech Stack:** Unity 6 / C# 9, TextMeshPro, Unity Input System, Newtonsoft.Json
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Package scaffold
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/package.json`
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/Jovian.PopupSystem.asmdef`
|
||||
- Create: `Packages/com.jovian.popup-system/Editor/Jovian.PopupSystem.Editor.asmdef`
|
||||
- Create: `Packages/com.jovian.popup-system/Samples~/README.md`
|
||||
|
||||
**Step 1: Create package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "com.jovian.popup-system",
|
||||
"version": "0.1.0",
|
||||
"displayName": "Jovian Popup System",
|
||||
"description": "A lightweight, low-allocation popup and tooltip system with category-based isolation, fluent content builder, and extensible animations.",
|
||||
"unity": "2022.3",
|
||||
"dependencies": {
|
||||
"com.unity.textmeshpro": "3.0.6",
|
||||
"com.unity.inputsystem": "1.18.0",
|
||||
"com.unity.nuget.newtonsoft-json": "3.2.1"
|
||||
},
|
||||
"keywords": [
|
||||
"popup",
|
||||
"tooltip",
|
||||
"ui"
|
||||
],
|
||||
"author": {
|
||||
"name": "Jovian"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create Runtime asmdef**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Jovian.PopupSystem",
|
||||
"rootNamespace": "Jovian.PopupSystem",
|
||||
"references": [
|
||||
"Unity.TextMeshPro",
|
||||
"Unity.InputSystem"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"Newtonsoft.Json.dll"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create Editor asmdef**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Jovian.PopupSystem.Editor",
|
||||
"rootNamespace": "Jovian.PopupSystem.Editor",
|
||||
"references": [
|
||||
"Jovian.PopupSystem"
|
||||
],
|
||||
"includePlatforms": ["Editor"],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Create Samples~/README.md**
|
||||
|
||||
```markdown
|
||||
# Samples
|
||||
|
||||
This folder is reserved for sample scenes and scripts demonstrating the Popup System.
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/
|
||||
git commit -m "feat: scaffold com.jovian.popup-system package"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PopupCategory + JSON converter + enums
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/PopupCategory.cs`
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/PopupCategoryJsonConverter.cs`
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/PopupEnums.cs`
|
||||
|
||||
**Step 1: Implement PopupCategory** (same pattern as LogChannel)
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
[Serializable]
|
||||
public readonly struct PopupCategory : IEquatable<PopupCategory> {
|
||||
[SerializeField] readonly string id;
|
||||
|
||||
public string Id => id;
|
||||
|
||||
public PopupCategory(string id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public static readonly PopupCategory Character = new("Character");
|
||||
public static readonly PopupCategory Item = new("Item");
|
||||
public static readonly PopupCategory Skill = new("Skill");
|
||||
public static readonly PopupCategory General = new("General");
|
||||
|
||||
public bool Equals(PopupCategory other) {
|
||||
return string.Equals(id, other.id, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) {
|
||||
return obj is PopupCategory other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return id != null ? id.GetHashCode() : 0;
|
||||
}
|
||||
|
||||
public override string ToString() {
|
||||
return id ?? string.Empty;
|
||||
}
|
||||
|
||||
public static bool operator ==(PopupCategory left, PopupCategory right) {
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(PopupCategory left, PopupCategory right) {
|
||||
return !left.Equals(right);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Implement JSON converter**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public sealed class PopupCategoryJsonConverter : JsonConverter<PopupCategory> {
|
||||
public override void WriteJson(JsonWriter writer, PopupCategory value, JsonSerializer serializer) {
|
||||
writer.WriteValue(value.Id);
|
||||
}
|
||||
|
||||
public override PopupCategory ReadJson(JsonReader reader, Type objectType, PopupCategory existingValue, bool hasExistingValue, JsonSerializer serializer) {
|
||||
var id = reader.Value as string;
|
||||
return new PopupCategory(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Implement enums**
|
||||
|
||||
```csharp
|
||||
namespace Jovian.PopupSystem {
|
||||
public enum AnchorSide {
|
||||
Below,
|
||||
Above,
|
||||
Left,
|
||||
Right
|
||||
}
|
||||
|
||||
public enum PopupPositionMode {
|
||||
AnchorToElement,
|
||||
FollowMouse
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Runtime/
|
||||
git commit -m "feat: add PopupCategory, enums, and JSON converter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: PopupSettings ScriptableObject
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/PopupSettings.cs`
|
||||
|
||||
**Step 1: Implement settings**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
[CreateAssetMenu(fileName = "PopupSettings", menuName = "Jovian/Popup System/Popup Settings")]
|
||||
public class PopupSettings : ScriptableObject {
|
||||
[Header("General")]
|
||||
public float popupDelay = 0.4f;
|
||||
public float fadeDuration = 0.2f;
|
||||
public AnchorSide defaultAnchorSide = AnchorSide.Below;
|
||||
public float screenEdgePadding = 10f;
|
||||
public float maxPopupWidth = 400f;
|
||||
public int sortingOrder = 100;
|
||||
|
||||
[Header("Follow Mouse")]
|
||||
public Vector2 followMouseOffset = new(15f, -15f);
|
||||
|
||||
[Header("Input")]
|
||||
public float touchHoldDuration = 0.6f;
|
||||
public bool gamepadFocusTrigger = true;
|
||||
|
||||
[Header("Priority")]
|
||||
public List<CategoryPriority> categoryPriorities = new();
|
||||
|
||||
[Header("Per-Category Overrides")]
|
||||
public List<CategoryDelay> categoryDelayOverrides = new();
|
||||
|
||||
public int GetPriority(PopupCategory category) {
|
||||
foreach(var cp in categoryPriorities) {
|
||||
if(cp.category == category) {
|
||||
return cp.priority;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public float GetDelay(PopupCategory category) {
|
||||
foreach(var cd in categoryDelayOverrides) {
|
||||
if(cd.category == category) {
|
||||
return cd.delay;
|
||||
}
|
||||
}
|
||||
return popupDelay;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class CategoryPriority {
|
||||
public PopupCategory category;
|
||||
public int priority;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class CategoryDelay {
|
||||
public PopupCategory category;
|
||||
public float delay;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Runtime/PopupSettings.cs
|
||||
git commit -m "feat: add PopupSettings ScriptableObject with full configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: IPopupAnimator + FadePopupAnimator
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs`
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs`
|
||||
|
||||
**Step 1: Implement interface**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public interface IPopupAnimator {
|
||||
void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
|
||||
void Tick(float deltaTime);
|
||||
bool IsAnimating { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Implement FadePopupAnimator**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public sealed class FadePopupAnimator : IPopupAnimator {
|
||||
private CanvasGroup target;
|
||||
private float duration;
|
||||
private float elapsed;
|
||||
private float startAlpha;
|
||||
private float endAlpha;
|
||||
private Action onComplete;
|
||||
|
||||
public bool IsAnimating => target != null;
|
||||
|
||||
public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) {
|
||||
target = canvasGroup;
|
||||
this.duration = Mathf.Max(duration, 0.001f);
|
||||
elapsed = 0f;
|
||||
startAlpha = 0f;
|
||||
endAlpha = 1f;
|
||||
this.onComplete = onComplete;
|
||||
canvasGroup.alpha = 0f;
|
||||
}
|
||||
|
||||
public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
|
||||
target = canvasGroup;
|
||||
this.duration = Mathf.Max(duration, 0.001f);
|
||||
elapsed = 0f;
|
||||
startAlpha = canvasGroup.alpha;
|
||||
endAlpha = 0f;
|
||||
this.onComplete = onComplete;
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime) {
|
||||
if(target == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
elapsed += deltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / duration);
|
||||
target.alpha = Mathf.Lerp(startAlpha, endAlpha, t);
|
||||
|
||||
if(t >= 1f) {
|
||||
var callback = onComplete;
|
||||
target = null;
|
||||
onComplete = null;
|
||||
callback?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs
|
||||
git commit -m "feat: add IPopupAnimator interface and FadePopupAnimator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: PopupView MonoBehaviour with element cache
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs`
|
||||
|
||||
**Step 1: Implement PopupView with grow-only element cache, positioning, and screen clamping**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Jovian.PopupSystem.UI {
|
||||
public class PopupView : MonoBehaviour {
|
||||
[SerializeField] RectTransform content;
|
||||
[SerializeField] CanvasGroup canvasGroup;
|
||||
[SerializeField] RectTransform background;
|
||||
|
||||
[Header("Element Prefabs")]
|
||||
[SerializeField] TMP_Text headerPrefab;
|
||||
[SerializeField] TMP_Text textPrefab;
|
||||
[SerializeField] RectTransform statPrefab; // contains label + value TMP_Text
|
||||
[SerializeField] Image imagePrefab;
|
||||
[SerializeField] Image separatorPrefab;
|
||||
|
||||
// Element caches (grow-only)
|
||||
readonly List<TMP_Text> headerCache = new();
|
||||
readonly List<TMP_Text> textCache = new();
|
||||
readonly List<RectTransform> statCache = new();
|
||||
readonly List<Image> imageCache = new();
|
||||
readonly List<Image> separatorCache = new();
|
||||
|
||||
int headerIndex;
|
||||
int textIndex;
|
||||
int statIndex;
|
||||
int imageIndex;
|
||||
int separatorIndex;
|
||||
|
||||
// State
|
||||
PopupPositionMode positionMode;
|
||||
RectTransform anchorTarget;
|
||||
AnchorSide anchorSide;
|
||||
Vector2 followOffset;
|
||||
float screenEdgePadding;
|
||||
bool isVisible;
|
||||
|
||||
public CanvasGroup CanvasGroup => canvasGroup;
|
||||
public RectTransform Content => content;
|
||||
public bool IsVisible => isVisible;
|
||||
|
||||
public void SetVisible(bool visible) {
|
||||
isVisible = visible;
|
||||
gameObject.SetActive(visible);
|
||||
if(!visible) {
|
||||
canvasGroup.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearContent() {
|
||||
for(int i = 0; i < headerIndex; i++) {
|
||||
headerCache[i].gameObject.SetActive(false);
|
||||
}
|
||||
for(int i = 0; i < textIndex; i++) {
|
||||
textCache[i].gameObject.SetActive(false);
|
||||
}
|
||||
for(int i = 0; i < statIndex; i++) {
|
||||
statCache[i].gameObject.SetActive(false);
|
||||
}
|
||||
for(int i = 0; i < imageIndex; i++) {
|
||||
imageCache[i].gameObject.SetActive(false);
|
||||
}
|
||||
for(int i = 0; i < separatorIndex; i++) {
|
||||
separatorCache[i].gameObject.SetActive(false);
|
||||
}
|
||||
headerIndex = 0;
|
||||
textIndex = 0;
|
||||
statIndex = 0;
|
||||
imageIndex = 0;
|
||||
separatorIndex = 0;
|
||||
}
|
||||
|
||||
// --- Element access (grow-only) ---
|
||||
|
||||
public TMP_Text GetHeader() {
|
||||
return GetOrCreate(headerCache, headerPrefab, ref headerIndex);
|
||||
}
|
||||
|
||||
public TMP_Text GetText() {
|
||||
return GetOrCreate(textCache, textPrefab, ref textIndex);
|
||||
}
|
||||
|
||||
public (TMP_Text label, TMP_Text value) GetStat() {
|
||||
var rt = GetOrCreate(statCache, statPrefab, ref statIndex);
|
||||
var children = rt.GetComponentsInChildren<TMP_Text>(true);
|
||||
return (children[0], children[1]);
|
||||
}
|
||||
|
||||
public Image GetImage() {
|
||||
return GetOrCreate(imageCache, imagePrefab, ref imageIndex);
|
||||
}
|
||||
|
||||
public Image GetSeparator() {
|
||||
return GetOrCreate(separatorCache, separatorPrefab, ref separatorIndex);
|
||||
}
|
||||
|
||||
private T GetOrCreate<T>(List<T> cache, T prefab, ref int index) where T : Component {
|
||||
if(index < cache.Count) {
|
||||
var existing = cache[index];
|
||||
existing.gameObject.SetActive(true);
|
||||
existing.transform.SetAsLastSibling();
|
||||
index++;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = Instantiate(prefab, content);
|
||||
created.gameObject.SetActive(true);
|
||||
created.transform.SetAsLastSibling();
|
||||
cache.Add(created);
|
||||
index++;
|
||||
return created;
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
public void SetAnchorMode(RectTransform target, AnchorSide side, float edgePadding) {
|
||||
positionMode = PopupPositionMode.AnchorToElement;
|
||||
anchorTarget = target;
|
||||
anchorSide = side;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
public void SetFollowMouseMode(Vector2 offset, float edgePadding) {
|
||||
positionMode = PopupPositionMode.FollowMouse;
|
||||
followOffset = offset;
|
||||
screenEdgePadding = edgePadding;
|
||||
}
|
||||
|
||||
public void UpdatePosition() {
|
||||
if(positionMode == PopupPositionMode.FollowMouse) {
|
||||
PositionAtScreenPoint(Mouse.current.position.ReadValue() + followOffset);
|
||||
}
|
||||
else if(anchorTarget != null) {
|
||||
PositionAnchoredTo(anchorTarget, anchorSide);
|
||||
}
|
||||
}
|
||||
|
||||
private void PositionAnchoredTo(RectTransform target, AnchorSide side) {
|
||||
var targetRect = GetScreenRect(target);
|
||||
var popupRect = GetScreenRect((RectTransform)transform);
|
||||
var popupSize = popupRect.size;
|
||||
|
||||
var pos = side switch {
|
||||
AnchorSide.Below => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMin - popupSize.y),
|
||||
AnchorSide.Above => new Vector2(targetRect.center.x - popupSize.x * 0.5f, targetRect.yMax),
|
||||
AnchorSide.Left => new Vector2(targetRect.xMin - popupSize.x, targetRect.center.y - popupSize.y * 0.5f),
|
||||
AnchorSide.Right => new Vector2(targetRect.xMax, targetRect.center.y - popupSize.y * 0.5f),
|
||||
_ => targetRect.center
|
||||
};
|
||||
|
||||
PositionAtScreenPoint(pos);
|
||||
}
|
||||
|
||||
private void PositionAtScreenPoint(Vector2 screenPos) {
|
||||
var rt = (RectTransform)transform;
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
|
||||
var popupSize = rt.rect.size;
|
||||
|
||||
// Clamp to screen
|
||||
screenPos.x = Mathf.Clamp(screenPos.x, screenEdgePadding, Screen.width - popupSize.x - screenEdgePadding);
|
||||
screenPos.y = Mathf.Clamp(screenPos.y, screenEdgePadding, Screen.height - popupSize.y - screenEdgePadding);
|
||||
|
||||
rt.position = screenPos;
|
||||
}
|
||||
|
||||
private static Rect GetScreenRect(RectTransform rt) {
|
||||
var corners = new Vector3[4];
|
||||
rt.GetWorldCorners(corners);
|
||||
var min = new Vector2(corners[0].x, corners[0].y);
|
||||
var max = new Vector2(corners[2].x, corners[2].y);
|
||||
return new Rect(min, max - min);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs
|
||||
git commit -m "feat: add PopupView with grow-only element cache and positioning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: PopupContentBuilder struct
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs`
|
||||
|
||||
**Step 1: Implement fluent builder**
|
||||
|
||||
```csharp
|
||||
using Jovian.PopupSystem.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public struct PopupContentBuilder {
|
||||
readonly PopupView view;
|
||||
|
||||
public PopupContentBuilder(PopupView view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddHeader(string text) {
|
||||
var header = view.GetHeader();
|
||||
header.text = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddText(string text) {
|
||||
var label = view.GetText();
|
||||
label.text = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddText(string text, string hexColor) {
|
||||
var label = view.GetText();
|
||||
var prefix = text.Length > 0 && hexColor[0] == '#' ? "" : "#";
|
||||
label.text = $"<color={prefix}{hexColor}>{text}</color>";
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddStat(string label, int value) {
|
||||
var (labelText, valueText) = view.GetStat();
|
||||
labelText.text = label;
|
||||
valueText.text = value.ToString();
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddStat(string label, string value) {
|
||||
var (labelText, valueText) = view.GetStat();
|
||||
labelText.text = label;
|
||||
valueText.text = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddImage(Sprite sprite, float height = 64f) {
|
||||
var image = view.GetImage();
|
||||
image.sprite = sprite;
|
||||
var rt = (RectTransform)image.transform;
|
||||
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PopupContentBuilder AddSeparator() {
|
||||
view.GetSeparator();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs
|
||||
git commit -m "feat: add PopupContentBuilder fluent API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: IPopupSystem + PopupSystem implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs`
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/PopupSystem.cs`
|
||||
|
||||
**Step 1: Implement interface**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public interface IPopupSystem {
|
||||
void RegisterCategory(PopupCategory category, int priority = 0);
|
||||
void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
RectTransform anchor = null, AnchorSide? anchorSide = null);
|
||||
void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
Vector2 screenPosition);
|
||||
void Hide(PopupCategory category);
|
||||
void HideAll();
|
||||
void Tick(float deltaTime);
|
||||
void Dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Implement PopupSystem**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jovian.PopupSystem.UI;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Jovian.PopupSystem {
|
||||
public sealed class PopupSystem : IPopupSystem {
|
||||
readonly PopupSettings settings;
|
||||
readonly PopupView viewPrefab;
|
||||
readonly IPopupAnimator animator;
|
||||
readonly Dictionary<PopupCategory, ViewState> categories = new();
|
||||
|
||||
public PopupSystem(PopupSettings settings, PopupView viewPrefab, IPopupAnimator animator = null) {
|
||||
this.settings = settings;
|
||||
this.viewPrefab = viewPrefab;
|
||||
this.animator = animator ?? new FadePopupAnimator();
|
||||
}
|
||||
|
||||
public void RegisterCategory(PopupCategory category, int priority = 0) {
|
||||
if(categories.ContainsKey(category)) {
|
||||
return;
|
||||
}
|
||||
var effectivePriority = settings.GetPriority(category);
|
||||
if(effectivePriority == 0) {
|
||||
effectivePriority = priority;
|
||||
}
|
||||
categories[category] = new ViewState {
|
||||
priority = effectivePriority,
|
||||
delay = settings.GetDelay(category)
|
||||
};
|
||||
}
|
||||
|
||||
public void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
RectTransform anchor = null, AnchorSide? anchorSide = null) {
|
||||
if(!categories.TryGetValue(category, out var state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss lower-priority visible popups
|
||||
DismissLowerPriority(state.priority);
|
||||
|
||||
state.pendingBuild = buildContent;
|
||||
state.pendingAnchor = anchor;
|
||||
state.pendingAnchorSide = anchorSide ?? settings.defaultAnchorSide;
|
||||
state.pendingScreenPos = null;
|
||||
state.delayTimer = state.delay;
|
||||
state.isPending = true;
|
||||
}
|
||||
|
||||
public void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
|
||||
Vector2 screenPosition) {
|
||||
if(!categories.TryGetValue(category, out var state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DismissLowerPriority(state.priority);
|
||||
|
||||
state.pendingBuild = buildContent;
|
||||
state.pendingAnchor = null;
|
||||
state.pendingScreenPos = screenPosition;
|
||||
state.delayTimer = state.delay;
|
||||
state.isPending = true;
|
||||
}
|
||||
|
||||
public void Hide(PopupCategory category) {
|
||||
if(!categories.TryGetValue(category, out var state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.isPending = false;
|
||||
if(state.view != null && state.view.IsVisible) {
|
||||
animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
|
||||
state.view.SetVisible(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void HideAll() {
|
||||
foreach(var kvp in categories) {
|
||||
Hide(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime) {
|
||||
animator.Tick(deltaTime);
|
||||
|
||||
foreach(var kvp in categories) {
|
||||
var state = kvp.Value;
|
||||
if(!state.isPending) {
|
||||
continue;
|
||||
}
|
||||
|
||||
state.delayTimer -= deltaTime;
|
||||
if(state.delayTimer > 0f) {
|
||||
continue;
|
||||
}
|
||||
|
||||
state.isPending = false;
|
||||
ShowImmediate(state);
|
||||
}
|
||||
|
||||
// Update follow-mouse positions
|
||||
foreach(var kvp in categories) {
|
||||
var state = kvp.Value;
|
||||
if(state.view != null && state.view.IsVisible && state.isFollowMouse) {
|
||||
state.view.UpdatePosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
foreach(var kvp in categories) {
|
||||
if(kvp.Value.view != null) {
|
||||
Object.Destroy(kvp.Value.view.gameObject);
|
||||
}
|
||||
}
|
||||
categories.Clear();
|
||||
}
|
||||
|
||||
private void ShowImmediate(ViewState state) {
|
||||
EnsureView(state);
|
||||
state.view.ClearContent();
|
||||
|
||||
var builder = new PopupContentBuilder(state.view);
|
||||
state.pendingBuild?.Invoke(builder);
|
||||
|
||||
if(state.pendingScreenPos.HasValue) {
|
||||
state.view.SetFollowMouseMode(settings.followMouseOffset, settings.screenEdgePadding);
|
||||
state.isFollowMouse = false; // positioned at fixed screen point
|
||||
}
|
||||
else if(state.pendingAnchor != null) {
|
||||
state.view.SetAnchorMode(state.pendingAnchor, state.pendingAnchorSide, settings.screenEdgePadding);
|
||||
state.isFollowMouse = false;
|
||||
}
|
||||
else {
|
||||
state.view.SetFollowMouseMode(settings.followMouseOffset, settings.screenEdgePadding);
|
||||
state.isFollowMouse = true;
|
||||
}
|
||||
|
||||
state.view.SetVisible(true);
|
||||
state.view.UpdatePosition();
|
||||
animator.Show(state.view.CanvasGroup, settings.fadeDuration, null);
|
||||
}
|
||||
|
||||
private void EnsureView(ViewState state) {
|
||||
if(state.view != null) {
|
||||
return;
|
||||
}
|
||||
state.view = Object.Instantiate(viewPrefab);
|
||||
state.view.SetVisible(false);
|
||||
|
||||
var canvas = state.view.GetComponent<Canvas>();
|
||||
if(canvas != null) {
|
||||
canvas.sortingOrder = settings.sortingOrder;
|
||||
}
|
||||
}
|
||||
|
||||
private void DismissLowerPriority(int showingPriority) {
|
||||
foreach(var kvp in categories) {
|
||||
var state = kvp.Value;
|
||||
if(state.priority < showingPriority && state.view != null && state.view.IsVisible) {
|
||||
state.isPending = false;
|
||||
animator.Hide(state.view.CanvasGroup, settings.fadeDuration, () => {
|
||||
state.view.SetVisible(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ViewState {
|
||||
public PopupView view;
|
||||
public int priority;
|
||||
public float delay;
|
||||
public float delayTimer;
|
||||
public bool isPending;
|
||||
public bool isFollowMouse;
|
||||
public Action<PopupContentBuilder> pendingBuild;
|
||||
public RectTransform pendingAnchor;
|
||||
public AnchorSide pendingAnchorSide;
|
||||
public Vector2? pendingScreenPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs Packages/com.jovian.popup-system/Runtime/PopupSystem.cs
|
||||
git commit -m "feat: add IPopupSystem and PopupSystem with priority and delay"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: PopupTrigger MonoBehaviour
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs`
|
||||
|
||||
**Step 1: Implement trigger**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace Jovian.PopupSystem.UI {
|
||||
public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
|
||||
[SerializeField] PopupCategory category;
|
||||
[SerializeField] AnchorSide anchorSide = AnchorSide.Below;
|
||||
[SerializeField] PopupPositionMode positionMode = PopupPositionMode.AnchorToElement;
|
||||
|
||||
IPopupSystem popupSystem;
|
||||
Action<PopupContentBuilder> contentCallback;
|
||||
bool initialized;
|
||||
|
||||
public PopupCategory Category => category;
|
||||
|
||||
public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> contentCallback) {
|
||||
this.popupSystem = popupSystem;
|
||||
this.contentCallback = contentCallback;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public void Initialize(IPopupSystem popupSystem, PopupCategory category, Action<PopupContentBuilder> contentCallback) {
|
||||
this.popupSystem = popupSystem;
|
||||
this.category = category;
|
||||
this.contentCallback = contentCallback;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public void UpdateContent(Action<PopupContentBuilder> contentCallback) {
|
||||
this.contentCallback = contentCallback;
|
||||
}
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData) {
|
||||
if(!initialized || popupSystem == null || contentCallback == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(positionMode == PopupPositionMode.AnchorToElement) {
|
||||
popupSystem.Show(category, contentCallback, (RectTransform)transform, anchorSide);
|
||||
}
|
||||
else {
|
||||
popupSystem.Show(category, contentCallback);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData) {
|
||||
if(!initialized || popupSystem == null) {
|
||||
return;
|
||||
}
|
||||
popupSystem.Hide(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs
|
||||
git commit -m "feat: add PopupTrigger MonoBehaviour for hover detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: README.md
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/README.md`
|
||||
|
||||
**Step 1: Write README** covering: quick start, PopupCategory, PopupSettings, content builder API, PopupTrigger setup, code-only triggers, prefab setup, IPopupAnimator extensibility.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/README.md
|
||||
git commit -m "feat: add README for popup system package"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Editor — PopupSettingsProvider
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs`
|
||||
|
||||
**Step 1: Implement settings provider** for Project Settings > Jovian > Popup System (same pattern as SaveSystemSettingsProvider).
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs
|
||||
git commit -m "feat: add PopupSettingsProvider for Project Settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Type | Purpose |
|
||||
|------|------|---------|
|
||||
| `Packages/com.jovian.popup-system/package.json` | Create | Package manifest |
|
||||
| `Packages/com.jovian.popup-system/Runtime/Jovian.PopupSystem.asmdef` | Create | Runtime assembly |
|
||||
| `Packages/com.jovian.popup-system/Editor/Jovian.PopupSystem.Editor.asmdef` | Create | Editor assembly |
|
||||
| `Packages/com.jovian.popup-system/Samples~/README.md` | Create | Samples placeholder |
|
||||
| `Packages/com.jovian.popup-system/Runtime/PopupCategory.cs` | Create | Category readonly struct |
|
||||
| `Packages/com.jovian.popup-system/Runtime/PopupCategoryJsonConverter.cs` | Create | Newtonsoft converter |
|
||||
| `Packages/com.jovian.popup-system/Runtime/PopupEnums.cs` | Create | AnchorSide, PopupPositionMode |
|
||||
| `Packages/com.jovian.popup-system/Runtime/PopupSettings.cs` | Create | ScriptableObject config |
|
||||
| `Packages/com.jovian.popup-system/Runtime/IPopupAnimator.cs` | Create | Animation interface |
|
||||
| `Packages/com.jovian.popup-system/Runtime/FadePopupAnimator.cs` | Create | Default fade animation |
|
||||
| `Packages/com.jovian.popup-system/Runtime/PopupContentBuilder.cs` | Create | Fluent builder struct |
|
||||
| `Packages/com.jovian.popup-system/Runtime/IPopupSystem.cs` | Create | System interface |
|
||||
| `Packages/com.jovian.popup-system/Runtime/PopupSystem.cs` | Create | System implementation |
|
||||
| `Packages/com.jovian.popup-system/Runtime/UI/PopupView.cs` | Create | MonoBehaviour with element cache |
|
||||
| `Packages/com.jovian.popup-system/Runtime/UI/PopupTrigger.cs` | Create | Hover trigger MonoBehaviour |
|
||||
| `Packages/com.jovian.popup-system/README.md` | Create | Package documentation |
|
||||
| `Packages/com.jovian.popup-system/Editor/PopupSettingsProvider.cs` | Create | Project Settings UI |
|
||||
Reference in New Issue
Block a user