Files
2026-07-30 23:15:58 +08:00

172 lines
5.3 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// 判定线旁的实时偏移条:每次判定在横条上生成一个彩色点,
/// x 位置表示偏移大小(左=偏晚 Late,右=偏早 Early),随后淡出。
/// 纯显示组件,订阅 ScoreManager.OnOffsetRecorded,不参与任何判定/计分逻辑。
/// 用法:挂到一个 RectTransform 上,作为横条容器;在 Inspector 指定 dotPrefab。
/// </summary>
[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<Dot> pool = new List<Dot>();
private void OnEnable()
{
ScoreManager.OnOffsetRecorded += HandleOffset;
}
private void OnDisable()
{
ScoreManager.OnOffsetRecorded -= HandleOffset;
}
private void Awake()
{
if (barContainer == null) barContainer = GetComponent<RectTransform>();
}
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<Image>();
if (img == null) img = rect.gameObject.AddComponent<Image>();
}
else
{
GameObject go = new GameObject("OffsetDot", typeof(RectTransform), typeof(Image));
rect = go.GetComponent<RectTransform>();
rect.SetParent(barContainer, false);
img = go.GetComponent<Image>();
}
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;
}
}