// 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 readonly bool supportsNonPerfectJudgements; 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!; protected ScoringTestScene(bool supportsNonPerfectJudgements = true) { this.supportsNonPerfectJudgements = supportsNonPerfectJudgements; } [SetUpSteps] public void SetUpSteps() { AddStep("setup tests", () => { OsuTextFlowContainer clickExplainer; 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(supportsNonPerfectJudgements) { 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 } }, clickExplainer = new OsuTextFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Top = 20 } } } }, }, } } }; clickExplainer.AddParagraph("Left click to add miss"); if (supportsNonPerfectJudgements) clickExplainer.AddParagraph("Right click to add OK"); 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> { private readonly bool supportsNonPerfectJudgements; 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(bool supportsNonPerfectJudgements) { this.supportsNonPerfectJudgements = supportsNonPerfectJudgements; 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 if (supportsNonPerfectJudgements) 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; } } } }