using System; using System.Collections.Generic; using Jovian.PopupSystem.UI; using System.Linq; using UnityEngine; using UnityEngine.UI; using Object = UnityEngine.Object; namespace Jovian.PopupSystem { /// /// Core implementation of . Created per game state, not as a singleton. /// Manages category registration, trigger discovery, popup lifecycle, priority dismissal, and /// tick-driven delay timers and animations. Pass a canvasParent to auto-scan triggers on construction. /// public sealed class PopupSystem : IPopupSystem { readonly PopupSettings settings; readonly PopupReference viewPrefab; readonly Func animatorFactory; readonly Transform canvasParent; readonly Dictionary categories = new(); readonly List registeredTriggers = new(); /// /// Creates a new popup system instance. /// /// Configuration ScriptableObject with delays, priorities, and display settings. /// The PopupReference prefab to instantiate per category. /// Optional parent Canvas transform. When provided, popup views are parented /// here (inheriting CanvasScaler) and all PopupTrigger components are auto-scanned. /// Optional factory for custom animators. Defaults to FadePopupAnimator. public PopupSystem(PopupSettings settings, PopupReference viewPrefab, Transform canvasParent = null, Func animatorFactory = null) { this.settings = settings; this.viewPrefab = viewPrefab; this.canvasParent = canvasParent; this.animatorFactory = animatorFactory ?? (() => new FadePopupAnimator()); // Auto-scan if a parent was provided if(canvasParent != null) { ScanTriggers(canvasParent); } } /// 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 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 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 state in categories.Select(kvp => kvp.Value).Where(state => state.isPending)) { state.delayTimer -= deltaTime; if(state.delayTimer > 0f) { continue; } state.isPending = false; ShowImmediate(state); } foreach(var state in categories.Select(kvp => kvp.Value).Where(state => state.view is { IsVisible: true } && state.isFollowMouse)) { state.view.UpdatePosition(); } } /// public void ScanTriggers(Transform parent) { var triggers = parent.GetComponentsInChildren(true); foreach(var trigger in triggers) { if(!registeredTriggers.Contains(trigger)) { BindTrigger(trigger); } } } /// public PopupTriggerView GetTriggerHandler(string gameObjectName) { foreach(var trigger in registeredTriggers) { if(trigger != null && trigger.gameObject.name == gameObjectName) { return trigger.Handler; } } return null; } /// public IReadOnlyList GetTriggerHandlers(PopupCategory category) { var result = new List(); foreach(var trigger in registeredTriggers) { if(trigger != null && trigger.Category == category && trigger.Handler != null) { result.Add(trigger.Handler); } } return result; } /// public void InitializeTriggersInChildren(Transform parent, Action configureTrigger) { var triggers = parent.GetComponentsInChildren(true); foreach(var trigger in triggers) { if(!registeredTriggers.Contains(trigger)) { BindTrigger(trigger); } configureTrigger(trigger, trigger.Handler); } } private void BindTrigger(PopupTrigger trigger) { var handler = new PopupTriggerView(this); trigger.Bind(handler); registeredTriggers.Add(trigger); } /// public void Dispose() { foreach(var kvp in categories) { if(kvp.Value.view?.Reference != null) { Object.Destroy(kvp.Value.view.Reference.gameObject); } } categories.Clear(); } private void ShowImmediate(ViewState state) { EnsureView(state); state.view.ClearContent(); var builder = new PopupContentBuilder(state.view); state.pendingBuild?.Invoke(builder); // Activate before layout rebuild so Unity has an active hierarchy to calculate state.view.CanvasGroup.alpha = 0f; state.view.SetVisible(true); // Force full layout rebuild so positioning has correct size on first show Canvas.ForceUpdateCanvases(); LayoutRebuilder.ForceRebuildLayoutImmediate(state.view.Content); LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform)state.view.Transform); if(state.pendingScreenPos.HasValue) { state.view.SetFixedPosition(state.pendingScreenPos.Value, settings.screenEdgePadding); state.isFollowMouse = false; } else if(state.pendingAnchor) { 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.UpdatePosition(); state.animator.Show(state.view.CanvasGroup, settings.fadeDuration, null); } private void EnsureView(ViewState state) { if(state.view != null) { return; } PopupReference popupRef; popupRef = !canvasParent ? Object.Instantiate(viewPrefab) : Object.Instantiate(viewPrefab, canvasParent); var canvas = popupRef.GetComponent(); if(canvas) { canvas.overrideSorting = true; canvas.sortingOrder = settings.sortingOrder; } state.view = new PopupView(popupRef, settings); state.view.SetVisible(false); state.view.SetMaxWidth(settings.maxPopupWidth); } private void DismissLowerPriority(int showingPriority) { foreach(var kvp in categories) { var state = kvp.Value; if(state.priority >= showingPriority || state.view is not { IsVisible: true }) { continue; } 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 pendingBuild; public RectTransform pendingAnchor; public AnchorSide pendingAnchorSide; public Vector2? pendingScreenPos; } } }