854 lines
31 KiB
C#
854 lines
31 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 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<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;
|
|
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<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 (_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<Button>() ??
|
|
targetTransform.GetComponentInParent<Button>() ??
|
|
targetTransform.GetComponentInChildren<Button>(true);
|
|
if (_clickTargetButton)
|
|
{
|
|
_clickTargetButton.onClick.AddListener(HandleClickTargetEvent);
|
|
LogDebug($"Bound ClickTarget button event: {GetTransformPath(_clickTargetButton.transform)}");
|
|
return;
|
|
}
|
|
|
|
_clickTargetAnchor = targetTransform.GetComponent<ShrinkTutorialAnchor>() ??
|
|
targetTransform.GetComponentInParent<ShrinkTutorialAnchor>() ??
|
|
targetTransform.GetComponentInChildren<ShrinkTutorialAnchor>(true);
|
|
if (_clickTargetAnchor)
|
|
{
|
|
_clickTargetAnchor.Clicked += HandleClickTargetPointerEvent;
|
|
LogDebug($"Bound ClickTarget anchor event: {GetTransformPath(_clickTargetAnchor.transform)}");
|
|
}
|
|
}
|
|
|
|
private void HandleClickTargetEvent()
|
|
{
|
|
LogDebug($"ClickTarget button event received, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}");
|
|
QueueStepCompletion();
|
|
}
|
|
|
|
private void HandleClickTargetPointerEvent(PointerEventData eventData)
|
|
{
|
|
if (eventData != null && eventData.button != PointerEventData.InputButton.Left)
|
|
return;
|
|
|
|
LogDebug($"ClickTarget pointer event received, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}");
|
|
QueueStepCompletion();
|
|
}
|
|
|
|
private void UnbindClickTargetEvents()
|
|
{
|
|
if (_clickTargetButton)
|
|
_clickTargetButton.onClick.RemoveListener(HandleClickTargetEvent);
|
|
|
|
if (_clickTargetAnchor)
|
|
_clickTargetAnchor.Clicked -= HandleClickTargetPointerEvent;
|
|
|
|
_clickTargetButton = null;
|
|
_clickTargetAnchor = null;
|
|
}
|
|
|
|
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;
|
|
UnbindClickTargetEvents();
|
|
|
|
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; }
|
|
}
|
|
}
|