feat: polish VR gameplay and sync tools

This commit is contained in:
jongjae0305
2026-05-28 19:01:20 +09:00
parent ee34d79a66
commit 03105a4f85
50 changed files with 4986 additions and 328 deletions
@@ -21,21 +21,30 @@ namespace VRBeats
}
scoreManager = FindFirstObjectByType<ScoreManager>();
ApplyPopupTextStyle();
}
public void ShowScore()
{
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , scoreFadeTime).SetOnUpdateFloat(delegate (float v)
if (scoreText == null || scoreManager == null)
return;
PlatinioTween.instance.ValueTween( 0.0f , scoreManager.CurrentScore , Mathf.Min(scoreFadeTime, 0.8f)).SetOnUpdateFloat(delegate (float v)
{
SetScore( (int)v );
}).SetOnComplete(delegate
{
scoreText.text = scoreManager.BuildResultSummary(length);
});
}
public void ResetValues()
{
gameObject.CancelAllTweens();
scoreText.text = initialValue;
ApplyPopupTextStyle();
if (scoreText != null)
scoreText.text = initialValue;
}
@@ -56,5 +65,20 @@ namespace VRBeats
}
private void ApplyPopupTextStyle()
{
if (scoreText == null)
return;
scoreText.enableAutoSizing = false;
scoreText.fontSize = 4.4f;
scoreText.alignment = TextAlignmentOptions.Center;
scoreText.overflowMode = TextOverflowModes.Overflow;
scoreText.textWrappingMode = TextWrappingModes.NoWrap;
scoreText.lineSpacing = -18.0f;
scoreText.color = Color.white;
scoreText.richText = true;
}
}
}
+411 -96
View File
@@ -1,4 +1,4 @@
using UnityEngine;
using UnityEngine;
using UnityEngine.UI;
using Platinio.TweenEngine;
using VRBeats.ScriptableEvents;
@@ -7,6 +7,14 @@ 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;
@@ -14,61 +22,167 @@ namespace VRBeats
[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 int scorePerHit = 0;
private int currentScore = 0;
private int currentMultiplier = 0;
private int toNextMultiplierIncrease = 2;
private int acumulateCorrectSlices = 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;
public int CurrentScore
private static bool hasPendingSliceTiming = false;
private static float pendingSliceTiming = 0.0f;
public int CurrentScore => Mathf.RoundToInt(MaxCourseScore * AccuracyPercent / 100.0f);
public float AccuracyPercent
{
get
{
return currentScore;
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;
scorePerHit = VR_BeatManager.instance.GameSettings.ScorePerHit;
errorLimit = VR_BeatManager.instance.GameSettings.ErrorLimit;
multiplierLoader.fillAmount = 0.0f;
}
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();
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
if (canvasGroup != null)
canvasGroup.Fade(0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
}
public void OnGameRestart()
{
{
ResetThisComponent();
gameObject.CancelAllTweens();
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
if (canvasGroup != null)
canvasGroup.Fade(1.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOwner(gameObject);
}
public void ResetThisComponent()
{
currentMultiplier = 0;
currentScore = 0;
acumulateCorrectSlices = 0;
{
currentMultiplier = 1.0f;
visualScore = 0;
acumulateErrors = 0;
toNextMultiplierIncrease = 2;
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();
@@ -79,46 +193,13 @@ namespace VRBeats
if (destroyed)
return;
acumulateErrors = 0;
acumulateCorrectSlices++;
currentScore += scorePerHit + (scorePerHit * currentMultiplier);
BeatJudgement judgement = ConsumeJudgement();
RegisterJudgement(judgement);
CancelTweenById(scoreTweenID);
scoreTweenID = PlatinioTween.instance.ValueTween(visualScore, currentScore, scoreFollowTime).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
visualScore = value;
}).ID;
acumulateErrors = judgement == BeatJudgement.Miss ? acumulateErrors + 1 : 0;
UpdateScoreTween();
UpdateMultiplierLoaderValue();
if (acumulateCorrectSlices >= toNextMultiplierIncrease)
{
IncreaseMultiplier();
}
}
private void CancelTweenById(int id)
{
if(id != -1)
PlatinioTween.instance.CancelTween(id);
}
private void UpdateMultiplierLoaderValue()
{
if (destroyed)
return;
float multiplierLoaderValue = (float)acumulateCorrectSlices / (float)toNextMultiplierIncrease;
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;
}
public void OnIncorrectSlice()
@@ -126,18 +207,63 @@ namespace VRBeats
if (destroyed)
return;
RegisterJudgement(BeatJudgement.Miss);
acumulateErrors++;
acumulateCorrectSlices = 0;
currentMultiplier = 0;
toNextMultiplierIncrease = 2;
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 $"<size=150%><color=#41F2FF>{Rank}</color></size>\n" +
$"<size=118%>{score}</size>\n" +
$"<size=56%><color=#D7F7FF>ACC {AccuracyPercent:0.0}% {badge}</color></size>\n" +
$"<size=50%>MAX COMBO {maxCombo}</size>\n" +
$"<size=42%><color=#A9B7C0>P {perfectCount} G {greatCount} GOOD {goodCount} MISS {missCount}</color></size>";
}
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()
@@ -145,41 +271,230 @@ namespace VRBeats
if (destroyed)
return;
multiplierLabel.text = currentMultiplier.ToString();
scoreLabel.text = Mathf.CeilToInt( visualScore ).ToString();
}
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
? $"<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>{currentCombo}</size>"
: "<size=42%><color=#E6F8FF>COMBO</color></size>\n<size=125%>0</size>";
if (accuracyLabel != null)
accuracyLabel.text = $"{AccuracyPercent:0.0}%";
if (rankLabel != null)
rankLabel.text = $"<color={GetRankColorHex()}>{Rank}</color>";
if (progressLabel != null)
progressLabel.text = songDuration > 0.0f
? $"{FormatTime(songCurrentTime)} / {FormatTime(songDuration)}"
: "";
private void IncreaseMultiplier()
{
if (destroyed)
if (judgementLabel == null)
return;
acumulateCorrectSlices = 0;
currentMultiplier = Mathf.Min( currentMultiplier + 1 , maxMultiplier );
toNextMultiplierIncrease = (currentMultiplier + 1) * 2;
PlatinioTween.instance.CancelTween(multiplierLoader.gameObject);
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 1.0f, 1.0f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if(multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject).SetOnComplete( delegate
{
if (multiplierLabel != null)
{
PlatinioTween.instance.ValueTween(multiplierLoader.fillAmount, 0.0f, 0.5f).SetEase(Ease.EaseOutExpo).SetOnUpdateFloat(delegate (float value)
{
if (multiplierLoader != null)
multiplierLoader.fillAmount = value;
}).SetOwner(multiplierLoader.gameObject);
}
} );
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<RectTransform>();
textObject.AddComponent<CanvasRenderer>();
Text text = textObject.AddComponent<Text>();
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<Shadow>() ?? text.gameObject.AddComponent<Shadow>();
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}";
}
}
}