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 _pendingTutorialIds = new(); private readonly HashSet _completedTutorials = new(); private readonly HashSet _skippedTutorials = new(); private readonly List _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 Button _clickTargetButton; private ShrinkTutorialAnchor _clickTargetAnchor; 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 OnTutorialStarted; public event Action OnTutorialCompleted; public event Action OnTutorialSkipped; public event Action OnStepChanged; public event Action OnSignalTriggered; public static ShrinkTutorialManager EnsureInstance(ShrinkTutorialSettings settings = null) { if (_instance) return _instance; var existing = FindObjectOfType(); if (existing) return existing; var host = new GameObject("ShrinkTutorialManager"); var manager = host.AddComponent(); 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; UnbindClickTargetEvents(); _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)}"); BindClickTargetEvents(step, targetTransform); EmitSignal(step.enterSignal); _dialog.SetSkipVisible(ResolveSettings().showSkipButton); while (!_stepCompleted && !_skipRequested) { yield return WaitWhilePaused(); UpdateStepVisuals(step, targetTransform); TickStepCompletion(step, targetTransform); yield return null; } UnbindClickTargetEvents(); 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 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 (_clickTargetButton || _clickTargetAnchor) return; 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 void BindClickTargetEvents(ShrinkTutorialStep step, Transform targetTransform) { if (step.completeCondition != ShrinkTutorialCompleteCondition.ClickTarget || !targetTransform) return; _clickTargetButton = targetTransform.GetComponent