// 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.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { public class ScoreProcessor : JudgementProcessor { private const double max_score = 1000000; /// /// The current total score. /// public readonly BindableDouble TotalScore = new BindableDouble { MinValue = 0 }; /// /// The current accuracy. /// public readonly BindableDouble Accuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; /// /// The current combo. /// public readonly BindableInt Combo = new BindableInt(); /// /// The current selected mods /// public readonly Bindable> Mods = new Bindable>(Array.Empty()); /// /// The current rank. /// public readonly Bindable Rank = new Bindable(ScoreRank.X); /// /// The highest combo achieved by this score. /// public readonly BindableInt HighestCombo = new BindableInt(); /// /// The used to calculate scores. /// public readonly Bindable Mode = new Bindable(); /// /// The default portion of awarded for hitting s accurately. Defaults to 30%. /// protected virtual double DefaultAccuracyPortion => 0.3; /// /// The default portion of awarded for achieving a high combo. Default to 70%. /// protected virtual double DefaultComboPortion => 0.7; private readonly double accuracyPortion; private readonly double comboPortion; private int maxAchievableCombo; /// /// The maximum achievable base score. /// private double maxBaseScore; private double rollingMaxBaseScore; private double baseScore; private readonly List hitEvents = new List(); private HitObject lastHitObject; private double scoreMultiplier = 1; public ScoreProcessor() { accuracyPortion = DefaultAccuracyPortion; comboPortion = DefaultComboPortion; if (!Precision.AlmostEquals(1.0, accuracyPortion + comboPortion)) throw new InvalidOperationException($"{nameof(DefaultAccuracyPortion)} + {nameof(DefaultComboPortion)} must equal 1."); Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => { Rank.Value = rankFrom(accuracy.NewValue); foreach (var mod in Mods.Value.OfType()) Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; Mode.ValueChanged += _ => updateScore(); Mods.ValueChanged += mods => { scoreMultiplier = 1; foreach (var m in mods.NewValue) scoreMultiplier *= m.ScoreMultiplier; updateScore(); }; } private readonly Dictionary scoreResultCounts = new Dictionary(); protected sealed override void ApplyResultInternal(JudgementResult result) { result.ComboAtJudgement = Combo.Value; result.HighestComboAtJudgement = HighestCombo.Value; if (result.FailedAtJudgement) return; if (!result.Type.IsScorable()) return; if (result.Type.AffectsCombo()) { switch (result.Type) { case HitResult.Miss: case HitResult.LargeTickMiss: Combo.Value = 0; break; default: Combo.Value++; break; } } double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; if (!result.Type.IsBonus()) { baseScore += scoreIncrease; rollingMaxBaseScore += result.Judgement.MaxNumericResult; } scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) + 1; hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; updateScore(); } /// /// Creates the that describes a . /// /// The to describe. /// The . protected virtual HitEvent CreateHitEvent(JudgementResult result) => new HitEvent(result.TimeOffset, result.Type, result.HitObject, lastHitObject, null); protected sealed override void RevertResultInternal(JudgementResult result) { Combo.Value = result.ComboAtJudgement; HighestCombo.Value = result.HighestComboAtJudgement; if (result.FailedAtJudgement) return; 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; } scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) - 1; Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; hitEvents.RemoveAt(hitEvents.Count - 1); updateScore(); } private void updateScore() { if (rollingMaxBaseScore != 0) Accuracy.Value = baseScore / rollingMaxBaseScore; TotalScore.Value = getScore(Mode.Value); } private double getScore(ScoringMode mode) { return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(baseScore), calculateComboRatio(HighestCombo.Value), scoreResultCounts); } /// /// Computes the total score. /// /// The to compute the total score in. /// The maximum combo achievable in the beatmap. /// The accuracy percentage achieved by the player. /// The proportion of achieved by the player. /// Any statistics to be factored in. /// The total score. public double GetScore(ScoringMode mode, int maxCombo, double accuracyRatio, double comboRatio, Dictionary statistics) { switch (mode) { default: case ScoringMode.Standardised: double accuracyScore = accuracyPortion * accuracyRatio; double comboScore = comboPortion * comboRatio; return (max_score * (accuracyScore + comboScore) + getBonusScore(statistics)) * scoreMultiplier; case ScoringMode.Classic: // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) return getBonusScore(statistics) + (accuracyRatio * Math.Max(1, maxCombo) * 300) * (1 + Math.Max(0, (comboRatio * maxCombo) - 1) * scoreMultiplier / 25); } } /// /// Given a minimal set of inputs, return the computed score and accuracy for the tracked beatmap / mods combination. /// /// The to compute the total score in. /// The maximum combo achievable in the beatmap. /// Statistics to be used for calculating accuracy, bonus score, etc. /// The computed score and accuracy for provided inputs. public (double score, double accuracy) GetScoreAndAccuracy(ScoringMode mode, int maxCombo, Dictionary statistics) { // calculate base score from statistics pairs int computedBaseScore = 0; foreach (var pair in statistics) { if (!pair.Key.AffectsAccuracy()) continue; computedBaseScore += Judgement.ToNumericResult(pair.Key) * pair.Value; } double accuracy = calculateAccuracyRatio(computedBaseScore); double comboRatio = calculateComboRatio(maxCombo); double score = GetScore(mode, maxAchievableCombo, accuracy, comboRatio, scoreResultCounts); return (score, accuracy); } private double calculateAccuracyRatio(double baseScore) => maxBaseScore > 0 ? baseScore / maxBaseScore : 0; private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1; private double getBonusScore(Dictionary statistics) => statistics.GetOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE + statistics.GetOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; private ScoreRank rankFrom(double acc) { if (acc == 1) return ScoreRank.X; if (acc > 0.95) return ScoreRank.S; if (acc > 0.9) return ScoreRank.A; if (acc > 0.8) return ScoreRank.B; if (acc > 0.7) return ScoreRank.C; return ScoreRank.D; } public int GetStatistic(HitResult result) => scoreResultCounts.GetOrDefault(result); public double GetStandardisedScore() => getScore(ScoringMode.Standardised); /// /// Resets this ScoreProcessor to a default state. /// /// Whether to store the current state of the for future use. protected override void Reset(bool storeResults) { base.Reset(storeResults); scoreResultCounts.Clear(); hitEvents.Clear(); lastHitObject = null; if (storeResults) { maxAchievableCombo = HighestCombo.Value; maxBaseScore = baseScore; } baseScore = 0; rollingMaxBaseScore = 0; TotalScore.Value = 0; Accuracy.Value = 1; Combo.Value = 0; Rank.Value = ScoreRank.X; HighestCombo.Value = 0; } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); hitEvents.Clear(); } /// /// Retrieve a score populated with data for the current play this processor is responsible for. /// public virtual void PopulateScore(ScoreInfo score) { score.TotalScore = (long)Math.Round(GetStandardisedScore()); score.Combo = Combo.Value; score.MaxCombo = HighestCombo.Value; score.Accuracy = Math.Round(Accuracy.Value, 4); score.Rank = Rank.Value; score.Date = DateTimeOffset.Now; foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.IsScorable())) score.Statistics[result] = GetStatistic(result); score.HitEvents = hitEvents; } /// /// Create a for this processor. /// [Obsolete("Method is now unused.")] // Can be removed 20210328 public virtual HitWindows CreateHitWindows() => new HitWindows(); } public enum ScoringMode { Standardised, Classic } }