From d87f8d2a26aece34d5a89474475549d7185302af Mon Sep 17 00:00:00 2001 From: Senya Date: Fri, 10 Oct 2025 16:36:37 +0300 Subject: [PATCH] Implement path-driven QTE drag handling --- .../Assets/Scripts/QTESystem/QTEPhaseSetup.cs | 5 +- .../QTESystem/QTETargetMovementPathConfig.cs | 19 + .../Assets/Scripts/UI/QTE/QTEButtonView.cs | 4 +- .../Assets/Scripts/UI/QTE/QTEInvalidReason.cs | 13 + .../Scripts/UI/QTE/QTEPhasePresenter.cs | 10 +- .../Assets/Scripts/UI/QTE/QTERaycasterView.cs | 18 +- .../Assets/Scripts/UI/QTE/QTETapUIView.cs | 6 +- .../UI/QTE/QTETargetMovementOnCanvas.cs | 352 ++++++++++++++++-- 8 files changed, 384 insertions(+), 43 deletions(-) create mode 100644 Source/NotPokemonGo/Assets/Scripts/QTESystem/QTETargetMovementPathConfig.cs create mode 100644 Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEInvalidReason.cs diff --git a/Source/NotPokemonGo/Assets/Scripts/QTESystem/QTEPhaseSetup.cs b/Source/NotPokemonGo/Assets/Scripts/QTESystem/QTEPhaseSetup.cs index 8dce0b5..4bc394d 100644 --- a/Source/NotPokemonGo/Assets/Scripts/QTESystem/QTEPhaseSetup.cs +++ b/Source/NotPokemonGo/Assets/Scripts/QTESystem/QTEPhaseSetup.cs @@ -1,5 +1,4 @@ using System; -using Services.QTEServices; using UI.QTE; using UnityEngine.UI; @@ -18,6 +17,8 @@ public class QTEPhaseSetup public float Offset; public float TimeToNextTarget; + public QTETargetMovementPathConfig TargetMovementPathConfig; + // поле которое отвечает за то, через сколько появится следующая QTE } -} \ No newline at end of file +} diff --git a/Source/NotPokemonGo/Assets/Scripts/QTESystem/QTETargetMovementPathConfig.cs b/Source/NotPokemonGo/Assets/Scripts/QTESystem/QTETargetMovementPathConfig.cs new file mode 100644 index 0000000..4be1b7b --- /dev/null +++ b/Source/NotPokemonGo/Assets/Scripts/QTESystem/QTETargetMovementPathConfig.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace QTESystem +{ + [CreateAssetMenu(fileName = nameof(QTETargetMovementPathConfig), menuName = "StaticData/QTE/" + nameof(QTETargetMovementPathConfig))] + public class QTETargetMovementPathConfig : ScriptableObject + { + [SerializeField] private RectTransform _pathSpace; + [SerializeField] private Vector2[] _splinePoints; + [SerializeField, Min(0f)] private float _tolerance = 10f; + [SerializeField, Min(0f)] private float _timeLimit = 5f; + + public RectTransform PathSpace => _pathSpace; + public IReadOnlyList SplinePoints => _splinePoints; + public float Tolerance => _tolerance; + public float TimeLimit => _timeLimit; + } +} diff --git a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEButtonView.cs b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEButtonView.cs index 2542f71..5264ff5 100644 --- a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEButtonView.cs +++ b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEButtonView.cs @@ -8,9 +8,9 @@ public abstract class QTEButtonView : MonoBehaviour public float CurrentTime { get; protected set; } public abstract event Action Successed; - public abstract event Action Invalided; + public abstract event Action Invalided; public virtual void Initialize(QTEPhasePresenter qtePhasePresenter) { } } -} \ No newline at end of file +} diff --git a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEInvalidReason.cs b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEInvalidReason.cs new file mode 100644 index 0000000..829b0e8 --- /dev/null +++ b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEInvalidReason.cs @@ -0,0 +1,13 @@ +using System; + +namespace UI.QTE +{ + [Serializable] + public enum QTEInvalidReason + { + Unknown = 0, + Timeout = 1, + OutOfBounds = 2, + WrongInput = 3, + } +} diff --git a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEPhasePresenter.cs b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEPhasePresenter.cs index 36c5e3a..fb33e7a 100644 --- a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEPhasePresenter.cs +++ b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTEPhasePresenter.cs @@ -1,11 +1,9 @@ -using QTESystem; -using VContainer; +using QTESystem; namespace UI.QTE { public class QTEPhasePresenter { - private readonly IObjectResolver _objectResolver; private readonly QTEButtonView _qteButtonView; private bool _isActive; @@ -37,13 +35,13 @@ public void Disable() public bool IsActive() => _isActive; - private void OnInvalided(QTEButtonView qteButtonView) + private void OnInvalided(QTEButtonView qteButtonView, QTEInvalidReason reason) { qteButtonView.Invalided -= OnInvalided; _isActive = false; IsSuccess = false; } - + private void OnSuccessed(QTEButtonView qteButtonView) { qteButtonView.Successed -= OnSuccessed; @@ -51,4 +49,4 @@ private void OnSuccessed(QTEButtonView qteButtonView) IsSuccess = true; } } -} \ No newline at end of file +} diff --git a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTERaycasterView.cs b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTERaycasterView.cs index d502b3f..2e768e3 100644 --- a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTERaycasterView.cs +++ b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTERaycasterView.cs @@ -20,8 +20,8 @@ public class QTERaycasterView : QTEButtonView private int _clickCount; public override event Action Successed; - public override event Action Invalided; - + public override event Action Invalided; + [Inject] public void Construct(IInputReader inputReader, IRaycastService raycastService, ITargetSelector targetSelector) { @@ -29,7 +29,7 @@ public void Construct(IInputReader inputReader, IRaycastService raycastService, _targetSelector = targetSelector; _raycastService = raycastService; _inputReader.LeftMouseButtonPressed += OnLeftMouseButtonClicked; - + _units = _targetSelector.GetTargets(TargetMode.Single).Where(x => x != null).ToList(); foreach (var unit in _units) @@ -55,7 +55,7 @@ private void Update() if (CurrentTime >= _qtePhasePresenter.QtePhaseSetup.TargetTime) { - Invalided?.Invoke(this); + Invalided?.Invoke(this, QTEInvalidReason.Timeout); Debug.LogWarning("прошло время QTERaycasterView"); } } @@ -64,7 +64,7 @@ private void OnLeftMouseButtonClicked() { if (_raycastService.Raycast(out Unit unit) == false) { - Invalided?.Invoke(this); + Invalided?.Invoke(this, QTEInvalidReason.WrongInput); Debug.LogWarning("не попал в юнита QTERaycasterView"); return; @@ -84,12 +84,12 @@ private void OnLeftMouseButtonClicked() else { Debug.LogWarning("не попал в юнита в for QTERaycasterView"); - Invalided?.Invoke(this); + Invalided?.Invoke(this, QTEInvalidReason.WrongInput); } } } - if (_clickCount == _qtePhasePresenter.QtePhaseSetup.ClickCount) + if (_clickCount == _qtePhasePresenter.QtePhaseSetup.ClickCount) Successed?.Invoke(this); } @@ -112,7 +112,7 @@ public void MarkProcess() { _renderer.material.SetColor("_Color", Color.red); } - + public void MarkInterract() { _renderer.material.SetColor("_Color", Color.yellow); @@ -123,4 +123,4 @@ public void FinalizeProcess() _renderer.material.color = _defaultColor; } } -} \ No newline at end of file +} diff --git a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETapUIView.cs b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETapUIView.cs index 9b20c18..55f6891 100644 --- a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETapUIView.cs +++ b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETapUIView.cs @@ -28,7 +28,7 @@ public class QTETapUIView : QTEButtonView private Vector2 _visualEndSize; public override event Action Successed; - public override event Action Invalided; + public override event Action Invalided; private bool _isSuccesTime => CurrentTime >= TargetTime - Offset && CurrentTime <= TargetTime; @@ -96,7 +96,7 @@ private void Clicked() if (_isSuccesTime) Successed?.Invoke(this); else - Invalided?.Invoke(this); + Invalided?.Invoke(this, QTEInvalidReason.WrongInput); } public void Reset() @@ -105,4 +105,4 @@ public void Reset() rectTransform.anchoredPosition = Vector2.zero; } } -} \ No newline at end of file +} diff --git a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETargetMovementOnCanvas.cs b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETargetMovementOnCanvas.cs index 66d92aa..dd71bbd 100644 --- a/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETargetMovementOnCanvas.cs +++ b/Source/NotPokemonGo/Assets/Scripts/UI/QTE/QTETargetMovementOnCanvas.cs @@ -1,46 +1,356 @@ using System; +using QTESystem; using UnityEngine; +using UnityEngine.EventSystems; using UnityEngine.UI; namespace UI.QTE { - public class QTETargetMovementOnCanvas : QTEButtonView + public class QTETargetMovementOnCanvas : QTEButtonView, IBeginDragHandler, IDragHandler, IEndDragHandler { - private const float MaxValue = 1f; - - [SerializeField] private Slider _slider; - [SerializeField] private float _timer; - [SerializeField] private float _speed; + [SerializeField] private RectTransform _marker; + [SerializeField] private Image _progressFill; + [SerializeField] private Image _timeFill; public override event Action Successed; - public override event Action Invalided; + public override event Action Invalided; + public event Action OnProgressChanged; - private void OnEnable() => - _slider.onValueChanged.AddListener(OnSliderValueChanged); + private QTETargetMovementPathConfig _config; + private RectTransform _rectTransform; + private Canvas _canvas; + private Camera _uiCamera; + private Vector2[] _polyline; + private float[] _segmentLengths; + private float _totalLength; + private float _toleranceSqr; + private float _timeLimit; + private float _elapsedTime; + private float _progress; + private bool _isRunning; + private bool _isDragging; + private bool _hasCompleted; + private bool _pendingSuccess; + private bool _pendingFailure; + private QTEInvalidReason _pendingFailureReason; - private void OnDisable() => - _slider.onValueChanged.AddListener(OnSliderValueChanged); - - private void OnSliderValueChanged(float value) + public override void Initialize(QTEPhasePresenter qtePhasePresenter) { - if (Mathf.Approximately(value, MaxValue)) + base.Initialize(qtePhasePresenter); + + if (_rectTransform == null) { - Successed?.Invoke(this); + _rectTransform = (RectTransform)transform; + _canvas = GetComponentInParent(); + if (_canvas != null && _canvas.renderMode == RenderMode.ScreenSpaceCamera) + _uiCamera = _canvas.worldCamera; } + + Configure(qtePhasePresenter.QtePhaseSetup.TargetMovementPathConfig); } private void Update() { - if (_timer <= 0) + if (_pendingFailure) + { + _pendingFailure = false; + Fail(_pendingFailureReason); + return; + } + + if (_pendingSuccess) + { + _pendingSuccess = false; + UpdateProgress(1f); + return; + } + + if (_isRunning == false || _hasCompleted) + return; + + if (_timeLimit > 0f) + { + _elapsedTime += Time.deltaTime; + UpdateTimeIndicator(); + + if (_elapsedTime >= _timeLimit) + { + Fail(QTEInvalidReason.Timeout); + } + } + } + + public void OnBeginDrag(PointerEventData eventData) + { + if (_isRunning == false || eventData.button != PointerEventData.InputButton.Left) + return; + + _isDragging = true; + ProcessPointer(eventData); + } + + public void OnDrag(PointerEventData eventData) + { + if (_isRunning == false || _isDragging == false) + return; + + ProcessPointer(eventData); + } + + public void OnEndDrag(PointerEventData eventData) + { + if (_isDragging == false) + return; + + _isDragging = false; + } + + private void Configure(QTETargetMovementPathConfig config) + { + _config = config; + _elapsedTime = 0f; + _progress = 0f; + _hasCompleted = false; + _pendingSuccess = false; + _pendingFailure = false; + _isDragging = false; + + if (_config == null) + { + _isRunning = false; + _pendingFailure = true; + _pendingFailureReason = QTEInvalidReason.Unknown; + return; + } + + int pointCount = BuildPolyline(); + _toleranceSqr = _config.Tolerance * _config.Tolerance; + _timeLimit = _config.TimeLimit; + + if (pointCount == 0) + { + _isRunning = false; + _pendingFailure = true; + _pendingFailureReason = QTEInvalidReason.Unknown; + return; + } + + SetProgressInternal(0f, notify: false); + UpdateTimeIndicator(); + + if (_marker != null && _polyline != null && _polyline.Length > 0) + _marker.anchoredPosition = _polyline[0]; + + if (_totalLength <= Mathf.Epsilon) { - Invalided?.Invoke(this); - Debug.Log($"Failed in QTETargetMovementOnCanvas"); + _isRunning = false; + _pendingSuccess = true; } else { - float time = _timer -= (Time.deltaTime * _speed); - Debug.Log($"current time in QTETargetMovementOnCanvas - {time}"); + _isRunning = true; + } + } + + private int BuildPolyline() + { + var points = _config.SplinePoints; + int pointCount = points == null ? 0 : points.Count; + + if (pointCount == 0) + { + _polyline = Array.Empty(); + _segmentLengths = Array.Empty(); + _totalLength = 0f; + return 0; + } + + EnsurePolylineCapacity(pointCount); + + RectTransform pathSpace = _config.PathSpace; + for (int i = 0; i < pointCount; i++) + { + _polyline[i] = ConvertToLocal(points[i], pathSpace); + } + + int segmentCount = Mathf.Max(0, pointCount - 1); + EnsureSegmentCapacity(segmentCount); + + _totalLength = 0f; + for (int i = 0; i < segmentCount; i++) + { + Vector2 segment = _polyline[i + 1] - _polyline[i]; + float length = segment.magnitude; + _segmentLengths[i] = length; + _totalLength += length; } + + return pointCount; + } + + private void ProcessPointer(PointerEventData eventData) + { + if (_polyline == null || _polyline.Length == 0) + return; + + if (RectTransformUtility.ScreenPointToLocalPointInRectangle(_rectTransform, eventData.position, _uiCamera, out Vector2 localPoint) == false) + return; + + Vector2 projectedPoint; + float sqrDistance; + float progress = CalculateProgress(localPoint, out projectedPoint, out sqrDistance); + + if (_marker != null) + _marker.anchoredPosition = projectedPoint; + + if (sqrDistance > _toleranceSqr) + { + Fail(QTEInvalidReason.OutOfBounds); + return; + } + + if (progress > _progress) + UpdateProgress(progress); + } + + private float CalculateProgress(Vector2 position, out Vector2 projectedPoint, out float sqrDistance) + { + if (_polyline.Length == 1) + { + projectedPoint = _polyline[0]; + Vector2 diffSingle = position - projectedPoint; + sqrDistance = diffSingle.sqrMagnitude; + return _totalLength <= Mathf.Epsilon ? 1f : 0f; + } + + float bestDistance = float.MaxValue; + float bestProgress = 0f; + Vector2 bestPoint = _polyline[0]; + float accumulated = 0f; + + int lastIndex = _polyline.Length - 1; + for (int i = 0; i < lastIndex; i++) + { + Vector2 start = _polyline[i]; + Vector2 end = _polyline[i + 1]; + Vector2 segment = end - start; + float segmentLength = _segmentLengths[i]; + + float t = 0f; + Vector2 closestPoint = start; + + if (segmentLength > 0f) + { + float projection = Vector2.Dot(position - start, segment); + float denom = segmentLength * segmentLength; + projection /= denom; + + if (projection <= 0f) + { + t = 0f; + closestPoint = start; + } + else if (projection >= 1f) + { + t = 1f; + closestPoint = end; + } + else + { + t = projection; + closestPoint = new Vector2(start.x + segment.x * t, start.y + segment.y * t); + } + } + + Vector2 diff = position - closestPoint; + float distanceSqr = diff.sqrMagnitude; + if (distanceSqr < bestDistance) + { + bestDistance = distanceSqr; + bestPoint = closestPoint; + bestProgress = _totalLength > 0f ? (accumulated + segmentLength * t) / _totalLength : 1f; + } + + accumulated += segmentLength; + } + + projectedPoint = bestPoint; + sqrDistance = bestDistance; + return Mathf.Clamp01(bestProgress); + } + + private void UpdateProgress(float progress) + { + SetProgressInternal(progress, notify: true); + } + + private void SetProgressInternal(float progress, bool notify) + { + float clamped = Mathf.Clamp01(progress); + _progress = clamped; + + if (_progressFill != null) + _progressFill.fillAmount = clamped; + + if (notify) + { + OnProgressChanged?.Invoke(clamped); + + if (clamped >= 1f) + Complete(); + } + } + + private void UpdateTimeIndicator() + { + if (_timeFill == null || _timeLimit <= 0f) + return; + + float remaining = Mathf.Clamp01(1f - (_elapsedTime / _timeLimit)); + _timeFill.fillAmount = remaining; + } + + private void Complete() + { + if (_hasCompleted) + return; + + _hasCompleted = true; + _isRunning = false; + Successed?.Invoke(this); + } + + private void Fail(QTEInvalidReason reason) + { + if (_hasCompleted) + return; + + _hasCompleted = true; + _isRunning = false; + _isDragging = false; + Invalided?.Invoke(this, reason); + } + + private Vector2 ConvertToLocal(Vector2 point, RectTransform space) + { + if (space == null) + return point; + + Vector3 world = space.TransformPoint(new Vector3(point.x, point.y, 0f)); + Vector3 local = _rectTransform.InverseTransformPoint(world); + return new Vector2(local.x, local.y); + } + + private void EnsurePolylineCapacity(int count) + { + if (_polyline == null || _polyline.Length != count) + _polyline = count > 0 ? new Vector2[count] : Array.Empty(); + } + + private void EnsureSegmentCapacity(int count) + { + if (_segmentLengths == null || _segmentLengths.Length != count) + _segmentLengths = count > 0 ? new float[count] : Array.Empty(); } } -} \ No newline at end of file +}