From 846d9c8a6968257bf0582610d634ab06016c3b64 Mon Sep 17 00:00:00 2001 From: cneicy Date: Sun, 30 Aug 2026 20:46:57 +0800 Subject: [PATCH] fix(tutorial): preserve click targets across UI transitions --- CHANGELOG.md | 11 ++- README.md | 5 ++ Runtime/Core/ShrinkTutorialManager.cs | 120 +++++++++++++++++++++----- Runtime/UI/ShrinkTutorialDialog.cs | 26 +++++- package.json | 2 +- 5 files changed, 133 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a46ac0b..31ec167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## 0.1.1 +## 0.1.3 -- 声明 `JsonUtility` 所需的 Unity JSON Serialize 模块,使包可在最小消费者项目中独立解析与编译。 +- 修复 `ClickTarget` 在目标按钮点击后立即打开、关闭或替换 UI 时误判未命中的问题;现在按下时确认原目标,松开时按按下时的目标区域完成步骤。 +- 支持在场景中显式绑定教程 Canvas、遮罩和提示框,并可关闭运行时 UI 生成。 -## Unreleased +## 0.1.2 - 修复 `ClickTarget` 对 UI 目标过早在按下阶段完成的问题,改为等待指针释放并确认 EventSystem 射线真实命中目标后再过步。 - 修复隐藏中的 `Path` 目标仍被教程当作可用目标的问题;现在 inactive 目标会继续等待,不会把提示框提前挂到尚未显示的控件上。 @@ -12,6 +13,10 @@ - 修复教程结束或 `maskShape=None` 步骤下遮罩仍继续绘制/拦截的问题;现在无高亮时蒙版会完全隐藏。 - 修复 `ShrinkTutorialSample` 在前一次演示完成后下次 Play 不再显示教程的问题;样例现在会在启动时清掉自身教程进度再重新播放。 +## 0.1.1 + +- 声明 `JsonUtility` 所需的 Unity JSON Serialize 模块,使包可在最小消费者项目中独立解析与编译。 + ## 0.1.0 - 初始版本。 diff --git a/README.md b/README.md index 0d3918a..2c02f3d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ Assets/Modules/ShrinkTutorial/ ShrinkTutorialManager.Instance.StartTutorial("tut.inventory.open"); ``` +如果项目要求所有 UI 预先存在于场景,可在 `ShrinkTutorialManager` 上显式绑定 +`Canvas Override`、`Mask Override` 与 `Dialog Override`,并关闭 +`Allow Runtime Ui Fallback`。`ShrinkTutorialDialog` 的内容文本、箭头与跳过按钮也要 +在场景中序列化绑定;该模式不会在运行时创建教程 UI。 + 也可以给入口物体挂 `ShrinkTutorialTrigger`,在 `OnEnable` 首次可见时自动触发。 ### 4. 动态对象锚点 diff --git a/Runtime/Core/ShrinkTutorialManager.cs b/Runtime/Core/ShrinkTutorialManager.cs index ea29612..3140467 100644 --- a/Runtime/Core/ShrinkTutorialManager.cs +++ b/Runtime/Core/ShrinkTutorialManager.cs @@ -15,6 +15,12 @@ namespace ShrinkTutorial [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(); @@ -32,6 +38,11 @@ namespace ShrinkTutorial 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; @@ -202,6 +213,7 @@ namespace ShrinkTutorial _skipRequested = false; _stepCompleted = false; _stepCompletionQueued = false; + _clickTargetArmed = false; _stepAutoTimer = tutorial.steps[stepIndex].autoDelay; _receivedCustomEvent = string.Empty; @@ -298,16 +310,7 @@ namespace ShrinkTutorial } 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(); - } + TickClickTargetCompletion(step, targetTransform); break; case ShrinkTutorialCompleteCondition.CustomEvent: case ShrinkTutorialCompleteCondition.DragToTarget: @@ -359,24 +362,76 @@ namespace ShrinkTutorial return pressed && !_dialog.ContainsScreenPoint(screenPosition); } - private bool WasPrimaryPointerReleased(Rect targetRect, ShrinkTutorialMaskShape maskShape, out Vector2 screenPosition) + private void TickClickTargetCompletion(ShrinkTutorialStep step, Transform targetTransform) { - if (!TryGetPointerUpPosition(out screenPosition)) - return false; - - if (_dialog.ContainsScreenPoint(screenPosition)) + if (!_clickTargetArmed && + targetTransform && + ShrinkTutorialTargetUtility.TryBuildScreenRect(targetTransform, out var targetRect) && + TryGetPointerDownPosition(out var pointerDownPosition)) { - LogDebug($"Pointer release ignored because it hit dialog, pointer={screenPosition}"); - return false; + 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)}"); + } } - var insideTarget = maskShape == ShrinkTutorialMaskShape.Circle + 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); - if (!insideTarget) - LogDebug($"Pointer release outside target, pointer={screenPosition}, rect={targetRect}"); - - return insideTarget; } private static bool IsPointInsideCircle(Rect targetRect, Vector2 screenPosition) @@ -430,7 +485,7 @@ namespace ShrinkTutorial return false; } - private bool WasPointerReleaseRoutedToTarget(Transform targetTransform, Vector2 screenPosition) + private bool WasPointerRoutedToTarget(Transform targetTransform, Vector2 screenPosition) { var eventSystem = EventSystem.current; if (!eventSystem) @@ -521,6 +576,7 @@ namespace ShrinkTutorial _currentStepIndex = -1; _runner = null; _stepCompletionQueued = false; + _clickTargetArmed = false; TryStartQueuedTutorial(); } @@ -530,6 +586,24 @@ namespace ShrinkTutorial 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)); diff --git a/Runtime/UI/ShrinkTutorialDialog.cs b/Runtime/UI/ShrinkTutorialDialog.cs index 0dbf422..5e702f5 100644 --- a/Runtime/UI/ShrinkTutorialDialog.cs +++ b/Runtime/UI/ShrinkTutorialDialog.cs @@ -7,12 +7,30 @@ namespace ShrinkTutorial { public class ShrinkTutorialDialog : MonoBehaviour { - private RectTransform _rectTransform; - private RectTransform _arrowRectTransform; - private TextMeshProUGUI _contentText; - private GameObject _skipButtonObject; + [Header("Explicit scene bindings")] + [SerializeField] private RectTransform _rectTransform; + [SerializeField] private RectTransform _arrowRectTransform; + [SerializeField] private TextMeshProUGUI _contentText; + [SerializeField] private GameObject _skipButtonObject; private Action _skipAction; + /// + /// Uses UI objects that already exist in the scene. This path intentionally does not + /// create children or add listeners at runtime; scene-authored persistent callbacks + /// remain the source of truth. + /// + public bool BindExisting() + { + if (!_rectTransform) + _rectTransform = GetComponent(); + + if (_rectTransform && _arrowRectTransform && _contentText) + return true; + + Debug.LogError("[ShrinkTutorial] Explicit dialog bindings are incomplete.", this); + return false; + } + public void Initialize(Transform parent, Action skipAction) { _skipAction = skipAction; diff --git a/package.json b/package.json index 35e1b39..66ab673 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.cneicy.shrink-tutorial", - "version": "0.1.2", + "version": "0.1.3", "displayName": "ShrinkTutorial", "description": "面向 Unity 的互动式新手引导系统,提供数据驱动教程、遮罩挖洞、动态锚点、步骤条件与运行时调度。", "unity": "2022.3",