Files
ShrinkTutorial/Runtime/Core/ShrinkTutorialManager.cs
T
cneicy 846d9c8a69
Publish UPM package / publish (push) Successful in 2s
fix(tutorial): preserve click targets across UI transitions
2026-08-30 20:46:57 +08:00

792 lines
28 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace ShrinkTutorial
{
[DefaultExecutionOrder(-1500)]
public class ShrinkTutorialManager : MonoBehaviour
{
private static ShrinkTutorialManager _instance;
[SerializeField] private ShrinkTutorialSettings settingsOverride;
[Header("Explicit scene UI")]
[SerializeField] private Canvas canvasOverride;
[SerializeField] private ShrinkTutorialMask maskOverride;
[SerializeField] private ShrinkTutorialDialog dialogOverride;
[SerializeField] private bool allowRuntimeUiFallback = true;
private readonly List<string> _pendingTutorialIds = new();
private readonly HashSet<string> _completedTutorials = new();
private readonly HashSet<string> _skippedTutorials = new();
private readonly List<RaycastResult> _pointerRaycastResults = new();
private IShrinkTutorialStorage _storage;
private Canvas _canvas;
private ShrinkTutorialMask _mask;
private ShrinkTutorialDialog _dialog;
private Coroutine _runner;
private ShrinkTutorialData _currentTutorial;
private int _currentStepIndex = -1;
private bool _isPaused;
private bool _skipRequested;
private bool _stepCompleted;
private bool _stepCompletionQueued;
private bool _clickTargetArmed;
private Rect _clickTargetArmedRect;
private ShrinkTutorialMaskShape _clickTargetArmedShape;
private int _clickTargetArmedFrame;
private bool _clickTargetArmedWithTouch;
private float _stepAutoTimer;
private string _receivedCustomEvent = string.Empty;
public static ShrinkTutorialManager Instance => _instance;
public bool IsRunning => _currentTutorial != null;
public string CurrentTutorialId => _currentTutorial ? _currentTutorial.tutorialId : string.Empty;
public event Action<string> OnTutorialStarted;
public event Action<string> OnTutorialCompleted;
public event Action<string> OnTutorialSkipped;
public event Action<string, int> OnStepChanged;
public event Action<ShrinkTutorialSignalArgs> OnSignalTriggered;
public static ShrinkTutorialManager EnsureInstance(ShrinkTutorialSettings settings = null)
{
if (_instance)
return _instance;
var existing = FindObjectOfType<ShrinkTutorialManager>();
if (existing)
return existing;
var host = new GameObject("ShrinkTutorialManager");
var manager = host.AddComponent<ShrinkTutorialManager>();
manager.settingsOverride = settings;
return manager;
}
public static void ResetStaticState()
{
_instance = null;
}
public bool HasCompleted(string tutorialId)
{
return !string.IsNullOrWhiteSpace(tutorialId) && _completedTutorials.Contains(tutorialId);
}
public bool HasSkipped(string tutorialId)
{
return !string.IsNullOrWhiteSpace(tutorialId) && _skippedTutorials.Contains(tutorialId);
}
public void ClearProgress(string tutorialId)
{
InitializeIfNeeded();
if (string.IsNullOrWhiteSpace(tutorialId))
return;
var changed = _completedTutorials.Remove(tutorialId);
changed = _skippedTutorials.Remove(tutorialId) || changed;
if (changed)
SaveProgress();
}
public void StartTutorial(string tutorialId)
{
InitializeIfNeeded();
if (string.IsNullOrWhiteSpace(tutorialId) || HasCompleted(tutorialId))
return;
if (!TryGetDatabaseTutorial(tutorialId, out var tutorial))
{
Debug.LogWarning($"[ShrinkTutorial] 未找到教程: {tutorialId}");
return;
}
if (!ArePrerequisitesSatisfied(tutorial) || _currentTutorial)
{
EnqueueTutorial(tutorialId);
return;
}
LogDebug($"StartTutorial -> {tutorialId}");
_runner = StartCoroutine(RunTutorialRoutine(tutorial));
}
public void CompleteStep(string customEventName = "")
{
if (_currentTutorial)
{
LogDebug($"CompleteStep signal received, tutorial={_currentTutorial.tutorialId}, step={_currentStepIndex}, event={customEventName}");
_receivedCustomEvent = customEventName ?? string.Empty;
}
}
public void SkipCurrent()
{
if (!_currentTutorial)
return;
_skipRequested = true;
_stepCompleted = true;
}
public void PauseCurrent()
{
if (!_currentTutorial)
return;
_isPaused = true;
SetUiVisible(false);
}
public void ResumeCurrent()
{
if (!_currentTutorial)
return;
_isPaused = false;
SetUiVisible(true);
}
private void Awake()
{
if (_instance && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
InitializeIfNeeded();
}
private void InitializeIfNeeded()
{
if (_storage != null)
return;
var settings = ResolveSettings();
_storage = new PlayerPrefsShrinkTutorialStorage(settings.playerPrefsStorageKey);
var progress = _storage.Load();
_completedTutorials.Clear();
_skippedTutorials.Clear();
foreach (var tutorialId in progress.completedTutorials)
{
if (!string.IsNullOrWhiteSpace(tutorialId))
_completedTutorials.Add(tutorialId);
}
foreach (var tutorialId in progress.skippedTutorials)
{
if (!string.IsNullOrWhiteSpace(tutorialId))
_skippedTutorials.Add(tutorialId);
}
if (settings.dontDestroyOnLoad)
DontDestroyOnLoad(gameObject);
EnsureEventSystem();
EnsureUi();
}
private IEnumerator RunTutorialRoutine(ShrinkTutorialData tutorial)
{
_currentTutorial = tutorial;
OnTutorialStarted?.Invoke(tutorial.tutorialId);
for (var stepIndex = 0; stepIndex < tutorial.steps.Count; stepIndex++)
{
_currentStepIndex = stepIndex;
_skipRequested = false;
_stepCompleted = false;
_stepCompletionQueued = false;
_clickTargetArmed = false;
_stepAutoTimer = tutorial.steps[stepIndex].autoDelay;
_receivedCustomEvent = string.Empty;
var step = tutorial.steps[stepIndex];
LogDebug(
$"Enter step {stepIndex}, condition={step.completeCondition}, targetMode={step.targetMode}, " +
$"targetPath={step.targetPath}, anchorId={step.anchorId}, customEvent={step.customEventName}");
OnStepChanged?.Invoke(tutorial.tutorialId, stepIndex);
yield return WaitWhilePaused();
Transform targetTransform = null;
yield return ResolveTargetRoutine(step, value => targetTransform = value);
if (step.RequiresTarget() && !targetTransform)
{
Debug.LogWarning($"[ShrinkTutorial] 教程 {tutorial.tutorialId} 第 {stepIndex + 1} 步缺少目标,已跳过。");
continue;
}
if (targetTransform)
LogDebug($"Resolved target for step {stepIndex}: {GetTransformPath(targetTransform)}");
EmitSignal(step.enterSignal);
_dialog.SetSkipVisible(ResolveSettings().showSkipButton);
while (!_stepCompleted && !_skipRequested)
{
yield return WaitWhilePaused();
UpdateStepVisuals(step, targetTransform);
TickStepCompletion(step, targetTransform);
yield return null;
}
EmitSignal(step.exitSignal);
_dialog.Hide();
if (_skipRequested)
{
MarkSkipped(tutorial.tutorialId);
CleanupAfterTutorial();
OnTutorialSkipped?.Invoke(tutorial.tutorialId);
yield break;
}
}
MarkCompleted(tutorial.tutorialId);
OnTutorialCompleted?.Invoke(tutorial.tutorialId);
CleanupAfterTutorial();
}
private IEnumerator ResolveTargetRoutine(ShrinkTutorialStep step, Action<Transform> assignResult)
{
var elapsed = 0f;
var target = ShrinkTutorialTargetUtility.ResolveTarget(step);
while (!target && step.waitForTarget)
{
if (step.waitTimeout > 0f && elapsed >= step.waitTimeout)
break;
elapsed += Time.unscaledDeltaTime;
yield return null;
target = ShrinkTutorialTargetUtility.ResolveTarget(step);
}
assignResult?.Invoke(target);
}
private IEnumerator WaitWhilePaused()
{
while (_isPaused)
yield return null;
}
private void TickStepCompletion(ShrinkTutorialStep step, Transform targetTransform)
{
switch (step.completeCondition)
{
case ShrinkTutorialCompleteCondition.Auto:
_stepAutoTimer -= Time.unscaledDeltaTime;
if (_stepAutoTimer <= 0f)
{
LogDebug($"Auto step completed, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}");
_stepCompleted = true;
}
break;
case ShrinkTutorialCompleteCondition.AnyClick:
if (WasPrimaryPointerPressedOutsideDialog())
{
LogDebug($"AnyClick step completed, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}");
_stepCompleted = true;
}
break;
case ShrinkTutorialCompleteCondition.ClickTarget:
TickClickTargetCompletion(step, targetTransform);
break;
case ShrinkTutorialCompleteCondition.CustomEvent:
case ShrinkTutorialCompleteCondition.DragToTarget:
if (MatchesCustomEvent(step.customEventName))
{
LogDebug(
$"CustomEvent step completed, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}, event={step.customEventName}");
_stepCompleted = true;
}
break;
}
}
private void UpdateStepVisuals(ShrinkTutorialStep step, Transform targetTransform)
{
Rect targetRect = default;
var hasTargetRect = targetTransform && ShrinkTutorialTargetUtility.TryBuildScreenRect(targetTransform, out targetRect);
if (hasTargetRect && step.maskShape != ShrinkTutorialMaskShape.None)
_mask.SetHighlight(targetRect, step.maskShape, step.maskPadding);
else
_mask.ClearHighlight();
var tutorialText = ShrinkTutorialLocalization.Resolve(step.localizedTextKey, step.fallbackText);
_dialog.Show(tutorialText, hasTargetRect ? targetRect : (Rect?)null, step.dialogAnchor, step.dialogOffset, step.showArrow);
}
private bool MatchesCustomEvent(string expectedEventName)
{
if (string.IsNullOrWhiteSpace(_receivedCustomEvent))
return false;
if (string.IsNullOrWhiteSpace(expectedEventName))
{
_receivedCustomEvent = string.Empty;
return true;
}
if (!string.Equals(expectedEventName, _receivedCustomEvent, StringComparison.Ordinal))
return false;
_receivedCustomEvent = string.Empty;
return true;
}
private bool WasPrimaryPointerPressedOutsideDialog()
{
var pressed = TryGetPointerDownPosition(out var screenPosition);
return pressed && !_dialog.ContainsScreenPoint(screenPosition);
}
private void TickClickTargetCompletion(ShrinkTutorialStep step, Transform targetTransform)
{
if (!_clickTargetArmed &&
targetTransform &&
ShrinkTutorialTargetUtility.TryBuildScreenRect(targetTransform, out var targetRect) &&
TryGetPointerDownPosition(out var pointerDownPosition))
{
if (_dialog.ContainsScreenPoint(pointerDownPosition))
{
LogDebug($"Pointer down ignored because it hit dialog, pointer={pointerDownPosition}");
}
else if (!IsPointInsideTarget(targetRect, step.maskShape, pointerDownPosition))
{
LogDebug($"Pointer down outside target, pointer={pointerDownPosition}, rect={targetRect}");
}
else if (WasPointerRoutedToTarget(targetTransform, pointerDownPosition))
{
_clickTargetArmed = true;
_clickTargetArmedRect = targetRect;
_clickTargetArmedShape = step.maskShape;
_clickTargetArmedFrame = Time.frameCount;
_clickTargetArmedWithTouch = Input.touchCount > 0;
LogDebug(
$"ClickTarget armed, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}, " +
$"pointer={pointerDownPosition}, target={GetTransformPath(targetTransform)}");
}
}
if (!_clickTargetArmed)
return;
if (!TryGetPointerUpPosition(out var pointerUpPosition))
{
if (_clickTargetArmedWithTouch ||
Time.frameCount <= _clickTargetArmedFrame ||
Input.GetMouseButton(0))
{
return;
}
pointerUpPosition = Input.mousePosition;
}
var armedRect = _clickTargetArmedRect;
var armedShape = _clickTargetArmedShape;
_clickTargetArmed = false;
if (_dialog.ContainsScreenPoint(pointerUpPosition))
{
LogDebug($"Pointer release ignored because it hit dialog, pointer={pointerUpPosition}");
return;
}
if (!IsPointInsideTarget(armedRect, armedShape, pointerUpPosition))
{
LogDebug($"Pointer release outside armed target, pointer={pointerUpPosition}, rect={armedRect}");
return;
}
LogDebug(
$"ClickTarget candidate accepted, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}, " +
$"pointer={pointerUpPosition}, target={GetTransformPath(targetTransform)}");
QueueStepCompletion();
}
private static bool IsPointInsideTarget(Rect targetRect, ShrinkTutorialMaskShape maskShape, Vector2 screenPosition)
{
return maskShape == ShrinkTutorialMaskShape.Circle
? IsPointInsideCircle(targetRect, screenPosition)
: targetRect.Contains(screenPosition);
}
private static bool IsPointInsideCircle(Rect targetRect, Vector2 screenPosition)
{
var center = targetRect.center;
var radius = Mathf.Min(targetRect.width, targetRect.height) * 0.5f;
return Vector2.SqrMagnitude(screenPosition - center) <= radius * radius;
}
private static bool TryGetPointerDownPosition(out Vector2 screenPosition)
{
if (Input.touchCount > 0)
{
var touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began)
{
screenPosition = touch.position;
return true;
}
}
if (Input.GetMouseButtonDown(0))
{
screenPosition = Input.mousePosition;
return true;
}
screenPosition = default;
return false;
}
private static bool TryGetPointerUpPosition(out Vector2 screenPosition)
{
if (Input.touchCount > 0)
{
var touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Ended)
{
screenPosition = touch.position;
return true;
}
}
if (Input.GetMouseButtonUp(0))
{
screenPosition = Input.mousePosition;
return true;
}
screenPosition = default;
return false;
}
private bool WasPointerRoutedToTarget(Transform targetTransform, Vector2 screenPosition)
{
var eventSystem = EventSystem.current;
if (!eventSystem)
return true;
_pointerRaycastResults.Clear();
var pointerEventData = new PointerEventData(eventSystem)
{
position = screenPosition
};
eventSystem.RaycastAll(pointerEventData, _pointerRaycastResults);
foreach (var result in _pointerRaycastResults)
{
var hitTransform = result.gameObject ? result.gameObject.transform : null;
if (IsSameOrNestedTarget(hitTransform, targetTransform))
{
LogDebug(
$"Raycast matched target, pointer={screenPosition}, target={GetTransformPath(targetTransform)}, " +
$"hit={GetTransformPath(hitTransform)}");
return true;
}
}
var hitSummary = _pointerRaycastResults.Count == 0
? "<none>"
: string.Join(" | ", _pointerRaycastResults.Take(5).Select(result =>
{
var hitTransform = result.gameObject ? result.gameObject.transform : null;
return GetTransformPath(hitTransform);
}));
LogDebug(
$"Raycast missed target, pointer={screenPosition}, target={GetTransformPath(targetTransform)}, hits={hitSummary}");
return false;
}
private static bool IsSameOrNestedTarget(Transform hitTransform, Transform targetTransform)
{
return hitTransform &&
targetTransform &&
(hitTransform == targetTransform ||
hitTransform.IsChildOf(targetTransform) ||
targetTransform.IsChildOf(hitTransform));
}
private void QueueStepCompletion()
{
if (_stepCompletionQueued || !_currentTutorial)
return;
LogDebug($"QueueStepCompletion, tutorial={_currentTutorial.tutorialId}, step={_currentStepIndex}");
_stepCompletionQueued = true;
StartCoroutine(CompleteStepAtEndOfFrame(_currentTutorial.tutorialId, _currentStepIndex));
}
private IEnumerator CompleteStepAtEndOfFrame(string tutorialId, int stepIndex)
{
yield return new WaitForEndOfFrame();
_stepCompletionQueued = false;
if (_currentTutorial &&
!_skipRequested &&
!_stepCompleted &&
string.Equals(_currentTutorial.tutorialId, tutorialId, StringComparison.Ordinal) &&
_currentStepIndex == stepIndex)
{
LogDebug($"Step completed at end of frame, tutorial={tutorialId}, step={stepIndex}");
_stepCompleted = true;
}
}
private void EmitSignal(string signalName)
{
if (string.IsNullOrWhiteSpace(signalName) || !_currentTutorial)
return;
OnSignalTriggered?.Invoke(new ShrinkTutorialSignalArgs(
_currentTutorial.tutorialId,
_currentStepIndex,
signalName));
}
private void CleanupAfterTutorial()
{
_mask.ClearHighlight();
_dialog.Hide();
_currentTutorial = null;
_currentStepIndex = -1;
_runner = null;
_stepCompletionQueued = false;
_clickTargetArmed = false;
TryStartQueuedTutorial();
}
private void EnsureUi()
{
if (_canvas && _mask && _dialog)
return;
if (canvasOverride && maskOverride && dialogOverride)
{
_canvas = canvasOverride;
_mask = maskOverride;
_dialog = dialogOverride;
if (!_dialog.BindExisting())
throw new InvalidOperationException("ShrinkTutorial explicit dialog bindings are incomplete.");
_dialog.Hide();
return;
}
if (!allowRuntimeUiFallback)
{
throw new InvalidOperationException(
"ShrinkTutorialManager requires explicit Canvas, Mask and Dialog bindings when runtime UI fallback is disabled.");
}
var settings = ResolveSettings();
var canvasObject = new GameObject("ShrinkTutorialCanvas", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler),
typeof(GraphicRaycaster));
canvasObject.transform.SetParent(transform, false);
_canvas = canvasObject.GetComponent<Canvas>();
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
_canvas.sortingOrder = settings.canvasSortingOrder;
var scaler = canvasObject.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.matchWidthOrHeight = 0.5f;
var maskObject = new GameObject("Mask", typeof(RectTransform), typeof(CanvasRenderer), typeof(ShrinkTutorialMask));
maskObject.transform.SetParent(canvasObject.transform, false);
StretchFull(maskObject.GetComponent<RectTransform>());
_mask = maskObject.GetComponent<ShrinkTutorialMask>();
_mask.color = settings.maskColor;
var dialogObject = new GameObject("Dialog", typeof(RectTransform), typeof(ShrinkTutorialDialog));
dialogObject.transform.SetParent(canvasObject.transform, false);
_dialog = dialogObject.GetComponent<ShrinkTutorialDialog>();
_dialog.Initialize(canvasObject.transform, SkipCurrent);
_dialog.Hide();
}
private void EnsureEventSystem()
{
if (FindObjectOfType<EventSystem>())
return;
var eventSystemObject = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule));
DontDestroyOnLoad(eventSystemObject);
}
private bool TryGetDatabaseTutorial(string tutorialId, out ShrinkTutorialData tutorial)
{
tutorial = null;
var settings = ResolveSettings();
return settings.database && settings.database.TryGetTutorial(tutorialId, out tutorial);
}
private ShrinkTutorialSettings ResolveSettings()
{
return settingsOverride ? settingsOverride : ShrinkTutorialSettings.Instance;
}
private bool ArePrerequisitesSatisfied(ShrinkTutorialData tutorial)
{
if (!tutorial || tutorial.prerequisiteIds == null)
return true;
foreach (var prerequisiteId in tutorial.prerequisiteIds)
{
if (string.IsNullOrWhiteSpace(prerequisiteId))
continue;
if (!_completedTutorials.Contains(prerequisiteId))
return false;
}
return true;
}
private void EnqueueTutorial(string tutorialId)
{
if (_pendingTutorialIds.Contains(tutorialId))
return;
_pendingTutorialIds.Add(tutorialId);
_pendingTutorialIds.Sort(ComparePendingTutorial);
}
private int ComparePendingTutorial(string leftId, string rightId)
{
var leftPriority = TryGetDatabaseTutorial(leftId, out var leftTutorial) ? leftTutorial.priority : 0;
var rightPriority = TryGetDatabaseTutorial(rightId, out var rightTutorial) ? rightTutorial.priority : 0;
return rightPriority.CompareTo(leftPriority);
}
private void TryStartQueuedTutorial()
{
for (var i = 0; i < _pendingTutorialIds.Count; i++)
{
var tutorialId = _pendingTutorialIds[i];
if (!TryGetDatabaseTutorial(tutorialId, out var tutorial) || !ArePrerequisitesSatisfied(tutorial))
continue;
_pendingTutorialIds.RemoveAt(i);
StartTutorial(tutorialId);
return;
}
}
private void MarkCompleted(string tutorialId)
{
_completedTutorials.Add(tutorialId);
_skippedTutorials.Remove(tutorialId);
SaveProgress();
}
private void MarkSkipped(string tutorialId)
{
_skippedTutorials.Add(tutorialId);
SaveProgress();
}
private void SaveProgress()
{
_storage.Save(new ShrinkTutorialProgress
{
completedTutorials = new List<string>(_completedTutorials),
skippedTutorials = new List<string>(_skippedTutorials)
});
}
private void SetUiVisible(bool visible)
{
if (_canvas)
_canvas.enabled = visible;
}
private static void StretchFull(RectTransform rectTransform)
{
rectTransform.anchorMin = Vector2.zero;
rectTransform.anchorMax = Vector2.one;
rectTransform.offsetMin = Vector2.zero;
rectTransform.offsetMax = Vector2.zero;
rectTransform.pivot = new Vector2(0.5f, 0.5f);
}
[System.Diagnostics.Conditional("UNITY_EDITOR")]
private static void LogDebug(string message)
{
if (!IsDebugLoggingEnabled())
return;
Debug.Log($"[ShrinkTutorial][Debug] {message}");
}
[System.Diagnostics.Conditional("UNITY_EDITOR")]
private static void LogDebug(string format, params object[] args)
{
if (!IsDebugLoggingEnabled())
return;
Debug.LogFormat("[ShrinkTutorial][Debug] " + format, args);
}
private static bool IsDebugLoggingEnabled()
{
return false;
}
private static string GetTransformPath(Transform transform)
{
if (!transform)
return "<null>";
var path = transform.name;
while (transform.parent)
{
transform = transform.parent;
path = $"{transform.name}/{path}";
}
return path;
}
}
public readonly struct ShrinkTutorialSignalArgs
{
public ShrinkTutorialSignalArgs(string tutorialId, int stepIndex, string signalName)
{
TutorialId = tutorialId;
StepIndex = stepIndex;
SignalName = signalName;
}
public string TutorialId { get; }
public int StepIndex { get; }
public string SignalName { get; }
}
}