diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index 9c307341bd..af4b002bc9 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -61,13 +61,13 @@ namespace osu.Game.Tests.Gameplay Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); // No header shouldn't cause any change - scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame()); + scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame()); Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(1_000_000)); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); // Reset with a miss instead. - scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame + scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { Header = new FrameHeader(0, 0, 0, new Dictionary { { HitResult.Miss, 1 } }, DateTimeOffset.Now) }); @@ -76,7 +76,7 @@ namespace osu.Game.Tests.Gameplay Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); // Reset with no judged hit. - scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame + scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { Header = new FrameHeader(0, 0, 0, new Dictionary(), DateTimeOffset.Now) }); diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index 94ddc32bb7..bfa67b8c45 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -117,9 +117,8 @@ namespace osu.Game.Rulesets.Scoring /// /// If the provided replay frame does not have any header information, this will be a noop. /// - /// The ruleset to be used for retrieving statistics. /// The replay frame to read header statistics from. - public virtual void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame) + public virtual void ResetFromReplayFrame(ReplayFrame frame) { if (frame.Header == null) return; diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 1dd1d1aeb6..4a5897c621 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.Contracts; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.Online.Spectator; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -88,17 +90,34 @@ namespace osu.Game.Rulesets.Scoring private readonly double accuracyPortion; private readonly double comboPortion; - private int maxAchievableCombo; + /// + /// Scoring values for a perfect play. + /// + public ScoringValues MaximumScoringValues + { + get + { + if (!beatmapApplied) + throw new InvalidOperationException($"Cannot access maximum scoring values before calling {nameof(ApplyBeatmap)}."); + + return maximumScoringValues; + } + } + + private ScoringValues maximumScoringValues; /// - /// The maximum achievable base score. + /// Scoring values for the current play assuming all perfect hits. /// - private double maxBaseScore; + /// + /// This is only used to determine the accuracy with respect to the current point in time for an ongoing play session. + /// + private ScoringValues currentMaximumScoringValues; /// - /// The maximum number of basic (non-tick and non-bonus) hitobjects. + /// Scoring values for the current play. /// - private int maxBasicHitObjects; + private ScoringValues currentScoringValues; /// /// The maximum of a basic (non-tick and non-bonus) hitobject. @@ -106,9 +125,6 @@ namespace osu.Game.Rulesets.Scoring /// private HitResult? maxBasicResult; - private double rollingMaxBaseScore; - private double baseScore; - private int basicHitObjects; private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); @@ -163,6 +179,10 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; + // Always update the maximum scoring values. + applyResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); + currentMaximumScoringValues.MaxCombo += result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; + if (!result.Type.IsScorable()) return; @@ -171,16 +191,8 @@ namespace osu.Game.Rulesets.Scoring else if (result.Type.BreaksCombo()) Combo.Value = 0; - double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; - - if (!result.Type.IsBonus()) - { - baseScore += scoreIncrease; - rollingMaxBaseScore += result.Judgement.MaxNumericResult; - } - - if (result.Type.IsBasic()) - basicHitObjects++; + applyResult(result.Type, ref currentScoringValues); + currentScoringValues.MaxCombo = HighestCombo.Value; hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; @@ -188,6 +200,20 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } + private static void applyResult(HitResult result, ref ScoringValues scoringValues) + { + if (!result.IsScorable()) + return; + + if (result.IsBonus()) + scoringValues.BonusScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; + else + scoringValues.BaseScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; + + if (result.IsBasic()) + scoringValues.CountBasicHitObjects++; + } + /// /// Creates the that describes a . /// @@ -206,19 +232,15 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; + // Always update the maximum scoring values. + revertResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); + currentMaximumScoringValues.MaxCombo -= result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; + if (!result.Type.IsScorable()) return; - double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; - - if (!result.Type.IsBonus()) - { - baseScore -= scoreIncrease; - rollingMaxBaseScore -= result.Judgement.MaxNumericResult; - } - - if (result.Type.IsBasic()) - basicHitObjects--; + revertResult(result.Type, ref currentScoringValues); + currentScoringValues.MaxCombo = HighestCombo.Value; Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; @@ -227,14 +249,24 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } + private static void revertResult(HitResult result, ref ScoringValues scoringValues) + { + if (!result.IsScorable()) + return; + + if (result.IsBonus()) + scoringValues.BonusScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; + else + scoringValues.BaseScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; + + if (result.IsBasic()) + scoringValues.CountBasicHitObjects--; + } + private void updateScore() { - double rollingAccuracyRatio = rollingMaxBaseScore > 0 ? baseScore / rollingMaxBaseScore : 1; - double accuracyRatio = maxBaseScore > 0 ? baseScore / maxBaseScore : 1; - double comboRatio = maxAchievableCombo > 0 ? (double)HighestCombo.Value / maxAchievableCombo : 1; - - Accuracy.Value = rollingAccuracyRatio; - TotalScore.Value = ComputeScore(Mode.Value, accuracyRatio, comboRatio, getBonusScore(scoreResultCounts), maxBasicHitObjects); + Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; + TotalScore.Value = ComputeScore(Mode.Value, currentScoringValues, maximumScoringValues); } /// @@ -246,22 +278,15 @@ namespace osu.Game.Rulesets.Scoring /// The to represent the score as. /// The to compute the total score of. /// The total score in the given . + [Pure] public double ComputeFinalScore(ScoringMode mode, ScoreInfo scoreInfo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - extractFromStatistics(ruleset, - scoreInfo.Statistics, - out double extractedBaseScore, - out double extractedMaxBaseScore, - out int extractedMaxCombo, - out int extractedBasicHitObjects); + ExtractScoringValues(scoreInfo, out var current, out var maximum); - double accuracyRatio = extractedMaxBaseScore > 0 ? extractedBaseScore / extractedMaxBaseScore : 1; - double comboRatio = extractedMaxCombo > 0 ? (double)scoreInfo.MaxCombo / extractedMaxCombo : 1; - - return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), extractedBasicHitObjects); + return ComputeScore(mode, current, maximum); } /// @@ -273,6 +298,7 @@ namespace osu.Game.Rulesets.Scoring /// The to represent the score as. /// The to compute the total score of. /// The total score in the given . + [Pure] public double ComputePartialScore(ScoringMode mode, ScoreInfo scoreInfo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) @@ -281,17 +307,9 @@ namespace osu.Game.Rulesets.Scoring if (!beatmapApplied) throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}."); - extractFromStatistics(ruleset, - scoreInfo.Statistics, - out double extractedBaseScore, - out _, - out _, - out _); + ExtractScoringValues(scoreInfo, out var current, out _); - double accuracyRatio = maxBaseScore > 0 ? extractedBaseScore / maxBaseScore : 1; - double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; - - return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), maxBasicHitObjects); + return ComputeScore(mode, current, MaximumScoringValues); } /// @@ -305,6 +323,7 @@ namespace osu.Game.Rulesets.Scoring /// The to compute the total score of. /// The maximum achievable combo for the provided beatmap. /// The total score in the given . + [Pure] public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) @@ -313,26 +332,30 @@ namespace osu.Game.Rulesets.Scoring double accuracyRatio = scoreInfo.Accuracy; double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; + ExtractScoringValues(scoreInfo, out var current, out var maximum); + // For legacy osu!mania scores, a full-GREAT score has 100% accuracy. If combined with a full-combo, the score becomes indistinguishable from a full-PERFECT score. // To get around this, the accuracy ratio is always recalculated based on the hit statistics rather than trusting the score. // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. - if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3) - { - extractFromStatistics( - ruleset, - scoreInfo.Statistics, - out double computedBaseScore, - out double computedMaxBaseScore, - out _, - out _); + if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3 && maximum.BaseScore > 0) + accuracyRatio = current.BaseScore / maximum.BaseScore; - if (computedMaxBaseScore > 0) - accuracyRatio = computedBaseScore / computedMaxBaseScore; - } + return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); + } - int computedBasicHitObjects = scoreInfo.Statistics.Where(kvp => kvp.Key.IsBasic()).Select(kvp => kvp.Value).Sum(); - - return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), computedBasicHitObjects); + /// + /// Computes the total score from scoring values. + /// + /// The to represent the score as. + /// The current scoring values. + /// The maximum scoring values. + /// The total score computed from the given scoring values. + [Pure] + public double ComputeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) + { + double accuracyRatio = maximum.BaseScore > 0 ? current.BaseScore / maximum.BaseScore : 1; + double comboRatio = maximum.MaxCombo > 0 ? (double)current.MaxCombo / maximum.MaxCombo : 1; + return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); } /// @@ -344,6 +367,7 @@ namespace osu.Game.Rulesets.Scoring /// The total bonus score. /// The total number of basic (non-tick and non-bonus) hitobjects in the beatmap. /// The total score computed from the given scoring component ratios. + [Pure] public double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects) { switch (mode) @@ -362,15 +386,6 @@ namespace osu.Game.Rulesets.Scoring } } - /// - /// Calculates the total bonus score from score statistics. - /// - /// The score statistics. - /// The total bonus score. - private double getBonusScore(IReadOnlyDictionary statistics) - => statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE - + statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; - private ScoreRank rankFrom(double acc) { if (acc == 1) @@ -402,15 +417,10 @@ namespace osu.Game.Rulesets.Scoring lastHitObject = null; if (storeResults) - { - maxAchievableCombo = HighestCombo.Value; - maxBaseScore = baseScore; - maxBasicHitObjects = basicHitObjects; - } + maximumScoringValues = currentScoringValues; - baseScore = 0; - rollingMaxBaseScore = 0; - basicHitObjects = 0; + currentScoringValues = default; + currentMaximumScoringValues = default; TotalScore.Value = 0; Accuracy.Value = 1; @@ -437,14 +447,19 @@ namespace osu.Game.Rulesets.Scoring score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score)); } - public override void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame) + public override void ResetFromReplayFrame(ReplayFrame frame) { - base.ResetFromReplayFrame(ruleset, frame); + base.ResetFromReplayFrame(frame); if (frame.Header == null) return; - extractFromStatistics(ruleset, frame.Header.Statistics, out baseScore, out rollingMaxBaseScore, out _, out _); + extractScoringValues(frame.Header.Statistics, out var current, out var maximum); + currentScoringValues.BaseScore = current.BaseScore; + currentScoringValues.MaxCombo = frame.Header.MaxCombo; + currentMaximumScoringValues.BaseScore = maximum.BaseScore; + currentMaximumScoringValues.MaxCombo = maximum.MaxCombo; + HighestCombo.Value = frame.Header.MaxCombo; scoreResultCounts.Clear(); @@ -455,52 +470,126 @@ namespace osu.Game.Rulesets.Scoring OnResetFromReplayFrame?.Invoke(); } - private void extractFromStatistics(Ruleset ruleset, IReadOnlyDictionary statistics, out double baseScore, out double maxBaseScore, out int maxCombo, - out int basicHitObjects) + #region ScoringValue extraction + + /// + /// Applies a best-effort extraction of hit statistics into . + /// + /// + /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: + /// + /// The maximum will always be 0. + /// The current and maximum will always be the same value. + /// + /// Consumers are expected to more accurately fill in the above values through external means. + /// + /// Ensure to fill in the maximum for use in + /// . + /// + /// + /// The score to extract scoring values from. + /// The "current" scoring values, representing the hit statistics as they appear. + /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. + [Pure] + internal void ExtractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum) { - baseScore = 0; - maxBaseScore = 0; - maxCombo = 0; - basicHitObjects = 0; + extractScoringValues(scoreInfo.Statistics, out current, out maximum); + current.MaxCombo = scoreInfo.MaxCombo; + } + + /// + /// Applies a best-effort extraction of hit statistics into . + /// + /// + /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: + /// + /// The maximum will always be 0. + /// The current and maximum will always be the same value. + /// + /// Consumers are expected to more accurately fill in the above values through external means. + /// + /// Ensure to fill in the maximum for use in + /// . + /// + /// + /// The replay frame header to extract scoring values from. + /// The "current" scoring values, representing the hit statistics as they appear. + /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. + [Pure] + internal void ExtractScoringValues(FrameHeader header, out ScoringValues current, out ScoringValues maximum) + { + extractScoringValues(header.Statistics, out current, out maximum); + current.MaxCombo = header.MaxCombo; + } + + /// + /// Applies a best-effort extraction of hit statistics into . + /// + /// + /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: + /// + /// The current will always be 0. + /// The maximum will always be 0. + /// The current and maximum will always be the same value. + /// + /// Consumers are expected to more accurately fill in the above values (especially the current ) via external means (e.g. ). + /// + /// The hit statistics to extract scoring values from. + /// The "current" scoring values, representing the hit statistics as they appear. + /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. + [Pure] + private void extractScoringValues(IReadOnlyDictionary statistics, out ScoringValues current, out ScoringValues maximum) + { + current = default; + maximum = default; foreach ((HitResult result, int count) in statistics) { - // Bonus scores are counted separately directly from the statistics dictionary later on. - if (!result.IsScorable() || result.IsBonus()) + if (!result.IsScorable()) continue; - // The maximum result of this judgement if it wasn't a miss. - // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). - HitResult maxResult; - - switch (result) + if (result.IsBonus()) + current.BonusScore += count * Judgement.ToNumericResult(result); + else { - case HitResult.LargeTickHit: - case HitResult.LargeTickMiss: - maxResult = HitResult.LargeTickHit; - break; + // The maximum result of this judgement if it wasn't a miss. + // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). + HitResult maxResult; - case HitResult.SmallTickHit: - case HitResult.SmallTickMiss: - maxResult = HitResult.SmallTickHit; - break; + switch (result) + { + case HitResult.LargeTickHit: + case HitResult.LargeTickMiss: + maxResult = HitResult.LargeTickHit; + break; - default: - maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; - break; + case HitResult.SmallTickHit: + case HitResult.SmallTickMiss: + maxResult = HitResult.SmallTickHit; + break; + + default: + maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; + break; + } + + current.BaseScore += count * Judgement.ToNumericResult(result); + maximum.BaseScore += count * Judgement.ToNumericResult(maxResult); } - baseScore += count * Judgement.ToNumericResult(result); - maxBaseScore += count * Judgement.ToNumericResult(maxResult); - if (result.AffectsCombo()) - maxCombo += count; + maximum.MaxCombo += count; if (result.IsBasic()) - basicHitObjects += count; + { + current.CountBasicHitObjects += count; + maximum.CountBasicHitObjects += count; + } } } + #endregion + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 7d1b23f48b..b5390eb6e2 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -27,8 +27,6 @@ namespace osu.Game.Rulesets.UI { public readonly KeyBindingContainer KeyBindingContainer; - private readonly Ruleset ruleset; - [Resolved(CanBeNull = true)] private ScoreProcessor scoreProcessor { get; set; } @@ -57,8 +55,6 @@ namespace osu.Game.Rulesets.UI protected RulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) { - this.ruleset = ruleset.CreateInstance(); - InternalChild = KeyBindingContainer = CreateKeyBindingContainer(ruleset, variant, unique) .WithChild(content = new Container { RelativeSizeAxes = Axes.Both }); @@ -85,7 +81,7 @@ namespace osu.Game.Rulesets.UI break; case ReplayStatisticsFrameEvent statisticsStateChangeEvent: - scoreProcessor?.ResetFromReplayFrame(ruleset, statisticsStateChangeEvent.Frame); + scoreProcessor?.ResetFromReplayFrame(statisticsStateChangeEvent.Frame); break; default: diff --git a/osu.Game/Scoring/ScoringValues.cs b/osu.Game/Scoring/ScoringValues.cs new file mode 100644 index 0000000000..d31cd7c68b --- /dev/null +++ b/osu.Game/Scoring/ScoringValues.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Scoring +{ + /// + /// Stores the required scoring data that fulfils the minimum requirements for a to calculate score. + /// + [MessagePackObject] + public struct ScoringValues + { + /// + /// The sum of all "basic" scoring values. See: and . + /// + [Key(0)] + public double BaseScore; + + /// + /// The sum of all "bonus" scoring values. See: and . + /// + [Key(1)] + public double BonusScore; + + /// + /// The highest achieved combo. + /// + [Key(2)] + public int MaxCombo; + + /// + /// The count of "basic" s. See: . + /// + [Key(3)] + public int CountBasicHitObjects; + } +} diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index fabc087952..6a99dfa108 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -196,7 +196,7 @@ namespace osu.Game.Screens.Edit.Timing // Try and create matching types from the currently selected control point. var selected = selectedGroup.Value; - if (selected != null) + if (selected != null && selected != group) { foreach (var controlPoint in selected.ControlPoints) {