forked from Shardstone/trail-into-darkness
272 lines
10 KiB
C#
272 lines
10 KiB
C#
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 {
|
|
/// <summary>
|
|
/// Core implementation of <see cref="IPopupSystem"/>. 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.
|
|
/// </summary>
|
|
public sealed class PopupSystem : IPopupSystem {
|
|
readonly PopupSettings settings;
|
|
readonly PopupReference viewPrefab;
|
|
readonly Func<IPopupAnimator> animatorFactory;
|
|
readonly Transform canvasParent;
|
|
readonly Dictionary<PopupCategory, ViewState> categories = new();
|
|
readonly List<PopupTrigger> registeredTriggers = new();
|
|
|
|
/// <summary>
|
|
/// Creates a new popup system instance.
|
|
/// </summary>
|
|
/// <param name="settings">Configuration ScriptableObject with delays, priorities, and display settings.</param>
|
|
/// <param name="viewPrefab">The PopupReference prefab to instantiate per category.</param>
|
|
/// <param name="canvasParent">Optional parent Canvas transform. When provided, popup views are parented
|
|
/// here (inheriting CanvasScaler) and all PopupTrigger components are auto-scanned.</param>
|
|
/// <param name="animatorFactory">Optional factory for custom animators. Defaults to FadePopupAnimator.</param>
|
|
public PopupSystem(PopupSettings settings, PopupReference viewPrefab, Transform canvasParent = null, Func<IPopupAnimator> 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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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()
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void HideAll() {
|
|
foreach(var kvp in categories) {
|
|
Hide(kvp.Key);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ScanTriggers(Transform parent) {
|
|
var triggers = parent.GetComponentsInChildren<PopupTrigger>(true);
|
|
foreach(var trigger in triggers) {
|
|
if(!registeredTriggers.Contains(trigger)) {
|
|
BindTrigger(trigger);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public PopupTriggerView GetTriggerHandler(string gameObjectName) {
|
|
foreach(var trigger in registeredTriggers) {
|
|
if(trigger != null && trigger.gameObject.name == gameObjectName) {
|
|
return trigger.Handler;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<PopupTriggerView> GetTriggerHandlers(PopupCategory category) {
|
|
var result = new List<PopupTriggerView>();
|
|
foreach(var trigger in registeredTriggers) {
|
|
if(trigger != null && trigger.Category == category && trigger.Handler != null) {
|
|
result.Add(trigger.Handler);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void InitializeTriggersInChildren(Transform parent, Action<PopupTrigger, PopupTriggerView> configureTrigger) {
|
|
var triggers = parent.GetComponentsInChildren<PopupTrigger>(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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<Canvas>();
|
|
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<PopupContentBuilder> pendingBuild;
|
|
public RectTransform pendingAnchor;
|
|
public AnchorSide pendingAnchorSide;
|
|
public Vector2? pendingScreenPos;
|
|
}
|
|
}
|
|
}
|