using UnityEngine; using UnityEngine.UI; using Platinio.TweenEngine; using VRBeats.ScriptableEvents; namespace VRBeats { public class ScoreManager : MonoBehaviour { private enum BeatJudgement { Perfect, Great, Good, Miss } [SerializeField] private Text multiplierLabel = null; [SerializeField] private Text scoreLabel = null; [SerializeField] private Image multiplierLoader = null; [SerializeField] private float scoreFollowTime = 1.0f; [SerializeField] private CanvasGroup canvasGroup = null; [SerializeField] private GameEvent onGameOver = null; [Header("DJMAX Style Score")] [SerializeField] private Text comboLabel = null; [SerializeField] private Text accuracyLabel = null; [SerializeField] private Text judgementLabel = null; [SerializeField] private bool createMissingHudLabels = true; [SerializeField] private bool applyHudPlacement = true; [SerializeField] private Vector2 hudAnchoredPosition = new Vector2(0.0f, 1.65f); [SerializeField] private float perfectWindow = 0.08f; [SerializeField] private float greatWindow = 0.15f; [SerializeField] private float goodWindow = 0.25f; private int maxMultiplier = 0; private const int MaxCourseScore = 1000000; private float currentMultiplier = 1.0f; private int acumulateErrors = 0; private int errorLimit = 0; private int totalNoteCount = 0; private int judgedNoteCount = 0; private int currentCombo = 0; private int maxCombo = 0; private int perfectCount = 0; private int greatCount = 0; private int goodCount = 0; private int missCount = 0; private int earnedAccuracyPoints = 0; private float visualScore = 0.0f; private int scoreTweenID = -1; private int loaderTweenID = -1; private BeatJudgement lastJudgement = BeatJudgement.Perfect; private float judgementTimer = 0.0f; private Text progressLabel = null; private Text rankLabel = null; private Vector3 comboBaseScale = Vector3.one; private float songCurrentTime = 0.0f; private float songDuration = 0.0f; private bool resultFinalized = false; private bool destroyed = false; private static bool hasPendingSliceTiming = false; private static float pendingSliceTiming = 0.0f; public int CurrentScore => Mathf.RoundToInt(MaxCourseScore * AccuracyPercent / 100.0f); public float AccuracyPercent { get { int denominatorNotes = totalNoteCount > 0 ? totalNoteCount : judgedNoteCount; if (denominatorNotes <= 0) return 100.0f; return (float)earnedAccuracyPoints / (denominatorNotes * 1000) * 100.0f; } } public string Rank { get { float accuracy = AccuracyPercent; if (accuracy >= 98.0f) return "S+"; if (accuracy >= 95.0f) return "S"; if (accuracy >= 90.0f) return "A"; if (accuracy >= 80.0f) return "B"; if (accuracy >= 70.0f) return "C"; if (accuracy >= 60.0f) return "D"; return "F"; } } private void Awake() { maxMultiplier = VR_BeatManager.instance.GameSettings.MaxMultiplier; errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit; if (multiplierLoader != null) multiplierLoader.fillAmount = 0.0f; PrepareHud(); } public static void ReportSliceTiming(float timingErrorSeconds) { pendingSliceTiming = timingErrorSeconds; hasPendingSliceTiming = true; } public static void ReportMiss() { hasPendingSliceTiming = false; pendingSliceTiming = 0.0f; } public void SetTotalNotes(int noteCount) { totalNoteCount = Mathf.Max(0, noteCount); resultFinalized = false; UpdateScoreTween(); } public void CompleteSong() { if (resultFinalized) return; resultFinalized = true; int missedUnjudgedNotes = Mathf.Max(0, totalNoteCount - judgedNoteCount); if (missedUnjudgedNotes > 0) { missCount += missedUnjudgedNotes; judgedNoteCount += missedUnjudgedNotes; currentCombo = 0; currentMultiplier = 1.0f; } UpdateScoreTween(); } public void SetSongProgress(float currentTime, float duration) { songCurrentTime = Mathf.Max(0.0f, currentTime); songDuration = Mathf.Max(0.0f, duration); } public void OnGameOver() { gameObject.CancelAllTweens(); if (canvasGroup != null) canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject); } public void OnGameRestart() { ResetThisComponent(); gameObject.CancelAllTweens(); if (canvasGroup != null) canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject); } public void ResetThisComponent() { currentMultiplier = 1.0f; visualScore = 0; acumulateErrors = 0; judgedNoteCount = 0; currentCombo = 0; maxCombo = 0; perfectCount = 0; greatCount = 0; goodCount = 0; missCount = 0; earnedAccuracyPoints = 0; judgementTimer = 0.0f; resultFinalized = false; hasPendingSliceTiming = false; pendingSliceTiming = 0.0f; if (multiplierLoader != null) multiplierLoader.fillAmount = 0.0f; } private void Update() { UpdateUI(); } public void OnCorrectSlice() { if (destroyed) return; BeatJudgement judgement = ConsumeJudgement(); RegisterJudgement(judgement); acumulateErrors = judgement == BeatJudgement.Miss ? acumulateErrors + 1 : 0; UpdateScoreTween(); UpdateMultiplierLoaderValue(); } public void OnIncorrectSlice() { if (destroyed) return; RegisterJudgement(BeatJudgement.Miss); acumulateErrors++; currentMultiplier = 1.0f; UpdateScoreTween(); UpdateMultiplierLoaderValue(); if (acumulateErrors > errorLimit) onGameOver.Invoke(); } public string BuildResultSummary(int minScoreLength) { string score = CurrentScore.ToString(); score = new string('0', Mathf.Max(minScoreLength - score.Length, 0)) + score; string badge = missCount == 0 ? (greatCount == 0 && goodCount == 0 ? "PERFECT PLAY" : "FULL COMBO") : "TRY AGAIN"; return $"{Rank}\n" + $"{score}\n" + $"ACC {AccuracyPercent:0.0}% {badge}\n" + $"MAX COMBO {maxCombo}\n" + $"P {perfectCount} G {greatCount} GOOD {goodCount} MISS {missCount}"; } private void CancelTweenById(int id) { if (id != -1) PlatinioTween.instance.CancelTween(id); } private void UpdateMultiplierLoaderValue() { if (destroyed || multiplierLoader == null) return; float multiplierLoaderValue = GetComboTierProgress(); CancelTweenById(loaderTweenID); loaderTweenID = PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, multiplierLoaderValue, 1.0f) .SetEase(Ease.EaseOutExpo) .SetOnUpdateFloat(delegate (float value) { if (multiplierLoader != null) multiplierLoader.fillAmount = value; }) .SetOwner(multiplierLoader.gameObject) .ID; } private void UpdateScoreTween() { CancelTweenById(scoreTweenID); scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, CurrentScore, scoreFollowTime) .SetEase(Ease.EaseOutExpo) .SetOnUpdateFloat(delegate (float value) { visualScore = value; }) .ID; } private void UpdateUI() { if (destroyed) return; if (multiplierLabel != null) multiplierLabel.text = $"x{Mathf.RoundToInt(currentMultiplier)}"; if (scoreLabel != null) scoreLabel.text = $"{Mathf.CeilToInt(visualScore):N0}"; if (comboLabel != null) comboLabel.text = currentCombo > 0 ? $"COMBO\n{currentCombo}" : "COMBO\n0"; if (accuracyLabel != null) accuracyLabel.text = $"{AccuracyPercent:0.0}%"; if (rankLabel != null) rankLabel.text = $"{Rank}"; if (progressLabel != null) progressLabel.text = songDuration > 0.0f ? $"{FormatTime(songCurrentTime)} / {FormatTime(songDuration)}" : ""; if (judgementLabel == null) return; judgementTimer -= Time.deltaTime; judgementLabel.text = judgementTimer > 0.0f ? GetJudgementText(lastJudgement) : ""; judgementLabel.color = GetJudgementColor(lastJudgement); } private BeatJudgement ConsumeJudgement() { if (!hasPendingSliceTiming) return BeatJudgement.Perfect; float timing = pendingSliceTiming; hasPendingSliceTiming = false; pendingSliceTiming = 0.0f; if (timing <= perfectWindow) return BeatJudgement.Perfect; if (timing <= greatWindow) return BeatJudgement.Great; if (timing <= goodWindow) return BeatJudgement.Good; return BeatJudgement.Good; } private void RegisterJudgement(BeatJudgement judgement) { lastJudgement = judgement; judgementTimer = 0.45f; judgedNoteCount++; if (judgement == BeatJudgement.Perfect) { perfectCount++; earnedAccuracyPoints += 1000; currentCombo++; } else if (judgement == BeatJudgement.Great) { greatCount++; earnedAccuracyPoints += 700; currentCombo++; } else if (judgement == BeatJudgement.Good) { goodCount++; earnedAccuracyPoints += 400; currentCombo++; } else { missCount++; currentCombo = 0; } maxCombo = Mathf.Max(maxCombo, currentCombo); currentMultiplier = judgement == BeatJudgement.Miss ? 1.0f : Mathf.Min(GetComboMultiplier(currentCombo), Mathf.Max(1.0f, maxMultiplier)); PulseComboLabel(judgement); } private static float GetComboMultiplier(int combo) { if (combo >= 200) return 1.5f; if (combo >= 100) return 1.35f; if (combo >= 50) return 1.2f; if (combo >= 20) return 1.1f; return 1.0f; } private static string GetJudgementText(BeatJudgement judgement) { switch (judgement) { case BeatJudgement.Perfect: return "PERFECT"; case BeatJudgement.Great: return "GREAT"; case BeatJudgement.Good: return "GOOD"; default: return "BREAK"; } } private static Color GetJudgementColor(BeatJudgement judgement) { switch (judgement) { case BeatJudgement.Perfect: return new Color(0.25f, 0.95f, 1.0f, 1.0f); case BeatJudgement.Great: return new Color(0.58f, 1.0f, 0.45f, 1.0f); case BeatJudgement.Good: return new Color(1.0f, 0.8f, 0.35f, 1.0f); default: return new Color(1.0f, 0.25f, 0.45f, 1.0f); } } private float GetComboTierProgress() { int lower = 0; int upper = 20; if (currentCombo >= 200) return 1.0f; if (currentCombo >= 100) { lower = 100; upper = 200; } else if (currentCombo >= 50) { lower = 50; upper = 100; } else if (currentCombo >= 20) { lower = 20; upper = 50; } return Mathf.InverseLerp(lower, upper, currentCombo); } private void PrepareHud() { RectTransform rect = transform as RectTransform; if (applyHudPlacement && rect != null) rect.anchoredPosition = hudAnchoredPosition; ConfigureText(scoreLabel, new Vector2(-255.0f, -18.0f), new Vector2(220.0f, 40.0f), 26, Color.white, TextAnchor.MiddleLeft); ConfigureText(multiplierLabel, new Vector2(255.0f, 36.0f), new Vector2(100.0f, 68.0f), 34, Color.white, TextAnchor.MiddleCenter); ConfigureImage(multiplierLoader, new Vector2(255.0f, 36.0f), new Vector2(104.0f, 104.0f)); if (!createMissingHudLabels) return; comboLabel ??= CreateHudText("Combo", new Vector2(-255.0f, 74.0f), new Vector2(220.0f, 112.0f), 36, Color.white, TextAnchor.MiddleLeft); accuracyLabel ??= CreateHudText("Accuracy", new Vector2(-255.0f, -58.0f), new Vector2(220.0f, 34.0f), 20, new Color(0.88f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleLeft); rankLabel ??= CreateHudText("Rank", new Vector2(-255.0f, -105.0f), new Vector2(220.0f, 76.0f), 48, Color.white, TextAnchor.MiddleLeft); judgementLabel ??= CreateHudText("Judgement", new Vector2(0.0f, 112.0f), new Vector2(260.0f, 54.0f), 28, new Color(0.25f, 0.95f, 1.0f, 1.0f), TextAnchor.MiddleCenter); progressLabel ??= CreateHudText("SongProgress", new Vector2(255.0f, -62.0f), new Vector2(180.0f, 34.0f), 18, Color.white, TextAnchor.MiddleCenter); comboBaseScale = comboLabel != null ? comboLabel.transform.localScale : Vector3.one; } private Text CreateHudText(string name, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment) { GameObject textObject = new GameObject(name); textObject.layer = gameObject.layer; textObject.transform.SetParent(transform, false); RectTransform rect = textObject.AddComponent(); textObject.AddComponent(); Text text = textObject.AddComponent(); ConfigureText(text, anchoredPosition, size, fontSize, color, alignment); return text; } private static void ConfigureText(Text text, Vector2 anchoredPosition, Vector2 size, int fontSize, Color color, TextAnchor alignment) { if (text == null) return; RectTransform rect = text.rectTransform; rect.anchorMin = new Vector2(0.5f, 0.5f); rect.anchorMax = new Vector2(0.5f, 0.5f); rect.anchoredPosition = anchoredPosition; rect.sizeDelta = size; text.fontSize = fontSize; text.color = color; text.alignment = alignment; text.horizontalOverflow = HorizontalWrapMode.Overflow; text.verticalOverflow = VerticalWrapMode.Overflow; text.raycastTarget = false; text.supportRichText = true; text.lineSpacing = 0.86f; Shadow shadow = text.GetComponent() ?? text.gameObject.AddComponent(); shadow.effectColor = new Color(0.0f, 0.0f, 0.0f, 0.72f); shadow.effectDistance = new Vector2(3.0f, -3.0f); } private static void ConfigureImage(Image image, Vector2 anchoredPosition, Vector2 size) { if (image == null) return; RectTransform rect = image.rectTransform; rect.anchorMin = new Vector2(0.5f, 0.5f); rect.anchorMax = new Vector2(0.5f, 0.5f); rect.anchoredPosition = anchoredPosition; rect.sizeDelta = size; image.color = new Color(1.0f, 1.0f, 1.0f, 0.85f); image.raycastTarget = false; } private string GetRankColorHex() { switch (Rank) { case "S+": return "#41F2FF"; case "S": return "#69FFD1"; case "A": return "#B9FF72"; case "B": return "#FFE06A"; case "C": return "#FFB15C"; case "D": return "#FF7C7C"; default: return "#A9B7C0"; } } private void PulseComboLabel(BeatJudgement judgement) { if (comboLabel == null || judgement == BeatJudgement.Miss) return; comboLabel.gameObject.CancelAllTweens(); comboLabel.transform.localScale = comboBaseScale * 1.08f; comboLabel.transform.ScaleTween(comboBaseScale, 0.16f).SetEase(Ease.EaseOutExpo).SetOwner(comboLabel.gameObject); } private static string FormatTime(float seconds) { int wholeSeconds = Mathf.Max(0, Mathf.FloorToInt(seconds)); int minutes = wholeSeconds / 60; int remainingSeconds = wholeSeconds % 60; return $"{minutes}:{remainingSeconds:00}"; } } }