using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; /// /// 判定线旁的实时偏移条:每次判定在横条上生成一个彩色点, /// x 位置表示偏移大小(左=偏晚 Late,右=偏早 Early),随后淡出。 /// 纯显示组件,订阅 ScoreManager.OnOffsetRecorded,不参与任何判定/计分逻辑。 /// 用法:挂到一个 RectTransform 上,作为横条容器;在 Inspector 指定 dotPrefab。 /// [RequireComponent(typeof(RectTransform))] public class OffsetBarDisplay : MonoBehaviour { [Header("References")] [Tooltip("单个偏移点的预制(应带 Image/RectTransform)。若留空则运行时自动生成一个圆点。")] public RectTransform dotPrefab; [Tooltip("横条容器;留空则用本物体的 RectTransform。")] public RectTransform barContainer; [Tooltip("可选:中线(完美判定 0ms)指示线,仅用于视觉参考。")] public RectTransform centerMarker; [Header("Mapping")] [Tooltip("映射到横条边缘的最大偏移(毫秒)。超出按此裁剪。")] public float maxOffsetMs = 120f; [Tooltip("横条半宽(像素)。点的 x 在 [-halfWidth, halfWidth] 内。留 <=0 则用容器宽度的一半。")] public float halfWidthPixels = 0f; [Header("Appearance")] public float dotLifetime = 0.9f; public float dotStartSize = 14f; public Color earlyColor = new Color(0.35f, 0.7f, 1f, 1f); public Color lateColor = new Color(1f, 0.55f, 0.35f, 1f); public Color perfectColor = new Color(0.6f, 1f, 0.6f, 1f); [Tooltip("|偏移| 小于此值视为完美,用 perfectColor。")] public float perfectThresholdMs = 8f; [Tooltip("对象池上限,避免高连击时无限实例化。")] public int maxPooledDots = 48; private class Dot { public RectTransform rect; public Image image; public float age; public bool active; } private readonly List pool = new List(); private void OnEnable() { ScoreManager.OnOffsetRecorded += HandleOffset; } private void OnDisable() { ScoreManager.OnOffsetRecorded -= HandleOffset; } private void Awake() { if (barContainer == null) barContainer = GetComponent(); } private float HalfWidth() { if (halfWidthPixels > 0f) return halfWidthPixels; if (barContainer != null) return barContainer.rect.width * 0.5f; return 200f; } // 正数 = 偏早(Early),负数 = 偏晚(Late)。见 ScoreManager.RecordOffset。 private void HandleOffset(float offsetMs) { if (!isActiveAndEnabled) return; Dot dot = GetPooledDot(); if (dot == null) return; float clamped = Mathf.Clamp(offsetMs, -maxOffsetMs, maxOffsetMs); float norm = maxOffsetMs > 0f ? clamped / maxOffsetMs : 0f; // [-1,1] // Early(正) 放右侧,Late(负) 放左侧。 float x = norm * HalfWidth(); Vector2 pos = dot.rect.anchoredPosition; pos.x = x; pos.y = 0f; dot.rect.anchoredPosition = pos; dot.rect.sizeDelta = new Vector2(dotStartSize, dotStartSize); Color c; if (Mathf.Abs(offsetMs) < perfectThresholdMs) c = perfectColor; else c = offsetMs > 0f ? earlyColor : lateColor; dot.image.color = c; dot.age = 0f; dot.active = true; dot.rect.gameObject.SetActive(true); } private void Update() { float dt = Time.unscaledDeltaTime; float life = Mathf.Max(0.01f, dotLifetime); for (int i = 0; i < pool.Count; i++) { Dot d = pool[i]; if (!d.active) continue; d.age += dt; float t = d.age / life; if (t >= 1f) { d.active = false; d.rect.gameObject.SetActive(false); continue; } Color c = d.image.color; c.a = 1f - t; d.image.color = c; } } private Dot GetPooledDot() { for (int i = 0; i < pool.Count; i++) { if (!pool[i].active) return pool[i]; } if (pool.Count >= maxPooledDots) { // 全部占用:复用最老的一个。 Dot oldest = pool[0]; for (int i = 1; i < pool.Count; i++) if (pool[i].age > oldest.age) oldest = pool[i]; return oldest; } return CreateDot(); } private Dot CreateDot() { RectTransform rect; Image img; if (dotPrefab != null) { rect = Instantiate(dotPrefab, barContainer); img = rect.GetComponent(); if (img == null) img = rect.gameObject.AddComponent(); } else { GameObject go = new GameObject("OffsetDot", typeof(RectTransform), typeof(Image)); rect = go.GetComponent(); rect.SetParent(barContainer, false); img = go.GetComponent(); } rect.anchorMin = rect.anchorMax = new Vector2(0.5f, 0.5f); rect.pivot = new Vector2(0.5f, 0.5f); rect.gameObject.SetActive(false); Dot dot = new Dot { rect = rect, image = img, age = 0f, active = false }; pool.Add(dot); return dot; } }