diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneScoring.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneScoring.cs new file mode 100644 index 0000000000..bb09328ab7 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneScoring.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Visual.Gameplay; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public partial class TestSceneScoring : ScoringTestScene + { + private Bindable scoreMultiplier { get; } = new BindableDouble + { + Default = 4, + Value = 4 + }; + + protected override IBeatmap CreateBeatmap(int maxCombo) + { + var beatmap = new OsuBeatmap(); + for (int i = 0; i < maxCombo; i++) + beatmap.HitObjects.Add(new HitCircle()); + return beatmap; + } + + protected override IScoringAlgorithm CreateScoreV1() => new ScoreV1 { ScoreMultiplier = { BindTarget = scoreMultiplier } }; + protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo); + protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new OsuProcessorBasedScoringAlgorithm(beatmap, mode); + + [Test] + public void TestBasicScenarios() + { + AddStep("set up score multiplier", () => + { + scoreMultiplier.BindValueChanged(_ => Rerun()); + }); + AddStep("set max combo to 100", () => MaxCombo.Value = 100); + AddStep("set perfect score", () => + { + NonPerfectLocations.Clear(); + MissLocations.Clear(); + }); + AddStep("set score with misses", () => + { + NonPerfectLocations.Clear(); + MissLocations.Clear(); + MissLocations.AddRange(new[] { 24d, 49 }); + }); + AddStep("set score with misses and OKs", () => + { + NonPerfectLocations.Clear(); + MissLocations.Clear(); + + NonPerfectLocations.AddRange(new[] { 9d, 19, 29, 39, 59, 69, 79, 89, 99 }); + MissLocations.AddRange(new[] { 24d, 49 }); + }); + AddSliderStep("adjust score multiplier", 0, 10, (int)scoreMultiplier.Default, multiplier => scoreMultiplier.Value = multiplier); + } + + private const int base_great = 300; + private const int base_ok = 100; + + private class ScoreV1 : IScoringAlgorithm + { + private int currentCombo; + + public BindableDouble ScoreMultiplier { get; } = new BindableDouble(); + + public void ApplyHit() => applyHitV1(base_great); + public void ApplyNonPerfect() => applyHitV1(base_ok); + public void ApplyMiss() => applyHitV1(0); + + private void applyHitV1(int baseScore) + { + if (baseScore == 0) + { + currentCombo = 0; + return; + } + + TotalScore += baseScore; + + // combo multiplier + // ReSharper disable once PossibleLossOfFraction + TotalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * ScoreMultiplier.Value)); + + currentCombo++; + } + + public long TotalScore { get; private set; } + } + + private class ScoreV2 : IScoringAlgorithm + { + private int currentCombo; + private double comboPortion; + private double currentBaseScore; + private double maxBaseScore; + private int currentHits; + + private readonly double comboPortionMax; + private readonly int maxCombo; + + public ScoreV2(int maxCombo) + { + this.maxCombo = maxCombo; + + for (int i = 0; i < this.maxCombo; i++) + ApplyHit(); + + comboPortionMax = comboPortion; + + currentCombo = 0; + comboPortion = 0; + currentBaseScore = 0; + maxBaseScore = 0; + currentHits = 0; + } + + public void ApplyHit() => applyHitV2(base_great); + public void ApplyNonPerfect() => applyHitV2(base_ok); + + private void applyHitV2(int baseScore) + { + maxBaseScore += base_great; + currentBaseScore += baseScore; + comboPortion += baseScore * (1 + ++currentCombo / 10.0); + + currentHits++; + } + + public void ApplyMiss() + { + currentHits++; + maxBaseScore += base_great; + currentCombo = 0; + } + + public long TotalScore + { + get + { + double accuracy = currentBaseScore / maxBaseScore; + + return (int)Math.Round + ( + 700000 * comboPortion / comboPortionMax + + 300000 * Math.Pow(accuracy, 10) * ((double)currentHits / maxCombo) + ); + } + } + } + + private class OsuProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm + { + public OsuProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode) + : base(beatmap, mode) + { + } + + protected override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor(); + protected override JudgementResult CreatePerfectJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great }; + protected override JudgementResult CreateNonPerfectJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok }; + protected override JudgementResult CreateMissJudgementResult() => new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss }; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs deleted file mode 100644 index 5db05ea67c..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs +++ /dev/null @@ -1,636 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Settings; -using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Osu.Beatmaps; -using osu.Game.Rulesets.Osu.Judgements; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring.Legacy; -using osuTK; -using osuTK.Graphics; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public partial class TestSceneScoring : OsuTestScene - { - private GraphContainer graphs = null!; - private SettingsSlider sliderMaxCombo = null!; - private SettingsCheckbox scaleToMax = null!; - - private FillFlowContainer legend = null!; - - private readonly BindableBool standardisedVisible = new BindableBool(true); - private readonly BindableBool classicVisible = new BindableBool(true); - private readonly BindableBool scoreV1Visible = new BindableBool(true); - private readonly BindableBool scoreV2Visible = new BindableBool(true); - - [Resolved] - private OsuColour colours { get; set; } = null!; - - [Test] - public void TestBasic() - { - AddStep("setup tests", () => - { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.Black - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - graphs = new GraphContainer - { - RelativeSizeAxes = Axes.Both, - }, - }, - new Drawable[] - { - legend = new FillFlowContainer - { - Padding = new MarginPadding(20), - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - }, - new Drawable[] - { - new FillFlowContainer - { - Padding = new MarginPadding(20), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(10), - Children = new Drawable[] - { - sliderMaxCombo = new SettingsSlider - { - TransferValueOnCommit = true, - Current = new BindableInt(1024) - { - MinValue = 96, - MaxValue = 8192, - }, - LabelText = "Max combo", - }, - scaleToMax = new SettingsCheckbox - { - LabelText = "Rescale plots to 100%", - Current = { Value = true, Default = true } - }, - new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Text = $"Left click to add miss\nRight click to add OK/{base_ok}", - Margin = new MarginPadding { Top = 20 } - } - } - }, - }, - } - } - }; - - sliderMaxCombo.Current.BindValueChanged(_ => rerun()); - scaleToMax.Current.BindValueChanged(_ => rerun()); - - standardisedVisible.BindValueChanged(_ => rescalePlots()); - classicVisible.BindValueChanged(_ => rescalePlots()); - scoreV1Visible.BindValueChanged(_ => rescalePlots()); - scoreV2Visible.BindValueChanged(_ => rescalePlots()); - - graphs.MissLocations.BindCollectionChanged((_, __) => rerun()); - graphs.NonPerfectLocations.BindCollectionChanged((_, __) => rerun()); - - graphs.MaxCombo.BindTo(sliderMaxCombo.Current); - - rerun(); - }); - } - - private const int base_great = 300; - private const int base_ok = 100; - - private void rerun() - { - graphs.Clear(); - legend.Clear(); - - runForProcessor("lazer-standardised", colours.Green1, new OsuScoreProcessor(), ScoringMode.Standardised, standardisedVisible); - runForProcessor("lazer-classic", colours.Blue1, new OsuScoreProcessor(), ScoringMode.Classic, classicVisible); - - runScoreV1(); - runScoreV2(); - - rescalePlots(); - } - - private void rescalePlots() - { - if (!scaleToMax.Current.Value && legend.Any(entry => entry.Visible.Value)) - { - long maxScore = legend.Where(entry => entry.Visible.Value).Max(entry => entry.FinalScore); - - foreach (var graph in graphs) - graph.Height = graph.Values.Max() / maxScore; - } - else - { - foreach (var graph in graphs) - graph.Height = 1; - } - } - - private void runScoreV1() - { - int totalScore = 0; - int currentCombo = 0; - - void applyHitV1(int baseScore) - { - if (baseScore == 0) - { - currentCombo = 0; - return; - } - - // this corresponds to stable's `ScoreMultiplier`. - // value is chosen arbitrarily, towards the upper range. - const float score_multiplier = 4; - - totalScore += baseScore; - - // combo multiplier - // ReSharper disable once PossibleLossOfFraction - totalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * score_multiplier)); - - currentCombo++; - } - - runForAlgorithm(new ScoringAlgorithm - { - Name = "ScoreV1 (classic)", - Colour = colours.Purple1, - ApplyHit = () => applyHitV1(base_great), - ApplyNonPerfect = () => applyHitV1(base_ok), - ApplyMiss = () => applyHitV1(0), - GetTotalScore = () => totalScore, - Visible = scoreV1Visible - }); - } - - private void runScoreV2() - { - int maxCombo = sliderMaxCombo.Current.Value; - - int currentCombo = 0; - double comboPortion = 0; - double currentBaseScore = 0; - double maxBaseScore = 0; - int currentHits = 0; - - for (int i = 0; i < maxCombo; i++) - applyHitV2(base_great); - - double comboPortionMax = comboPortion; - - currentCombo = 0; - comboPortion = 0; - currentBaseScore = 0; - maxBaseScore = 0; - currentHits = 0; - - void applyHitV2(int baseScore) - { - maxBaseScore += base_great; - currentBaseScore += baseScore; - comboPortion += baseScore * (1 + ++currentCombo / 10.0); - - currentHits++; - } - - runForAlgorithm(new ScoringAlgorithm - { - Name = "ScoreV2", - Colour = colours.Red1, - ApplyHit = () => applyHitV2(base_great), - ApplyNonPerfect = () => applyHitV2(base_ok), - ApplyMiss = () => - { - currentHits++; - maxBaseScore += base_great; - currentCombo = 0; - }, - GetTotalScore = () => - { - double accuracy = currentBaseScore / maxBaseScore; - - return (int)Math.Round - ( - 700000 * comboPortion / comboPortionMax + - 300000 * Math.Pow(accuracy, 10) * ((double)currentHits / maxCombo) - ); - }, - Visible = scoreV2Visible - }); - } - - private void runForProcessor(string name, Color4 colour, ScoreProcessor processor, ScoringMode mode, BindableBool visibility) - { - int maxCombo = sliderMaxCombo.Current.Value; - - var beatmap = new OsuBeatmap(); - for (int i = 0; i < maxCombo; i++) - beatmap.HitObjects.Add(new HitCircle()); - - processor.ApplyBeatmap(beatmap); - - runForAlgorithm(new ScoringAlgorithm - { - Name = name, - Colour = colour, - ApplyHit = () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great }), - ApplyNonPerfect = () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok }), - ApplyMiss = () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss }), - GetTotalScore = () => processor.GetDisplayScore(mode), - Visible = visibility - }); - } - - private void runForAlgorithm(ScoringAlgorithm scoringAlgorithm) - { - int maxCombo = sliderMaxCombo.Current.Value; - - List results = new List(); - - for (int i = 0; i < maxCombo; i++) - { - if (graphs.MissLocations.Contains(i)) - scoringAlgorithm.ApplyMiss(); - else if (graphs.NonPerfectLocations.Contains(i)) - scoringAlgorithm.ApplyNonPerfect(); - else - scoringAlgorithm.ApplyHit(); - - results.Add(scoringAlgorithm.GetTotalScore()); - } - - LineGraph graph; - graphs.Add(graph = new LineGraph - { - Name = scoringAlgorithm.Name, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - LineColour = scoringAlgorithm.Colour, - Values = results - }); - - legend.Add(new LegendEntry(scoringAlgorithm, graph) - { - AccentColour = scoringAlgorithm.Colour, - }); - } - } - - public class ScoringAlgorithm - { - public string Name { get; init; } = null!; - public Color4 Colour { get; init; } - public Action ApplyHit { get; init; } = () => { }; - public Action ApplyNonPerfect { get; init; } = () => { }; - public Action ApplyMiss { get; init; } = () => { }; - public Func GetTotalScore { get; init; } = null!; - public BindableBool Visible { get; init; } = null!; - } - - public partial class GraphContainer : Container, IHasCustomTooltip> - { - public readonly BindableList MissLocations = new BindableList(); - public readonly BindableList NonPerfectLocations = new BindableList(); - - public Bindable MaxCombo = new Bindable(); - - protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; - - private readonly Box hoverLine; - - private readonly Container missLines; - private readonly Container verticalGridLines; - - public int CurrentHoverCombo { get; private set; } - - public GraphContainer() - { - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = OsuColour.Gray(0.1f), - RelativeSizeAxes = Axes.Both, - }, - verticalGridLines = new Container - { - RelativeSizeAxes = Axes.Both, - }, - hoverLine = new Box - { - Colour = Color4.Yellow, - RelativeSizeAxes = Axes.Y, - Origin = Anchor.TopCentre, - Alpha = 0, - Width = 1, - }, - missLines = new Container - { - Alpha = 0.6f, - RelativeSizeAxes = Axes.Both, - }, - Content, - } - }; - - MissLocations.BindCollectionChanged((_, _) => updateMissLocations()); - NonPerfectLocations.BindCollectionChanged((_, _) => updateMissLocations()); - - MaxCombo.BindValueChanged(_ => - { - updateMissLocations(); - updateVerticalGridLines(); - }, true); - } - - private void updateVerticalGridLines() - { - verticalGridLines.Clear(); - - for (int i = 0; i < MaxCombo.Value; i++) - { - if (i % 100 == 0) - { - verticalGridLines.AddRange(new Drawable[] - { - new Box - { - Colour = OsuColour.Gray(0.2f), - Origin = Anchor.TopCentre, - Width = 1, - RelativeSizeAxes = Axes.Y, - RelativePositionAxes = Axes.X, - X = (float)i / MaxCombo.Value, - }, - new OsuSpriteText - { - RelativePositionAxes = Axes.X, - X = (float)i / MaxCombo.Value, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Text = $"{i:#,0}", - Rotation = -30, - Y = -20, - } - }); - } - } - } - - private void updateMissLocations() - { - missLines.Clear(); - - foreach (int miss in MissLocations) - { - missLines.Add(new Box - { - Colour = Color4.Red, - Origin = Anchor.TopCentre, - Width = 1, - RelativeSizeAxes = Axes.Y, - RelativePositionAxes = Axes.X, - X = (float)miss / MaxCombo.Value, - }); - } - - foreach (int miss in NonPerfectLocations) - { - missLines.Add(new Box - { - Colour = Color4.Orange, - Origin = Anchor.TopCentre, - Width = 1, - RelativeSizeAxes = Axes.Y, - RelativePositionAxes = Axes.X, - X = (float)miss / MaxCombo.Value, - }); - } - } - - protected override bool OnHover(HoverEvent e) - { - hoverLine.Show(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - hoverLine.Hide(); - base.OnHoverLost(e); - } - - protected override bool OnMouseMove(MouseMoveEvent e) - { - CurrentHoverCombo = (int)(e.MousePosition.X / DrawWidth * MaxCombo.Value); - - hoverLine.X = e.MousePosition.X; - return base.OnMouseMove(e); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (e.Button == MouseButton.Left) - MissLocations.Add(CurrentHoverCombo); - else - NonPerfectLocations.Add(CurrentHoverCombo); - - return true; - } - - private GraphTooltip? tooltip; - - public ITooltip> GetCustomTooltip() => tooltip ??= new GraphTooltip(this); - - public IEnumerable TooltipContent => Content; - - public partial class GraphTooltip : CompositeDrawable, ITooltip> - { - private readonly GraphContainer graphContainer; - - private readonly OsuTextFlowContainer textFlow; - - public GraphTooltip(GraphContainer graphContainer) - { - this.graphContainer = graphContainer; - AutoSizeAxes = Axes.Both; - - Masking = true; - CornerRadius = 10; - - InternalChildren = new Drawable[] - { - new Box - { - Colour = OsuColour.Gray(0.15f), - RelativeSizeAxes = Axes.Both, - }, - textFlow = new OsuTextFlowContainer - { - Colour = Color4.White, - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(10), - } - }; - } - - private int? lastContentCombo; - - public void SetContent(IEnumerable content) - { - int relevantCombo = graphContainer.CurrentHoverCombo; - - if (lastContentCombo == relevantCombo) - return; - - lastContentCombo = relevantCombo; - textFlow.Clear(); - - textFlow.AddParagraph($"At combo {relevantCombo}:"); - - foreach (var graph in content) - { - if (graph.Alpha == 0) continue; - - float valueAtHover = graph.Values.ElementAt(relevantCombo); - float ofTotal = valueAtHover / graph.Values.Last(); - - textFlow.AddParagraph($"{graph.Name}: {valueAtHover:#,0} ({ofTotal * 100:N0}% of final)\n", st => st.Colour = graph.LineColour); - } - } - - public void Move(Vector2 pos) => this.MoveTo(pos); - } - } - - public partial class LegendEntry : OsuClickableContainer, IHasAccentColour - { - public Color4 AccentColour { get; set; } - - public BindableBool Visible { get; } = new BindableBool(true); - - public readonly long FinalScore; - - private readonly string description; - private readonly LineGraph lineGraph; - - private OsuSpriteText descriptionText = null!; - private OsuSpriteText finalScoreText = null!; - - public LegendEntry(ScoringAlgorithm scoringAlgorithm, LineGraph lineGraph) - { - description = scoringAlgorithm.Name; - FinalScore = scoringAlgorithm.GetTotalScore(); - AccentColour = scoringAlgorithm.Colour; - Visible.BindTo(scoringAlgorithm.Visible); - - this.lineGraph = lineGraph; - } - - [BackgroundDependencyLoader] - private void load() - { - RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X; - AutoSizeAxes = Content.AutoSizeAxes = Axes.Y; - - Children = new Drawable[] - { - descriptionText = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - finalScoreText = new OsuSpriteText - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Font = OsuFont.Default.With(fixedWidth: true) - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - Visible.BindValueChanged(_ => updateState(), true); - Action = Visible.Toggle; - } - - protected override bool OnHover(HoverEvent e) - { - updateState(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateState(); - base.OnHoverLost(e); - } - - private void updateState() - { - Colour = IsHovered ? AccentColour.Lighten(0.2f) : AccentColour; - - descriptionText.Text = $"{(Visible.Value ? FontAwesome.Solid.CheckCircle.Icon : FontAwesome.Solid.Circle.Icon)} {description}"; - finalScoreText.Text = FinalScore.ToString("#,0"); - lineGraph.Alpha = Visible.Value ? 1 : 0; - } - } -} diff --git a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs new file mode 100644 index 0000000000..c213a17185 --- /dev/null +++ b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs @@ -0,0 +1,581 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring.Legacy; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public abstract partial class ScoringTestScene : OsuTestScene + { + protected abstract IBeatmap CreateBeatmap(int maxCombo); + + protected abstract IScoringAlgorithm CreateScoreV1(); + protected abstract IScoringAlgorithm CreateScoreV2(int maxCombo); + protected abstract ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode); + + protected Bindable MaxCombo => sliderMaxCombo.Current; + protected BindableList NonPerfectLocations => graphs.NonPerfectLocations; + protected BindableList MissLocations => graphs.MissLocations; + + private GraphContainer graphs = null!; + private SettingsSlider sliderMaxCombo = null!; + private SettingsCheckbox scaleToMax = null!; + + private FillFlowContainer legend = null!; + + private readonly BindableBool standardisedVisible = new BindableBool(true); + private readonly BindableBool classicVisible = new BindableBool(true); + private readonly BindableBool scoreV1Visible = new BindableBool(true); + private readonly BindableBool scoreV2Visible = new BindableBool(true); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup tests", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + graphs = new GraphContainer + { + RelativeSizeAxes = Axes.Both, + }, + }, + new Drawable[] + { + legend = new FillFlowContainer + { + Padding = new MarginPadding(20), + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }, + new Drawable[] + { + new FillFlowContainer + { + Padding = new MarginPadding(20), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + sliderMaxCombo = new SettingsSlider + { + TransferValueOnCommit = true, + Current = new BindableInt(1024) + { + MinValue = 96, + MaxValue = 8192, + }, + LabelText = "Max combo", + }, + scaleToMax = new SettingsCheckbox + { + LabelText = "Rescale plots to 100%", + Current = { Value = true, Default = true } + }, + new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = "Left click to add miss\nRight click to add OK", + Margin = new MarginPadding { Top = 20 } + } + } + }, + }, + } + } + }; + + sliderMaxCombo.Current.BindValueChanged(_ => Rerun()); + scaleToMax.Current.BindValueChanged(_ => Rerun()); + + standardisedVisible.BindValueChanged(_ => rescalePlots()); + classicVisible.BindValueChanged(_ => rescalePlots()); + scoreV1Visible.BindValueChanged(_ => rescalePlots()); + scoreV2Visible.BindValueChanged(_ => rescalePlots()); + + graphs.MissLocations.BindCollectionChanged((_, __) => Rerun()); + graphs.NonPerfectLocations.BindCollectionChanged((_, __) => Rerun()); + + graphs.MaxCombo.BindTo(sliderMaxCombo.Current); + + Rerun(); + }); + } + + protected void Rerun() + { + graphs.Clear(); + legend.Clear(); + + runForProcessor("lazer-standardised", colours.Green1, ScoringMode.Standardised, standardisedVisible); + runForProcessor("lazer-classic", colours.Blue1, ScoringMode.Classic, classicVisible); + + runForAlgorithm(new ScoringAlgorithmInfo + { + Name = "ScoreV1 (classic)", + Colour = colours.Purple1, + Algorithm = CreateScoreV1(), + Visible = scoreV1Visible + }); + runForAlgorithm(new ScoringAlgorithmInfo + { + Name = "ScoreV2", + Colour = colours.Red1, + Algorithm = CreateScoreV2(sliderMaxCombo.Current.Value), + Visible = scoreV2Visible + }); + + rescalePlots(); + } + + private void rescalePlots() + { + if (!scaleToMax.Current.Value && legend.Any(entry => entry.Visible.Value)) + { + long maxScore = legend.Where(entry => entry.Visible.Value).Max(entry => entry.FinalScore); + + foreach (var graph in graphs) + graph.Height = graph.Values.Max() / maxScore; + } + else + { + foreach (var graph in graphs) + graph.Height = 1; + } + } + + private void runForProcessor(string name, Color4 colour, ScoringMode scoringMode, BindableBool visibility) + { + int maxCombo = sliderMaxCombo.Current.Value; + var beatmap = CreateBeatmap(maxCombo); + var algorithm = CreateScoreAlgorithm(beatmap, scoringMode); + + runForAlgorithm(new ScoringAlgorithmInfo + { + Name = name, + Colour = colour, + Algorithm = algorithm, + Visible = visibility + }); + } + + private void runForAlgorithm(ScoringAlgorithmInfo algorithmInfo) + { + int maxCombo = sliderMaxCombo.Current.Value; + + List results = new List(); + + for (int i = 0; i < maxCombo; i++) + { + if (graphs.MissLocations.Contains(i)) + algorithmInfo.Algorithm.ApplyMiss(); + else if (graphs.NonPerfectLocations.Contains(i)) + algorithmInfo.Algorithm.ApplyNonPerfect(); + else + algorithmInfo.Algorithm.ApplyHit(); + + results.Add(algorithmInfo.Algorithm.TotalScore); + } + + LineGraph graph; + graphs.Add(graph = new LineGraph + { + Name = algorithmInfo.Name, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + LineColour = algorithmInfo.Colour, + Values = results + }); + + legend.Add(new LegendEntry(algorithmInfo, graph) + { + AccentColour = algorithmInfo.Colour, + }); + } + + private class ScoringAlgorithmInfo + { + public string Name { get; init; } = null!; + public Color4 Colour { get; init; } + public IScoringAlgorithm Algorithm { get; init; } = null!; + public BindableBool Visible { get; init; } = null!; + } + + protected interface IScoringAlgorithm + { + void ApplyHit(); + void ApplyNonPerfect(); + void ApplyMiss(); + + long TotalScore { get; } + } + + protected abstract class ProcessorBasedScoringAlgorithm : IScoringAlgorithm + { + protected abstract ScoreProcessor CreateScoreProcessor(); + protected abstract JudgementResult CreatePerfectJudgementResult(); + protected abstract JudgementResult CreateNonPerfectJudgementResult(); + protected abstract JudgementResult CreateMissJudgementResult(); + + private readonly ScoreProcessor scoreProcessor; + private readonly ScoringMode mode; + + protected ProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode) + { + this.mode = mode; + scoreProcessor = CreateScoreProcessor(); + scoreProcessor.ApplyBeatmap(beatmap); + } + + public void ApplyHit() => scoreProcessor.ApplyResult(CreatePerfectJudgementResult()); + public void ApplyNonPerfect() => scoreProcessor.ApplyResult(CreateNonPerfectJudgementResult()); + public void ApplyMiss() => scoreProcessor.ApplyResult(CreateMissJudgementResult()); + + public long TotalScore => scoreProcessor.GetDisplayScore(mode); + } + + public partial class GraphContainer : Container, IHasCustomTooltip> + { + public readonly BindableList MissLocations = new BindableList(); + public readonly BindableList NonPerfectLocations = new BindableList(); + + public Bindable MaxCombo = new Bindable(); + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private readonly Box hoverLine; + + private readonly Container missLines; + private readonly Container verticalGridLines; + + public int CurrentHoverCombo { get; private set; } + + public GraphContainer() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + verticalGridLines = new Container + { + RelativeSizeAxes = Axes.Both, + }, + hoverLine = new Box + { + Colour = Color4.Yellow, + RelativeSizeAxes = Axes.Y, + Origin = Anchor.TopCentre, + Alpha = 0, + Width = 1, + }, + missLines = new Container + { + Alpha = 0.6f, + RelativeSizeAxes = Axes.Both, + }, + Content, + } + }; + + MissLocations.BindCollectionChanged((_, _) => updateMissLocations()); + NonPerfectLocations.BindCollectionChanged((_, _) => updateMissLocations()); + + MaxCombo.BindValueChanged(_ => + { + updateMissLocations(); + updateVerticalGridLines(); + }, true); + } + + private void updateVerticalGridLines() + { + verticalGridLines.Clear(); + + for (int i = 0; i < MaxCombo.Value; i++) + { + if (i % 100 == 0) + { + verticalGridLines.AddRange(new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)i / MaxCombo.Value, + }, + new OsuSpriteText + { + RelativePositionAxes = Axes.X, + X = (float)i / MaxCombo.Value, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = $"{i:#,0}", + Rotation = -30, + Y = -20, + } + }); + } + } + } + + private void updateMissLocations() + { + missLines.Clear(); + + foreach (int miss in MissLocations) + { + missLines.Add(new Box + { + Colour = Color4.Red, + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)miss / MaxCombo.Value, + }); + } + + foreach (int miss in NonPerfectLocations) + { + missLines.Add(new Box + { + Colour = Color4.Orange, + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)miss / MaxCombo.Value, + }); + } + } + + protected override bool OnHover(HoverEvent e) + { + hoverLine.Show(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverLine.Hide(); + base.OnHoverLost(e); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + CurrentHoverCombo = (int)(e.MousePosition.X / DrawWidth * MaxCombo.Value); + + hoverLine.X = e.MousePosition.X; + return base.OnMouseMove(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + MissLocations.Add(CurrentHoverCombo); + else + NonPerfectLocations.Add(CurrentHoverCombo); + + return true; + } + + private GraphTooltip? tooltip; + + public ITooltip> GetCustomTooltip() => tooltip ??= new GraphTooltip(this); + + public IEnumerable TooltipContent => Content; + + public partial class GraphTooltip : CompositeDrawable, ITooltip> + { + private readonly GraphContainer graphContainer; + + private readonly OsuTextFlowContainer textFlow; + + public GraphTooltip(GraphContainer graphContainer) + { + this.graphContainer = graphContainer; + AutoSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 10; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.15f), + RelativeSizeAxes = Axes.Both, + }, + textFlow = new OsuTextFlowContainer + { + Colour = Color4.White, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + } + }; + } + + private int? lastContentCombo; + + public void SetContent(IEnumerable content) + { + int relevantCombo = graphContainer.CurrentHoverCombo; + + if (lastContentCombo == relevantCombo) + return; + + lastContentCombo = relevantCombo; + textFlow.Clear(); + + textFlow.AddParagraph($"At combo {relevantCombo}:"); + + foreach (var graph in content) + { + if (graph.Alpha == 0) continue; + + float valueAtHover = graph.Values.ElementAt(relevantCombo); + float ofTotal = valueAtHover / graph.Values.Last(); + + textFlow.AddParagraph($"{graph.Name}: {valueAtHover:#,0} ({ofTotal * 100:N0}% of final)\n", st => st.Colour = graph.LineColour); + } + } + + public void Move(Vector2 pos) => this.MoveTo(pos); + } + } + + private partial class LegendEntry : OsuClickableContainer, IHasAccentColour + { + public Color4 AccentColour { get; set; } + + public BindableBool Visible { get; } = new BindableBool(true); + + public readonly long FinalScore; + + private readonly string description; + private readonly LineGraph lineGraph; + + private OsuSpriteText descriptionText = null!; + private OsuSpriteText finalScoreText = null!; + + public LegendEntry(ScoringAlgorithmInfo scoringAlgorithmInfo, LineGraph lineGraph) + { + description = scoringAlgorithmInfo.Name; + FinalScore = scoringAlgorithmInfo.Algorithm.TotalScore; + AccentColour = scoringAlgorithmInfo.Colour; + Visible.BindTo(scoringAlgorithmInfo.Visible); + + this.lineGraph = lineGraph; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X; + AutoSizeAxes = Content.AutoSizeAxes = Axes.Y; + + Children = new Drawable[] + { + descriptionText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + finalScoreText = new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Font = OsuFont.Default.With(fixedWidth: true) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Visible.BindValueChanged(_ => updateState(), true); + Action = Visible.Toggle; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + Colour = IsHovered ? AccentColour.Lighten(0.2f) : AccentColour; + + descriptionText.Text = $"{(Visible.Value ? FontAwesome.Solid.CheckCircle.Icon : FontAwesome.Solid.Circle.Icon)} {description}"; + finalScoreText.Text = FinalScore.ToString("#,0"); + lineGraph.Alpha = Visible.Value ? 1 : 0; + } + } + } +}