diff --git a/Assets/Script/Gameplay/GameManager.cs b/Assets/Script/Gameplay/GameManager.cs index b0317a47dd..edb87b40be 100644 --- a/Assets/Script/Gameplay/GameManager.cs +++ b/Assets/Script/Gameplay/GameManager.cs @@ -625,6 +625,9 @@ private bool EndSong() 0 : // PendingScore should be 0 at this point, so no reason to add it (float) player.BaseStats.CommittedScore / player.BaseEngine.BaseNoteScore, + VocalPhrasePercents = (player as VocalsPlayer)?.PhrasePercents, + VocalPercussionHits = (player as VocalsPlayer)?.PercussionHits ?? 0, + VocalPercussionTotal = (player as VocalsPlayer)?.PercussionTotal ?? 0, }).ToArray(), BandScore = BandScore, BandStars = (int) BandStars, diff --git a/Assets/Script/Gameplay/HUD/VocalsPlayerHUD.cs b/Assets/Script/Gameplay/HUD/VocalsPlayerHUD.cs index 23e809ea15..aa915f7ec4 100644 --- a/Assets/Script/Gameplay/HUD/VocalsPlayerHUD.cs +++ b/Assets/Script/Gameplay/HUD/VocalsPlayerHUD.cs @@ -4,6 +4,7 @@ using UnityEngine; using UnityEngine.UI; using YARG.Core.Game; +using YARG.Gameplay.Vocals; using YARG.Helpers.Extensions; using YARG.Helpers.UI; using YARG.Localization; @@ -108,16 +109,9 @@ public void UpdateInfo(float phrasePercent, int multiplier, public static string GetVocalPerformanceText(double hitPercent) { - string performanceKey = hitPercent switch - { - >= 1f => "Awesome", - >= 0.8f => "Strong", - >= 0.7f => "Good", - >= 0.6f => "Okay", - >= 0.1f => "Messy", - _ => "Awful" - }; - + // Single source of truth for the grade thresholds lives in VocalPhraseGrade.Classify, + // shared with the score-screen phrase summary so the two can't drift. + var performanceKey = VocalPhraseGradeExtensions.Classify(hitPercent).ToLocalizationKey(); return Localize.Key("Gameplay.Vocals.Performance", performanceKey); } diff --git a/Assets/Script/Gameplay/Player/VocalsPlayer.cs b/Assets/Script/Gameplay/Player/VocalsPlayer.cs index ecb7037f56..8f2784670e 100644 --- a/Assets/Script/Gameplay/Player/VocalsPlayer.cs +++ b/Assets/Script/Gameplay/Player/VocalsPlayer.cs @@ -61,6 +61,20 @@ public class VocalsPlayer : BasePlayer private int _phraseIndex = -1; + // Per-phrase normalized hit percents, captured live from OnPhraseHit in chronological + // (song) order. Routed to the score screen's Advanced view for the vocals phrase summary. + private readonly List _phrasePercents = new(); + public IReadOnlyList PhrasePercents => _phrasePercents; + + // Vocal percussion isn't tracked in stats; tally hits/total live for the score screen. + // Total is derived from chart data so it's correct regardless of shared mutable state + // (multiple engines on the same VocalsPart share VocalNote objects — first engine to hit + // a percussion note mutates WasHit, causing sibling engines to skip it). + private int _percussionHits; + private int _percussionTotalFromChart; + public int PercussionHits => _percussionHits; + public int PercussionTotal => _percussionTotalFromChart; + private const int NEEDLES_COUNT = 7; private SongChart _chart; @@ -95,6 +109,11 @@ public void Initialize(int index, int vocalIndex, YargPlayer player, SongChart c OriginalNoteTrack = track.CloneAsInstrumentDifficulty(); NoteTrack = OriginalNoteTrack; + // Count percussion notes from the chart data so the denominator is authoritative + // regardless of shared-mutable-state bugs between engines on the same part. + _percussionTotalFromChart = NoteTrack.Notes + .Sum(phrase => phrase.ChildNotes.Count(n => n.IsPercussion)); + _phraseIndex = -1; _previousStarPowerPercent = 0.0; @@ -189,6 +208,10 @@ protected VocalsEngine CreateEngine() LastCombo = Combo; + // Capture the phrase score for the score screen's phrase summary. The event fires + // once per non-empty phrase in chronological order, so this list stays in song order. + _phrasePercents.Add((float) percent); + ShowTextNotifications(isLastPhrase); // Order is important here. ShowVocalPhraseResult() will skip showing AWESOME! if other, more important notifications are already showing. @@ -200,10 +223,11 @@ protected VocalsEngine CreateEngine() if (note.IsPercussion) { _percussionTrack.HitPercussionNote(note); + _percussionHits++; } }; - engine.OnNoteMissed += (_, _) => + engine.OnNoteMissed += (_, note) => { if (LastCombo >= 2) { @@ -252,8 +276,28 @@ protected override void ResetVisuals() _lastTargetNote = null; } + // The phrase/percussion captures live Unity-side (the engine doesn't store them), so they + // must be cleared by hand whenever the engine resets and reprocesses — otherwise the + // re-fired OnPhraseHit/OnNoteHit events would append duplicates (e.g. on replay seek). + private void ResetScoreScreenCaptures() + { + _phrasePercents.Clear(); + _percussionHits = 0; + } + + public override void SetReplayTime(double time) + { + // Base resets the engine and reprocesses inputs up to `time`; clear first so the + // re-fired events rebuild the captures from scratch. + ResetScoreScreenCaptures(); + + base.SetReplayTime(time); + } + public override void ResetPracticeSection() { + ResetScoreScreenCaptures(); + Engine.Reset(true); if (NoteTrack.Notes.Count > 0) diff --git a/Assets/Script/Gameplay/Vocals.meta b/Assets/Script/Gameplay/Vocals.meta new file mode 100644 index 0000000000..543d05c1d0 --- /dev/null +++ b/Assets/Script/Gameplay/Vocals.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4da02413160e4497968e6889c257533b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Script/Gameplay/Vocals/VocalPhraseGrade.cs b/Assets/Script/Gameplay/Vocals/VocalPhraseGrade.cs new file mode 100644 index 0000000000..0c8f54cd4b --- /dev/null +++ b/Assets/Script/Gameplay/Vocals/VocalPhraseGrade.cs @@ -0,0 +1,50 @@ +namespace YARG.Gameplay.Vocals +{ + // Ordered worst-to-best so the ordinal can be used directly for sorting tallies. + public enum VocalPhraseGrade + { + Awful, + Messy, + Okay, + Good, + Strong, + Awesome + } + + public static class VocalPhraseGradeExtensions + { + // Inclusive lower bound per tier, indexed by (int) VocalPhraseGrade (worst -> best). + private static readonly double[] LowerBounds = { 0.0, 0.1, 0.6, 0.7, 0.8, 1.0 }; + + public static VocalPhraseGrade Classify(double normalizedPercent) + { + for (int i = LowerBounds.Length - 1; i >= 0; i--) + { + if (normalizedPercent >= LowerBounds[i]) + { + return (VocalPhraseGrade) i; + } + } + + return VocalPhraseGrade.Awful; + } + + public static double LowerBound(this VocalPhraseGrade grade) + { + return LowerBounds[(int) grade]; + } + + public static string ToLocalizationKey(this VocalPhraseGrade grade) + { + return grade switch + { + VocalPhraseGrade.Awesome => "Awesome", + VocalPhraseGrade.Strong => "Strong", + VocalPhraseGrade.Good => "Good", + VocalPhraseGrade.Okay => "Okay", + VocalPhraseGrade.Messy => "Messy", + _ => "Awful" + }; + } + } +} diff --git a/Assets/Script/Gameplay/Vocals/VocalPhraseGrade.cs.meta b/Assets/Script/Gameplay/Vocals/VocalPhraseGrade.cs.meta new file mode 100644 index 0000000000..b133c3aaa6 --- /dev/null +++ b/Assets/Script/Gameplay/Vocals/VocalPhraseGrade.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 453e8366786743e9a15c76e7cb62b9da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Script/Menu/ScoreScreen/ScoreCards/ScoreCard.cs b/Assets/Script/Menu/ScoreScreen/ScoreCards/ScoreCard.cs index 6558be28b2..9345b13112 100644 --- a/Assets/Script/Menu/ScoreScreen/ScoreCards/ScoreCard.cs +++ b/Assets/Script/Menu/ScoreScreen/ScoreCards/ScoreCard.cs @@ -120,6 +120,17 @@ public abstract class ScoreCard : MonoBehaviour, IScoreCard where T : Base public YargPlayer Player { get; private set; } + protected virtual bool ShouldShowOffsetHistogram => true; + + protected RectTransform AdvancedStatsRect => _advancedStatsRect; + + protected Color AdvancedAccentColor => _colorizer != null ? _colorizer.CurrentColor : Color.white; + + protected TextMeshProUGUI CreateStatLabel(Transform parent, string name, TextAlignmentOptions alignment) + { + return CreateHistogramLabel(parent, name, alignment); + } + private void Awake() { _colorizer = GetComponent(); @@ -208,7 +219,14 @@ public virtual void SetCardContents() _starPowerActivations.text = ColorizePrimary(Stats.StarPowerActivationCount); string timeInStarPower = TimeSpan.FromSeconds(Stats.TimeInStarPower).ToString(@"m\:ss"); _timeInStarPower.text = ColorizePrimary(timeInStarPower); - BuildOffsetHistogram(); + if (ShouldShowOffsetHistogram) + { + BuildOffsetHistogram(); + } + else + { + SetOffsetHistogramActive(false); + } // Set engine preset tag var enginePresetId = Player.EnginePreset.Id; diff --git a/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsPhraseHistogram.cs b/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsPhraseHistogram.cs new file mode 100644 index 0000000000..7ad99bb6fc --- /dev/null +++ b/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsPhraseHistogram.cs @@ -0,0 +1,530 @@ +using System; +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.UI; +using YARG.Gameplay.Vocals; +using YARG.Localization; + +namespace YARG.Menu.ScoreScreen +{ + public static class VocalsPhraseHistogram + { + // Matches the offset histogram's footprint exactly (see OFFSET_HISTOGRAM_* in ScoreCard). + private const float GRAPH_HEIGHT = 132f; + private const float HORIZONTAL_MARGIN = 54f; + // Floor so a fully-missed (0%) phrase still shows a clearly visible stub, kept just under the + // Messy cutoff line so it still reads as "below Messy". + private const float BAR_MIN_HEIGHT = 9f; + private const float BAR_ALPHA = 0.875f; + private const float BAR_DIM_TINT = 0.82f; // multiplier for even Awesome bars (applied as RawImage color tint) + private const float BAR_GRADIENT_BOTTOM = 0.55f; // fraction of top color at bar bottom (gray bar vertical gradient) + // Re-introduce a half-gap between bars when the phrase count is low enough to benefit. + private const int BAR_GAP_THRESHOLD = 25; + private const float BAR_HALF_GAP_PX = 1.5f; + private const float BAR_EDGE_PAD = BAR_HALF_GAP_PX * 2f; // fixed outer inset, always present + + private const float TALLY_ROW_HEIGHT = 22f; + private const float TALLY_SPACING = 2f; + private const float TALLY_SIDE_PADDING = HORIZONTAL_MARGIN; // line table edges up with the bars + private const float TALLY_COUNT_WIDTH = 56f; + private const float DIVIDER_THICKNESS = 2f; + private const float SECTION_SPACING = 14f; + private const float BAR_BASE_Y = 1f; + + // Match the score card's existing section-header and stat-label text styling (see + // ScoreCard.prefab) so the summary blends in rather than standing out. + private const float TEXT_SIZE = 20f; + private static readonly Color HeaderColor = new(0.8509804f, 0.8509804f, 1f); // section header lavender + private static readonly Color CoolGrayColor = new(0.48235294f, 0.49803922f, 0.60392157f); // #7b7f9a (tier labels) + private static readonly Color MutedColor = new(0.49019608f, 0.49019608f, 0.6392157f); // #7D7DA3 (zero counts) + private static readonly Color GoldColor = new(1f, 0.83921569f, 0.25882353f); // #FFD642 Awesome bar top + private static readonly Color UtOrangeColor = new(1f, 0.51764706f, 0.07450980f); // #FF8413 Awesome bar bottom + private static readonly Color BarDefaultColor = new(0.47843137f, 0.47843137f, 0.47843137f); // #7a7a7a Gray (Light #4) — dim bars + private static readonly Color BarBrightColor = new(0.62745098f, 0.62745098f, 0.62745098f); // #a0a0a0 Gray (Light #3.5) — bright bars + private static readonly Color BarCapColor = new(0.9607843f, 0.9607843f, 0.9607843f, 1f); // #F5F5F5 White Smoke + // Tier cutoff line colors (branding palette). + private static readonly Color LineColorMessy = new(0.93725490f, 0.20392157f, 0.21960784f); // #ef3438 Imperial Red + private static readonly Color LineColorOkay = new(0.72156863f, 0.72156863f, 0.72156863f); // #b8b8b8 Silver (Light #3) + private static readonly Color LineColorGood = new(0.27058824f, 0.84705882f, 0.99607843f); // #45d8fe Vivid Sky Blue + private static readonly Color LineColorStrong = new(0.16862745f, 0.88235294f, 0.55294118f); // #2be18d Emerald + + // The brand fonts used by the card: Red Hat Display (headers), Barlow (body). Resolved + // lazily from already-loaded assets so we don't need serialized prefab references. + private const string HEADER_FONT_NAME = "RedHatDisplay-ExtraBold"; + private const string LABEL_FONT_NAME = "Barlow-Medium"; + private static TMP_FontAsset _headerFont; + private static TMP_FontAsset _labelFont; + // One 1×N vertical gradient texture per tier region; created once and reused across score screens. + private const int GRADIENT_TEX_WIDTH = 256; + private const float REGION_FILL_ALPHA = 0.125f; + private const float REGION_FADE_MIN = 0.375f; + private static readonly Texture2D[] _tierGradientTextures = new Texture2D[5]; + + private static Texture2D _awesomeBarGradient; + private static Texture2D _grayBarGradient; + + public static void Build(RectTransform parent, IReadOnlyList percents, + Func labelFactory, Color accentColor, + int percussionHits, int percussionTotal) + { + if (parent == null || percents == null || percents.Count == 0) + { + return; + } + + EnsureFonts(); + + var rootRect = CreateLayoutColumn("Vocals Phrase Summary", parent, SECTION_SPACING); + // Sit at the top of the advanced container (above the now-empty offset-histogram slot), + // so the header lines up with "PERFORMANCE" in the basic view. + rootRect.SetAsFirstSibling(); + + // Section header — centered, uppercase, subdued, matching "PERFORMANCE" above it. + var header = labelFactory(rootRect, "Header", TextAlignmentOptions.Top); + header.text = Localize.Key("Menu.ScoreScreen.PhraseSummaryHeader"); + StyleText(header, _headerFont, HeaderColor, TextAlignmentOptions.Top); + AddLayoutElement(header.rectTransform, 50f); + + BuildGraph(rootRect, percents); + BuildTally(rootRect, percents, labelFactory, accentColor, percussionHits, percussionTotal); + } + + private static void BuildGraph(RectTransform parent, IReadOnlyList percents) + { + var graphObject = new GameObject("Graph", typeof(RectTransform)); + var graphRect = (RectTransform) graphObject.transform; + graphRect.SetParent(parent, false); + AddLayoutElement(graphRect, GRAPH_HEIGHT); + + // Inset the bars to match the offset histogram's horizontal margins. + var barsObject = new GameObject("Bars", typeof(RectTransform)); + var barsRect = (RectTransform) barsObject.transform; + barsRect.SetParent(graphRect, false); + barsRect.anchorMin = Vector2.zero; + barsRect.anchorMax = Vector2.one; + barsRect.offsetMin = new Vector2(HORIZONTAL_MARGIN, 0f); + barsRect.offsetMax = new Vector2(-HORIZONTAL_MARGIN, 0f); + + // Inner rect inset by BAR_EDGE_PAD on each side — bars live here so the fixed outer + // margin is independent of the inter-bar half-gap. Regions/axis stay on barsRect. + var innerBarsObject = new GameObject("BarsInner", typeof(RectTransform)); + var innerBarsRect = (RectTransform) innerBarsObject.transform; + innerBarsRect.SetParent(barsRect, false); + innerBarsRect.anchorMin = Vector2.zero; + innerBarsRect.anchorMax = Vector2.one; + innerBarsRect.offsetMin = new Vector2(BAR_EDGE_PAD, 0f); + innerBarsRect.offsetMax = new Vector2(-BAR_EDGE_PAD, 0f); + + float lineThickness = Mathf.Ceil(PixelUnit(barsRect)); + + // Baseline axis line. + var axis = new GameObject("XAxis", typeof(RectTransform), typeof(Image)); + var axisRect = (RectTransform) axis.transform; + axisRect.SetParent(barsRect, false); + axisRect.anchorMin = new Vector2(0f, 0f); + axisRect.anchorMax = new Vector2(1f, 0f); + // Axis top sits at BAR_BASE_Y so it doesn't overlap with the bar bottoms. + axisRect.offsetMin = new Vector2(0f, BAR_BASE_Y - 3f); + axisRect.offsetMax = new Vector2(0f, BAR_BASE_Y); + var axisImage = axis.GetComponent(); + axisImage.color = new Color(1f, 1f, 1f, 0.25f); + axisImage.raycastTarget = false; + + // Tier background regions at uniform 0.125 alpha, drawn BEHIND the bars. + DrawTierRegions(barsRect); + + // Subtle tier boundary lines on top of the regions, still behind bars. + for (int tier = 1; tier <= 4; tier++) + { + var grade = (VocalPhraseGrade) tier; + float threshold = (float) grade.LowerBound(); + var lineColor = grade switch + { + VocalPhraseGrade.Messy => LineColorMessy, + VocalPhraseGrade.Okay => LineColorOkay, + VocalPhraseGrade.Good => LineColorGood, + _ => LineColorStrong + }; + lineColor.a = 0.03125f; + + var cutoffObj = new GameObject($"Cutoff {grade}", typeof(RectTransform), typeof(Image)); + var cutoffRect = (RectTransform) cutoffObj.transform; + cutoffRect.SetParent(barsRect, false); + cutoffRect.anchorMin = new Vector2(0f, 0f); + cutoffRect.anchorMax = new Vector2(1f, 0f); + cutoffRect.pivot = new Vector2(0.5f, 0.5f); + cutoffRect.sizeDelta = new Vector2(0f, lineThickness); + cutoffRect.anchoredPosition = new Vector2(0f, BAR_BASE_Y + threshold * GRAPH_HEIGHT); + var cutoffImg = cutoffObj.GetComponent(); + cutoffImg.color = lineColor; + cutoffImg.raycastTarget = false; + } + + + // Push bars to the top of the render order so they sit in front of regions and lines. + innerBarsRect.SetAsLastSibling(); + + int count = percents.Count; + float halfGap = count < BAR_GAP_THRESHOLD ? BAR_HALF_GAP_PX : 0f; + + for (int i = 0; i < count; i++) + { + var grade = VocalPhraseGradeExtensions.Classify(percents[i]); + float fraction = Mathf.Clamp01(percents[i]); + float height = Mathf.Max(BAR_MIN_HEIGHT, fraction * GRAPH_HEIGHT); + + bool isBright = (i % 2) == 1; // odd bars brighter + + var barObject = new GameObject($"Bar {i}", typeof(RectTransform)); + var barRect = (RectTransform) barObject.transform; + barRect.SetParent(innerBarsRect, false); + barRect.anchorMin = new Vector2(i / (float) count, 0f); + barRect.anchorMax = new Vector2((i + 1f) / count, 0f); + barRect.pivot = new Vector2(0.5f, 0f); + barRect.offsetMin = new Vector2(halfGap, BAR_BASE_Y); + barRect.offsetMax = new Vector2(-halfGap, BAR_BASE_Y + height); + + if (grade == VocalPhraseGrade.Awesome) + { + // Vertical gradient: gold (#FFD642) at top, UT Orange (#FF8413) at bottom. + // Brightness alternates via RawImage tint (same texture, no second allocation). + var rawImage = barObject.AddComponent(); + rawImage.texture = GetOrCreateAwesomeBarGradient(); + float tint = isBright ? 1f : BAR_DIM_TINT; + rawImage.color = new Color(tint, tint, tint, BAR_ALPHA); + rawImage.raycastTarget = false; + } + else + { + // Vertical gradient: top = bar color, bottom = BAR_GRADIENT_BOTTOM fraction of it. + // One normalized texture is shared; the tint color shifts between dim and bright. + var rawImage = barObject.AddComponent(); + rawImage.texture = GetOrCreateGrayBarGradient(); + var baseColor = isBright ? BarBrightColor : BarDefaultColor; + rawImage.color = new Color(baseColor.r, baseColor.g, baseColor.b, BAR_ALPHA); + rawImage.raycastTarget = false; + + // Tier-colored cap line across the top of non-gold bars. + var capObj = new GameObject("Cap", typeof(RectTransform), typeof(Image)); + var capRect = (RectTransform) capObj.transform; + capRect.SetParent(barObject.transform, false); + capRect.anchorMin = new Vector2(0f, 1f); + capRect.anchorMax = new Vector2(1f, 1f); + capRect.pivot = new Vector2(0.5f, 1f); + capRect.sizeDelta = new Vector2(0f, lineThickness); + capRect.anchoredPosition = new Vector2(0f, lineThickness * 0.5f); + var capImage = capObj.GetComponent(); + var capColor = BarCapColor; + capColor.a = 0.5f; + capImage.color = capColor; + capImage.raycastTarget = false; + } + } + } + + private static void BuildTally(RectTransform parent, IReadOnlyList percents, + Func labelFactory, Color dividerColor, + int percussionHits, int percussionTotal) + { + // Tally phrases per tier. + int tierCount = VocalPhraseGrade.Awesome - VocalPhraseGrade.Awful + 1; + var counts = new int[tierCount]; + for (int i = 0; i < percents.Count; i++) + { + counts[(int) VocalPhraseGradeExtensions.Classify(percents[i])]++; + } + + var tallyRect = CreateLayoutColumn("Tally", parent, TALLY_SPACING); + var tallyLayout = tallyRect.GetComponent(); + tallyLayout.padding = new RectOffset((int) TALLY_SIDE_PADDING, (int) TALLY_SIDE_PADDING, 0, 0); + + // Always show every tier (best -> worst) so multiple players' tables line up row-for-row, + // even when a tier has no phrases. + for (int grade = tierCount - 1; grade >= 0; grade--) + { + BuildTallyRow(tallyRect, (VocalPhraseGrade) grade, counts[grade], labelFactory); + } + + // Vocal percussion (not a graded tier) gets its own row below the tiers, set off by a + // divider in the card accent color. Omitted entirely when the chart has no percussion. + if (percussionTotal > 0) + { + BuildDivider(tallyRect, dividerColor); + BuildPercussionRow(tallyRect, percussionHits, percussionTotal, labelFactory); + } + } + + private static void BuildDivider(RectTransform parent, Color color) + { + var dividerObject = new GameObject("Divider", typeof(RectTransform), typeof(Image)); + var dividerRect = (RectTransform) dividerObject.transform; + dividerRect.SetParent(parent, false); + AddLayoutElement(dividerRect, DIVIDER_THICKNESS); + + var image = dividerObject.GetComponent(); + image.color = color; + image.raycastTarget = false; + } + + private static void BuildPercussionRow(RectTransform parent, int hits, int total, + Func labelFactory) + { + var rowObject = new GameObject("Percussion", typeof(RectTransform), typeof(HorizontalLayoutGroup)); + var rowRect = (RectTransform) rowObject.transform; + rowRect.SetParent(parent, false); + AddLayoutElement(rowRect, TALLY_ROW_HEIGHT); + + var rowLayout = rowObject.GetComponent(); + rowLayout.childAlignment = TextAnchor.MiddleLeft; + rowLayout.spacing = 0f; + rowLayout.childForceExpandWidth = false; + rowLayout.childForceExpandHeight = false; + rowLayout.childControlWidth = true; + rowLayout.childControlHeight = true; + + var label = labelFactory(rowRect, "Label", TextAlignmentOptions.Left); + label.text = Localize.Key("Menu.ScoreScreen.Percussion"); + StyleText(label, _labelFont, CoolGrayColor, TextAlignmentOptions.Left); + var labelLayout = label.gameObject.AddComponent(); + labelLayout.flexibleWidth = 1f; + labelLayout.preferredHeight = TALLY_ROW_HEIGHT; + + // "hits / total" — numerator white, denominator muted, mirroring the regular stat rows. + // When every percussion note was hit, the whole count goes gold (like a maxed tier). + var countText = labelFactory(rowRect, "Count", TextAlignmentOptions.Right); + bool allHit = hits == total; + countText.text = allHit + ? $"{hits} / {total}" + : $"{hits} / {total}"; + StyleText(countText, _labelFont, null, TextAlignmentOptions.Right); + var countLayout = countText.gameObject.AddComponent(); + countLayout.minWidth = TALLY_COUNT_WIDTH; + countLayout.preferredWidth = TALLY_COUNT_WIDTH; + countLayout.flexibleWidth = 0f; + countLayout.preferredHeight = TALLY_ROW_HEIGHT; + } + + private static void BuildTallyRow(RectTransform parent, VocalPhraseGrade grade, int count, + Func labelFactory) + { + var rowObject = new GameObject($"Tally {grade}", typeof(RectTransform), typeof(HorizontalLayoutGroup)); + var rowRect = (RectTransform) rowObject.transform; + rowRect.SetParent(parent, false); + AddLayoutElement(rowRect, TALLY_ROW_HEIGHT); + + var rowLayout = rowObject.GetComponent(); + rowLayout.childAlignment = TextAnchor.MiddleLeft; + rowLayout.spacing = 0f; + rowLayout.childForceExpandWidth = false; + rowLayout.childForceExpandHeight = false; + rowLayout.childControlWidth = true; + rowLayout.childControlHeight = true; + + // Tier label, left-justified, standard white. + var label = labelFactory(rowRect, "Label", TextAlignmentOptions.Left); + label.text = Localize.Key("Gameplay.Vocals.Performance", grade.ToLocalizationKey()); + StyleText(label, _labelFont, CoolGrayColor, TextAlignmentOptions.Left); + var labelLayout = label.gameObject.AddComponent(); + labelLayout.flexibleWidth = 1f; + labelLayout.preferredHeight = TALLY_ROW_HEIGHT; + + // Count, right-justified. Same white as the label, dimmed to muted grey when zero. + var countText = labelFactory(rowRect, "Count", TextAlignmentOptions.Right); + countText.text = count.ToString(); + StyleText(countText, _labelFont, count > 0 ? (Color?) null : MutedColor, + TextAlignmentOptions.Right); + var countLayout = countText.gameObject.AddComponent(); + countLayout.minWidth = TALLY_COUNT_WIDTH; + countLayout.preferredWidth = TALLY_COUNT_WIDTH; + countLayout.flexibleWidth = 0f; + countLayout.preferredHeight = TALLY_ROW_HEIGHT; + } + + private static void DrawTierRegions(RectTransform barsRect) + { + // Five regions covering the full graph height, bottom to top. + (float bottom, float top, Color color, int idx)[] regions = + { + ((float) VocalPhraseGrade.Awful.LowerBound(), (float) VocalPhraseGrade.Messy.LowerBound(), LineColorMessy, 0), + ((float) VocalPhraseGrade.Messy.LowerBound(), (float) VocalPhraseGrade.Okay.LowerBound(), BarDefaultColor, 1), + ((float) VocalPhraseGrade.Okay.LowerBound(), (float) VocalPhraseGrade.Good.LowerBound(), LineColorOkay, 2), + ((float) VocalPhraseGrade.Good.LowerBound(), (float) VocalPhraseGrade.Strong.LowerBound(), LineColorGood, 3), + ((float) VocalPhraseGrade.Strong.LowerBound(), (float) VocalPhraseGrade.Awesome.LowerBound(),LineColorStrong, 4), + }; + + foreach (var (bottom, top, color, idx) in regions) + { + var obj = new GameObject($"Region {idx}", typeof(RectTransform), typeof(RawImage)); + var rect = (RectTransform) obj.transform; + rect.SetParent(barsRect, false); + rect.anchorMin = new Vector2(0f, 0f); + rect.anchorMax = new Vector2(1f, 0f); + rect.offsetMin = new Vector2(0f, BAR_BASE_Y + bottom * GRAPH_HEIGHT); + rect.offsetMax = new Vector2(0f, BAR_BASE_Y + top * GRAPH_HEIGHT); + + var rawImage = obj.GetComponent(); + rawImage.texture = GetOrCreateGradientTexture(idx, color); + rawImage.color = new Color(1f, 1f, 1f, REGION_FILL_ALPHA); + rawImage.raycastTarget = false; + } + } + + private static Texture2D GetOrCreateAwesomeBarGradient() + { + if (_awesomeBarGradient != null) + return _awesomeBarGradient; + + // 1×N texture: pixel row 0 (UV v=0) = bar bottom = UT Orange; top row = Gold. + var tex = new Texture2D(1, GRADIENT_TEX_WIDTH, TextureFormat.RGBA32, false); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + var pixels = new Color[GRADIENT_TEX_WIDTH]; + for (int i = 0; i < GRADIENT_TEX_WIDTH; i++) + { + pixels[i] = Color.Lerp(UtOrangeColor, GoldColor, i / (GRADIENT_TEX_WIDTH - 1f)); + } + tex.SetPixels(pixels); + tex.Apply(); + _awesomeBarGradient = tex; + return tex; + } + + private static Texture2D GetOrCreateGrayBarGradient() + { + if (_grayBarGradient != null) + return _grayBarGradient; + + // 1×N normalized brightness ramp: pixel row 0 (bar bottom) = BAR_GRADIENT_BOTTOM, + // top row = 1.0. The actual bar color is applied via RawImage.color as a tint. + var tex = new Texture2D(1, GRADIENT_TEX_WIDTH, TextureFormat.RGBA32, false); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + var pixels = new Color[GRADIENT_TEX_WIDTH]; + for (int i = 0; i < GRADIENT_TEX_WIDTH; i++) + { + float t = i / (GRADIENT_TEX_WIDTH - 1f); + float v = Mathf.Lerp(BAR_GRADIENT_BOTTOM, 1f, t); + pixels[i] = new Color(v, v, v, 1f); + } + tex.SetPixels(pixels); + tex.Apply(); + _grayBarGradient = tex; + return tex; + } + + private static Texture2D GetOrCreateGradientTexture(int idx, Color color) + { + if (_tierGradientTextures[idx] != null) + { + return _tierGradientTextures[idx]; + } + + // 1×N vertical texture: pixel row 0 (UV v=0) = region bottom, top row = region top. + var tex = new Texture2D(1, GRADIENT_TEX_WIDTH, TextureFormat.RGBA32, false); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Clamp; + var pixels = new Color[GRADIENT_TEX_WIDTH]; + + // All tiers: 1.0 at bottom (lower bound) → REGION_FADE_MIN at top (next cutoff). + for (int i = 0; i < GRADIENT_TEX_WIDTH; i++) + { + float t = i / (GRADIENT_TEX_WIDTH - 1f); + float alpha = Mathf.Lerp(1f, REGION_FADE_MIN, t); + var p = color; + p.a = alpha; + pixels[i] = p; + } + + tex.SetPixels(pixels); + tex.Apply(); + _tierGradientTextures[idx] = tex; + return tex; + } + + private static void EnsureFonts() + { + // Null-check rather than a resolved flag: Unity's overloaded == null detects + // destroyed objects (e.g. after domain reload with Enter Play Mode Options), + // so the scan re-runs automatically when the cached references become stale. + if (_headerFont != null && _labelFont != null) + { + return; + } + + // The card's brand fonts are already loaded (used across the menu UI); pick them up by + // name. Falls back to whatever the label factory provided if a font isn't found. + foreach (var font in Resources.FindObjectsOfTypeAll()) + { + if (font.name == HEADER_FONT_NAME) + { + _headerFont = font; + } + else if (font.name == LABEL_FONT_NAME) + { + _labelFont = font; + } + } + } + + // Pass null for color to inherit the prefab-derived color from the label factory. + private static void StyleText(TextMeshProUGUI label, TMP_FontAsset font, Color? color, + TextAlignmentOptions alignment) + { + if (font != null) + { + label.font = font; + label.fontSharedMaterial = font.material; + } + + label.fontSize = TEXT_SIZE; + label.fontStyle = FontStyles.UpperCase; + label.characterSpacing = 0f; + if (color.HasValue) + label.color = color.Value; + label.alignment = alignment; + } + + // Returns the canvas-unit size of one physical pixel, so callers can size thin elements to + // always render as at least one visible pixel regardless of canvas DPI scaling. + private static float PixelUnit(RectTransform rt) + { + var canvas = rt.GetComponentInParent(); + return canvas != null && canvas.scaleFactor > 0f ? 1f / canvas.scaleFactor : 1f; + } + + private static RectTransform CreateLayoutColumn(string name, RectTransform parent, float spacing) + { + var columnObject = new GameObject(name, typeof(RectTransform), typeof(VerticalLayoutGroup), + typeof(ContentSizeFitter)); + var columnRect = (RectTransform) columnObject.transform; + columnRect.SetParent(parent, false); + + var layout = columnObject.GetComponent(); + layout.spacing = spacing; + layout.childAlignment = TextAnchor.UpperLeft; + layout.childForceExpandWidth = true; + layout.childForceExpandHeight = false; + layout.childControlWidth = true; + layout.childControlHeight = true; + + var fitter = columnObject.GetComponent(); + fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained; + + return columnRect; + } + + private static void AddLayoutElement(RectTransform rect, float preferredHeight) + { + var layout = rect.gameObject.GetComponent(); + if (layout == null) + { + layout = rect.gameObject.AddComponent(); + } + + layout.preferredHeight = preferredHeight; + layout.minHeight = preferredHeight; + } + } +} diff --git a/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsPhraseHistogram.cs.meta b/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsPhraseHistogram.cs.meta new file mode 100644 index 0000000000..e970b82af5 --- /dev/null +++ b/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsPhraseHistogram.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b0af41de44c4c87b8bbb8128f905ee1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsScoreCard.cs b/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsScoreCard.cs index f3b0a8070c..1d990259d4 100644 --- a/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsScoreCard.cs +++ b/Assets/Script/Menu/ScoreScreen/ScoreCards/VocalsScoreCard.cs @@ -1,4 +1,5 @@ -using UnityEngine; +using System.Collections.Generic; +using UnityEngine; using UnityEngine.AddressableAssets; using YARG.Core.Engine.Vocals; using YARG.Helpers.Extensions; @@ -7,6 +8,25 @@ namespace YARG.Menu.ScoreScreen { public class VocalsScoreCard : ScoreCard { + // The hit-offset histogram is meaningless for vocals (graded per phrase, not per note); + // we render a phrase summary in its place instead. + protected override bool ShouldShowOffsetHistogram => false; + + private IReadOnlyList _phrasePercents; + private int _percussionHits; + private int _percussionTotal; + + public void SetPhrasePercents(IReadOnlyList percents) + { + _phrasePercents = percents; + } + + public void SetPercussion(int hits, int total) + { + _percussionHits = hits; + _percussionTotal = total; + } + public override void SetCardContents() { base.SetCardContents(); @@ -15,6 +35,11 @@ public override void SetCardContents() _instrumentIcon.sprite = Addressables .LoadAssetAsync($"InstrumentIcons[{Player.Profile.CurrentInstrument.ToResourceName()}]") .WaitForCompletion(); + + // Build the phrase histogram + tally into the Advanced view (advanced-only automatically). + // Renders nothing if the list is null/empty. + VocalsPhraseHistogram.Build(AdvancedStatsRect, _phrasePercents, CreateStatLabel, AdvancedAccentColor, + _percussionHits, _percussionTotal); } } -} \ No newline at end of file +} diff --git a/Assets/Script/Menu/ScoreScreen/ScoreScreenContainer.cs b/Assets/Script/Menu/ScoreScreen/ScoreScreenContainer.cs index 5b84b553f7..45fe59772d 100644 --- a/Assets/Script/Menu/ScoreScreen/ScoreScreenContainer.cs +++ b/Assets/Script/Menu/ScoreScreen/ScoreScreenContainer.cs @@ -1,4 +1,5 @@ -using YARG.Core.Engine; +using System.Collections.Generic; +using YARG.Core.Engine; using YARG.Core.Replays; using YARG.Player; using YARG.Replays; @@ -12,6 +13,14 @@ public struct PlayerScoreCard public YargPlayer Player; public BaseStats Stats; + + // Per-phrase normalized hit percents for vocals players, in song order. Null for + // non-vocals players. Feeds the vocals phrase summary in the Advanced view. + public IReadOnlyList VocalPhrasePercents; + + // Vocal percussion hits / total (0 for non-vocals or charts without percussion). + public int VocalPercussionHits; + public int VocalPercussionTotal; } public struct ScoreScreenStats diff --git a/Assets/Script/Menu/ScoreScreen/ScoreScreenMenu.cs b/Assets/Script/Menu/ScoreScreen/ScoreScreenMenu.cs index df4237fbe9..ef71b55c27 100644 --- a/Assets/Script/Menu/ScoreScreen/ScoreScreenMenu.cs +++ b/Assets/Script/Menu/ScoreScreen/ScoreScreenMenu.cs @@ -229,6 +229,8 @@ private void CreateScoreCards(ScoreScreenStats scoreScreenStats) { card = Instantiate(_vocalsCardPrefab, _cardContainer); ((ScoreCard)card).Initialize(score.IsHighScore, score.Player, score.Stats as VocalsStats, score.AverageMultiplier); + ((VocalsScoreCard) card).SetPhrasePercents(score.VocalPhrasePercents); + ((VocalsScoreCard) card).SetPercussion(score.VocalPercussionHits, score.VocalPercussionTotal); break; } case GameMode.ProKeys: diff --git a/Assets/StreamingAssets/lang/en-US.json b/Assets/StreamingAssets/lang/en-US.json index 32321736dc..4817c4d8a6 100644 --- a/Assets/StreamingAssets/lang/en-US.json +++ b/Assets/StreamingAssets/lang/en-US.json @@ -497,7 +497,9 @@ "RestartSong" : "Restart Song", "ShowAdvanced": "Show Advanced", "HideAdvanced": "Hide Advanced", - "EndSetlistEarly" : "End Setlist" + "EndSetlistEarly" : "End Setlist", + "PhraseSummaryHeader": "Phrase Breakdown", + "Percussion": "Percussion" }, "Settings": { "ShowAdvanced": "Show Advanced",