chore: initialize standalone UPM package
Publish UPM package / publish (push) Failing after 1s

This commit is contained in:
2026-08-26 02:50:43 +08:00
commit 1180be88a6
72 changed files with 3331 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using UnityEngine;
namespace ShrinkTutorial
{
[CreateAssetMenu(fileName = "ShrinkTutorialData", menuName = "ShrinkTutorial/Tutorial Data")]
public class ShrinkTutorialData : ScriptableObject
{
public string tutorialId = string.Empty;
public int priority;
public string[] prerequisiteIds = new string[0];
public List<ShrinkTutorialStep> steps = new();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 356e79bb4dd3ef04099347bbe1dbca2b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+93
View File
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using UnityEngine;
namespace ShrinkTutorial
{
[CreateAssetMenu(fileName = "ShrinkTutorialDatabase", menuName = "ShrinkTutorial/Tutorial Database")]
public class ShrinkTutorialDatabase : ScriptableObject
{
[SerializeField] private List<ShrinkTutorialData> tutorials = new();
private Dictionary<string, ShrinkTutorialData> _cache;
public IReadOnlyList<ShrinkTutorialData> Tutorials => tutorials;
public bool TryGetTutorial(string tutorialId, out ShrinkTutorialData tutorial)
{
EnsureCache();
return _cache.TryGetValue(tutorialId, out tutorial);
}
public List<string> Validate()
{
var issues = new List<string>();
var ids = new HashSet<string>();
foreach (var tutorial in tutorials)
{
if (!tutorial)
{
issues.Add("存在空的教程资源引用。");
continue;
}
if (string.IsNullOrWhiteSpace(tutorial.tutorialId))
{
issues.Add($"教程资源 {tutorial.name} 缺少 tutorialId。");
}
else if (!ids.Add(tutorial.tutorialId))
{
issues.Add($"tutorialId 重复: {tutorial.tutorialId}");
}
if (tutorial.steps == null || tutorial.steps.Count == 0)
{
issues.Add($"教程 {tutorial.tutorialId} 没有任何步骤。");
continue;
}
for (var i = 0; i < tutorial.steps.Count; i++)
{
var step = tutorial.steps[i];
if (step == null)
{
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步为空。");
continue;
}
if (step.targetMode == ShrinkTutorialTargetMode.Path && string.IsNullOrWhiteSpace(step.targetPath))
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步缺少 targetPath。");
if (step.targetMode == ShrinkTutorialTargetMode.AnchorId && string.IsNullOrWhiteSpace(step.anchorId))
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步缺少 anchorId。");
if (step.completeCondition == ShrinkTutorialCompleteCondition.CustomEvent &&
string.IsNullOrWhiteSpace(step.customEventName))
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步使用 CustomEvent 但未填写 customEventName。");
}
}
return issues;
}
private void OnValidate()
{
_cache = null;
}
private void EnsureCache()
{
if (_cache != null)
return;
_cache = new Dictionary<string, ShrinkTutorialData>();
foreach (var tutorial in tutorials)
{
if (!tutorial || string.IsNullOrWhiteSpace(tutorial.tutorialId))
continue;
_cache[tutorial.tutorialId] = tutorial;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b0483b6d3c8490947a769ec2f8afdc34
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+34
View File
@@ -0,0 +1,34 @@
namespace ShrinkTutorial
{
public enum ShrinkTutorialTargetMode
{
None = 0,
Path = 1,
AnchorId = 2
}
public enum ShrinkTutorialCompleteCondition
{
ClickTarget = 0,
AnyClick = 1,
CustomEvent = 2,
Auto = 3,
DragToTarget = 4
}
public enum ShrinkTutorialDialogAnchor
{
Auto = 0,
Top = 1,
Bottom = 2,
Left = 3,
Right = 4
}
public enum ShrinkTutorialMaskShape
{
None = 0,
Rect = 1,
Circle = 2
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a542a494fb74e964d80f595ce7b5ad19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
namespace ShrinkTutorial
{
public interface IShrinkTutorialLocalizationProvider
{
bool TryResolve(string key, out string text);
}
public static class ShrinkTutorialLocalization
{
public static IShrinkTutorialLocalizationProvider Provider { get; set; }
public static string Resolve(string key, string fallbackText)
{
if (!string.IsNullOrWhiteSpace(key) &&
Provider != null &&
Provider.TryResolve(key, out var localizedText) &&
!string.IsNullOrWhiteSpace(localizedText))
{
return localizedText;
}
if (!string.IsNullOrWhiteSpace(fallbackText))
return fallbackText;
return key ?? string.Empty;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b59e81c0301fa140b198d020c18e829
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+717
View File
@@ -0,0 +1,717 @@
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<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 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;
_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:
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
? "<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;
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>();
_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; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 11fb462c0e7f4f04ab5312d000322af4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
using UnityEngine;
namespace ShrinkTutorial
{
public static class ShrinkTutorialRuntimeBootstrap
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticState()
{
ShrinkTutorialAnchorRegistry.Reset();
ShrinkTutorialManager.ResetStaticState();
ShrinkTutorialSettings.Instance = null;
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void AutoCreateManager()
{
var settings = ShrinkTutorialSettings.Instance;
if (settings && !settings.autoCreateManager)
return;
ShrinkTutorialManager.EnsureInstance(settings);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f4103feef1d168648aac09027fa8da2b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+61
View File
@@ -0,0 +1,61 @@
using UnityEngine;
namespace ShrinkTutorial
{
[CreateAssetMenu(fileName = "ShrinkTutorialSettings", menuName = "ShrinkTutorial/Settings")]
public class ShrinkTutorialSettings : ScriptableObject
{
private static ShrinkTutorialSettings _instance;
public static ShrinkTutorialSettings Instance
{
get
{
if (_instance)
return _instance;
_instance = Resources.Load<ShrinkTutorialSettings>("ShrinkTutorialSettings");
#if UNITY_EDITOR
if (!_instance)
{
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkTutorialSettings");
if (guids.Length > 0)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkTutorialSettings>(path);
}
}
#endif
if (!_instance)
{
_instance = CreateInstance<ShrinkTutorialSettings>();
Debug.LogWarning(
"[ShrinkTutorial] 未找到 ShrinkTutorialSettings,当前使用默认配置。\n" +
"请通过 Assets -> Create -> ShrinkTutorial -> Settings 创建配置资源。");
}
return _instance;
}
internal set => _instance = value;
}
[Header("数据")]
public ShrinkTutorialDatabase database;
[Header("运行时")]
public bool autoCreateManager = true;
public bool dontDestroyOnLoad = true;
public int canvasSortingOrder = 9999;
[Header("遮罩")]
public Color maskColor = new(0f, 0f, 0f, 0.72f);
[Header("存储")]
public string playerPrefsStorageKey = "ShrinkTutorial.Progress";
[Header("交互")]
public bool showSkipButton = true;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 975bc7797d2338c42b237370d7ea7fcc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+44
View File
@@ -0,0 +1,44 @@
using System;
using UnityEngine;
namespace ShrinkTutorial
{
[Serializable]
public class ShrinkTutorialStep
{
[Header("目标定位")]
public ShrinkTutorialTargetMode targetMode = ShrinkTutorialTargetMode.None;
public string targetPath = string.Empty;
public string anchorId = string.Empty;
public bool waitForTarget = true;
[Min(0f)] public float waitTimeout = 5f;
[Header("完成条件")]
public ShrinkTutorialCompleteCondition completeCondition = ShrinkTutorialCompleteCondition.ClickTarget;
public string customEventName = string.Empty;
[Min(0f)] public float autoDelay = 0.5f;
public string dragTargetAnchorId = string.Empty;
[Header("文案")]
public string localizedTextKey = string.Empty;
[TextArea(2, 6)] public string fallbackText = string.Empty;
[Header("表现")]
public ShrinkTutorialDialogAnchor dialogAnchor = ShrinkTutorialDialogAnchor.Auto;
public Vector2 dialogOffset = Vector2.zero;
public ShrinkTutorialMaskShape maskShape = ShrinkTutorialMaskShape.Rect;
[Min(0f)] public float maskPadding = 12f;
public bool showArrow = true;
[Header("信号")]
public string enterSignal = string.Empty;
public string exitSignal = string.Empty;
public bool RequiresTarget()
{
return completeCondition == ShrinkTutorialCompleteCondition.ClickTarget ||
completeCondition == ShrinkTutorialCompleteCondition.DragToTarget ||
targetMode != ShrinkTutorialTargetMode.None;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4fb75d0b9c8a11c4d8609ba4a30c68ee
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+172
View File
@@ -0,0 +1,172 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace ShrinkTutorial
{
internal static class ShrinkTutorialTargetUtility
{
public static Transform ResolveTarget(ShrinkTutorialStep step)
{
var target = step.targetMode switch
{
ShrinkTutorialTargetMode.Path => FindByHierarchyPath(step.targetPath),
ShrinkTutorialTargetMode.AnchorId => ShrinkTutorialAnchorRegistry.Get(step.anchorId),
_ => null
};
return IsTargetAvailable(target) ? target : null;
}
public static bool TryBuildScreenRect(Transform targetTransform, out Rect rect)
{
rect = default;
if (!IsTargetAvailable(targetTransform))
return false;
if (targetTransform is RectTransform rectTransform)
return TryBuildRectTransformScreenRect(rectTransform, out rect);
if (targetTransform.TryGetComponent<Renderer>(out var renderer))
return TryBuildBoundsScreenRect(renderer.bounds, out rect);
if (targetTransform.TryGetComponent<Collider>(out var collider))
return TryBuildBoundsScreenRect(collider.bounds, out rect);
var screenPoint = RectTransformUtility.WorldToScreenPoint(GetWorldCamera(), targetTransform.position);
rect = new Rect(screenPoint.x - 24f, screenPoint.y - 24f, 48f, 48f);
return true;
}
private static bool TryBuildRectTransformScreenRect(RectTransform rectTransform, out Rect rect)
{
var corners = new Vector3[4];
rectTransform.GetWorldCorners(corners);
var camera = GetCanvasCamera(rectTransform);
var min = RectTransformUtility.WorldToScreenPoint(camera, corners[0]);
var max = min;
for (var i = 1; i < corners.Length; i++)
{
var point = RectTransformUtility.WorldToScreenPoint(camera, corners[i]);
min = Vector2.Min(min, point);
max = Vector2.Max(max, point);
}
rect = Rect.MinMaxRect(min.x, min.y, max.x, max.y);
return rect.width > 0f && rect.height > 0f;
}
private static bool TryBuildBoundsScreenRect(Bounds bounds, out Rect rect)
{
rect = default;
var camera = GetWorldCamera();
if (!camera)
return false;
var corners = new List<Vector3>(8)
{
new(bounds.min.x, bounds.min.y, bounds.min.z),
new(bounds.min.x, bounds.min.y, bounds.max.z),
new(bounds.min.x, bounds.max.y, bounds.min.z),
new(bounds.min.x, bounds.max.y, bounds.max.z),
new(bounds.max.x, bounds.min.y, bounds.min.z),
new(bounds.max.x, bounds.min.y, bounds.max.z),
new(bounds.max.x, bounds.max.y, bounds.min.z),
new(bounds.max.x, bounds.max.y, bounds.max.z)
};
var min = new Vector2(float.MaxValue, float.MaxValue);
var max = new Vector2(float.MinValue, float.MinValue);
var hasVisiblePoint = false;
foreach (var corner in corners)
{
var screenPoint = camera.WorldToScreenPoint(corner);
if (screenPoint.z < 0f)
continue;
hasVisiblePoint = true;
var point = new Vector2(screenPoint.x, screenPoint.y);
min = Vector2.Min(min, point);
max = Vector2.Max(max, point);
}
if (!hasVisiblePoint)
return false;
rect = Rect.MinMaxRect(min.x, min.y, max.x, max.y);
return true;
}
private static Camera GetCanvasCamera(RectTransform rectTransform)
{
var canvas = rectTransform.GetComponentInParent<Canvas>();
if (!canvas || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
return null;
return canvas.worldCamera ? canvas.worldCamera : Camera.main;
}
private static Camera GetWorldCamera()
{
if (Camera.main)
return Camera.main;
foreach (var camera in Camera.allCameras)
{
if (camera && camera.enabled)
return camera;
}
return null;
}
private static bool IsTargetAvailable(Transform targetTransform)
{
return targetTransform && targetTransform.gameObject.activeInHierarchy;
}
private static Transform FindByHierarchyPath(string hierarchyPath)
{
if (string.IsNullOrWhiteSpace(hierarchyPath))
return null;
var segments = hierarchyPath.Split('/');
for (var sceneIndex = 0; sceneIndex < SceneManager.sceneCount; sceneIndex++)
{
var scene = SceneManager.GetSceneAt(sceneIndex);
if (!scene.isLoaded)
continue;
foreach (var root in scene.GetRootGameObjects())
{
if (TryMatchPath(root.transform, segments, 0, out var result))
return result;
}
}
return null;
}
private static bool TryMatchPath(Transform current, string[] segments, int segmentIndex, out Transform result)
{
result = null;
if (!current || segmentIndex >= segments.Length || current.name != segments[segmentIndex])
return false;
if (segmentIndex == segments.Length - 1)
{
result = current;
return true;
}
for (var i = 0; i < current.childCount; i++)
{
if (TryMatchPath(current.GetChild(i), segments, segmentIndex + 1, out result))
return true;
}
return false;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 73608135e8345684c9f9194f1a23363e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: