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; 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 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; _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 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: if (targetTransform && ShrinkTutorialTargetUtility.TryBuildScreenRect(targetTransform, out var targetRect) && WasPrimaryPointerReleased(targetRect, step.maskShape, out var screenPosition) && WasPointerReleaseRoutedToTarget(targetTransform, screenPosition)) { LogDebug( $"ClickTarget candidate accepted, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}, " + $"pointer={screenPosition}, target={GetTransformPath(targetTransform)}"); QueueStepCompletion(); } 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 bool WasPrimaryPointerReleased(Rect targetRect, ShrinkTutorialMaskShape maskShape, out Vector2 screenPosition) { if (!TryGetPointerUpPosition(out screenPosition)) return false; if (_dialog.ContainsScreenPoint(screenPosition)) { LogDebug($"Pointer release ignored because it hit dialog, pointer={screenPosition}"); return false; } var insideTarget = maskShape == ShrinkTutorialMaskShape.Circle ? IsPointInsideCircle(targetRect, screenPosition) : targetRect.Contains(screenPosition); if (!insideTarget) LogDebug($"Pointer release outside target, pointer={screenPosition}, rect={targetRect}"); return insideTarget; } 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 WasPointerReleaseRoutedToTarget(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 ? "" : 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; TryStartQueuedTutorial(); } private void EnsureUi() { if (_canvas && _mask && _dialog) return; 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.renderMode = RenderMode.ScreenSpaceOverlay; _canvas.sortingOrder = settings.canvasSortingOrder; var scaler = canvasObject.GetComponent(); 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()); _mask = maskObject.GetComponent(); _mask.color = settings.maskColor; var dialogObject = new GameObject("Dialog", typeof(RectTransform), typeof(ShrinkTutorialDialog)); dialogObject.transform.SetParent(canvasObject.transform, false); _dialog = dialogObject.GetComponent(); _dialog.Initialize(canvasObject.transform, SkipCurrent); _dialog.Hide(); } private void EnsureEventSystem() { if (FindObjectOfType()) 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(_completedTutorials), skippedTutorials = new List(_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 ""; 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; } } }