using System.Collections.Generic; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; namespace game { /// /// UGUI 通用「摆动指针 + 分档倍率」:在 Inspector 配置 ,确认时按箭头落在哪一档返回倍率; /// 未命中任意 zone 时返回 。重叠区域按 从高到低判定。 /// public class UiSwingTierQte : MonoBehaviour { [Header("多档")] [SerializeField] private List tierBindings = new List(); [Tooltip("箭头未落在任一命中 zone 内时的倍率")] [SerializeField] private float missWhenNoHitMultiplier = 1f; [Header("引用")] [SerializeField] private RectTransform trackRect; [SerializeField] private RectTransform arrowRect; [SerializeField] private Button confirmButton; [Header("摆动")] [SerializeField] private float oscillationSpeed = 6f; [SerializeField] private bool deriveSwingExtentFromTrack; [SerializeField] private float swingEdgePadding; [SerializeField] private float swingHalfExtent = 200f; [SerializeField] private bool useUnscaledTime; [Header("事件")] [SerializeField] private UnityEvent onConfirmed; /// 供代码订阅 QTE 确认(Inspector 的 为 private)。 public void AddOnConfirmedListener(UnityAction listener) => onConfirmed?.AddListener(listener); public void RemoveOnConfirmedListener(UnityAction listener) => onConfirmed?.RemoveListener(listener); private bool _active; private float _phase; private readonly List _runtimeMultipliers = new List(); private float _missMultiplierRuntime = 1f; private readonly List _sortedEvalRows = new List(); private struct TierEvalRow { public RectTransform Zone; public float Multiplier; public int Priority; public int OriginalIndex; } public bool IsActive => _active; public IReadOnlyList TierBindings => tierBindings; public float MissWhenNoHitMultiplier => _missMultiplierRuntime; private bool HasTierBindings => tierBindings != null && tierBindings.Count > 0; /// /// 与 行数相同的规格列表(倍率 + 可选归一化宽度)。(0,0) 归一化表示不改该档 Rect 锚点。 /// public void ConfigureFromSpecs(IReadOnlyList specs) { if (!HasTierBindings) { Debug.LogWarning($"[{nameof(UiSwingTierQte)}] 请先在 Inspector 配置 tierBindings。", this); return; } if (specs == null || specs.Count != tierBindings.Count) { Debug.LogError($"[{nameof(UiSwingTierQte)}] specs 数量必须为 tierBindings.Count = {tierBindings.Count}。", this); return; } _runtimeMultipliers.Clear(); for (int i = 0; i < specs.Count; i++) { var s = specs[i]; _runtimeMultipliers.Add(s.Multiplier); var b = tierBindings[i]; if (b.zone == null) continue; if (!IsNormalizedSpanUnset(s.NormalizedMin, s.NormalizedMax)) ApplyHorizontalSpanFromNormalized(b.zone, s.NormalizedMin, s.NormalizedMax); } _missMultiplierRuntime = missWhenNoHitMultiplier; RebuildHitEvaluationOrder(); } /// 仅更新各档倍率,不改变 Rect 布局。 public void ConfigureMultipliers(IReadOnlyList multipliers) { if (!HasTierBindings) { Debug.LogWarning($"[{nameof(UiSwingTierQte)}] 请先在 Inspector 配置 tierBindings。", this); return; } if (multipliers == null || multipliers.Count != tierBindings.Count) { Debug.LogError($"[{nameof(UiSwingTierQte)}] multipliers 数量必须为 {tierBindings.Count}。", this); return; } _runtimeMultipliers.Clear(); for (int i = 0; i < multipliers.Count; i++) _runtimeMultipliers.Add(multipliers[i]); _missMultiplierRuntime = missWhenNoHitMultiplier; RebuildHitEvaluationOrder(); } /// 仅按归一化区间更新各档水平锚点;spans 与 tierBindings 等长,(0,0) 跳过该行。 public void ApplyNormalizedSpans(IReadOnlyList spans) { if (!HasTierBindings || spans == null || spans.Count != tierBindings.Count) { Debug.LogError($"[{nameof(UiSwingTierQte)}] ApplyNormalizedSpans 需要 tierBindings 非空且 spans.Count == tierBindings.Count。", this); return; } for (int i = 0; i < spans.Count; i++) { var z = tierBindings[i].zone; if (z == null) continue; var sp = spans[i]; if (!IsNormalizedSpanUnset(sp.x, sp.y)) ApplyHorizontalSpanFromNormalized(z, sp.x, sp.y); } } /// 运行时修改未命中倍率(与 Inspector 字段同步)。 public void SetMissWhenNoHitMultiplier(float missMultiplier) { missWhenNoHitMultiplier = missMultiplier; _missMultiplierRuntime = missMultiplier; } private void RebuildHitEvaluationOrder() { _sortedEvalRows.Clear(); if (!HasTierBindings) { _missMultiplierRuntime = missWhenNoHitMultiplier; return; } for (int i = 0; i < tierBindings.Count; i++) { var b = tierBindings[i]; if (b.zone == null) continue; float m = i < _runtimeMultipliers.Count ? _runtimeMultipliers[i] : 1f; _sortedEvalRows.Add(new TierEvalRow { Zone = b.zone, Multiplier = m, Priority = b.priority, OriginalIndex = i }); } _sortedEvalRows.Sort(CompareEvalRows); } private static int CompareEvalRows(TierEvalRow a, TierEvalRow b) { int c = b.Priority.CompareTo(a.Priority); return c != 0 ? c : a.OriginalIndex.CompareTo(b.OriginalIndex); } private static bool IsNormalizedSpanUnset(float min, float max) { return min == 0f && max == 0f; } private void ApplyHorizontalSpanFromNormalized(RectTransform zone, float minNorm, float maxNorm) { if (trackRect == null || zone == null) return; if (zone.parent != trackRect) { Debug.LogWarning( $"[{nameof(UiSwingTierQte)}] 「{zone.name}」应为「{trackRect.name}」的直接子节点,否则无法按归一化宽度设置档位区域。", this); return; } float lo = Mathf.Clamp01(Mathf.Min(minNorm, maxNorm)); float hi = Mathf.Clamp01(Mathf.Max(minNorm, maxNorm)); zone.anchorMin = new Vector2(lo, zone.anchorMin.y); zone.anchorMax = new Vector2(hi, zone.anchorMax.y); } private void Awake() { if (confirmButton != null) confirmButton.onClick.AddListener(Confirm); } private void OnEnable() { SyncRuntimeMultipliersFromInspectorDefaults(); _missMultiplierRuntime = missWhenNoHitMultiplier; RebuildHitEvaluationOrder(); } private void SyncRuntimeMultipliersFromInspectorDefaults() { if (!HasTierBindings) return; if (_runtimeMultipliers.Count == tierBindings.Count) return; _runtimeMultipliers.Clear(); for (int i = 0; i < tierBindings.Count; i++) _runtimeMultipliers.Add(1f); } private void OnDestroy() { if (confirmButton != null) confirmButton.onClick.RemoveListener(Confirm); } private void Update() { if (!_active || trackRect == null || arrowRect == null) return; float dt = useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime; _phase += dt * oscillationSpeed; float t = Mathf.Sin(_phase); float half = deriveSwingExtentFromTrack ? Mathf.Max(0f, trackRect.rect.width * 0.5f - swingEdgePadding) : swingHalfExtent; float x = t * half; var ap = arrowRect.anchoredPosition; ap.x = x; arrowRect.anchoredPosition = ap; } public void Begin() { _active = true; enabled = true; if (confirmButton != null) confirmButton.interactable = true; } public void RandomizePhase() { _phase = UnityEngine.Random.Range(0f, Mathf.PI * 2f); } public void Stop() { _active = false; if (confirmButton != null) confirmButton.interactable = false; } public void Confirm() { if (!_active) return; float mult = EvaluateMultiplierAtArrow(); Stop(); onConfirmed?.Invoke(mult); } public float PeekCurrentMultiplier() { return EvaluateMultiplierAtArrow(); } public float EvaluateMultiplierAtArrow() { if (trackRect == null || arrowRect == null) return _missMultiplierRuntime; Vector3 local = trackRect.InverseTransformPoint(arrowRect.position); for (int i = 0; i < _sortedEvalRows.Count; i++) { var row = _sortedEvalRows[i]; if (row.Zone != null && RectContainsLocalXByRectWidth(trackRect, row.Zone, local.x)) return row.Multiplier; } return _missMultiplierRuntime; } private static bool RectContainsLocalXByRectWidth(RectTransform track, RectTransform zone, float trackLocalX) { var corners = new Vector3[4]; zone.GetWorldCorners(corners); float minX = float.PositiveInfinity; float maxX = float.NegativeInfinity; for (int i = 0; i < 4; i++) { Vector3 zLocal = track.InverseTransformPoint(corners[i]); minX = Mathf.Min(minX, zLocal.x); maxX = Mathf.Max(maxX, zLocal.x); } return trackLocalX >= minX && trackLocalX <= maxX; } #if UNITY_EDITOR private void OnValidate() { oscillationSpeed = Mathf.Max(0.01f, oscillationSpeed); swingHalfExtent = Mathf.Max(0f, swingHalfExtent); swingEdgePadding = Mathf.Max(0f, swingEdgePadding); } #endif } }