// Copyright (c) ppy Pty Ltd <[email protected]>. 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 MessagePack; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { public partial class ScoreProcessor : JudgementProcessor { /// <summary> /// The exponent applied to combo in the default implementation of <see cref="GetComboScoreChange"/>. /// </summary> /// <remarks> /// If a custom implementation overrides <see cref="GetComboScoreChange"/> this may not be relevant. /// </remarks> public const double COMBO_EXPONENT = 0.5; public const double MAX_SCORE = 1000000; private const double accuracy_cutoff_x = 1; private const double accuracy_cutoff_s = 0.95; private const double accuracy_cutoff_a = 0.9; private const double accuracy_cutoff_b = 0.8; private const double accuracy_cutoff_c = 0.7; private const double accuracy_cutoff_d = 0; /// <summary> /// Whether <see cref="HitEvents"/> should be populated during application of results. /// </summary> /// <remarks> /// Should only be disabled for special cases. /// When disabled, <see cref="JudgementProcessor.RevertResult"/> cannot be used.</remarks> internal bool TrackHitEvents = true; /// <summary> /// Invoked when this <see cref="ScoreProcessor"/> was reset from a replay frame. /// </summary> public event Action? OnResetFromReplayFrame; /// <summary> /// The current total score. /// </summary> public readonly BindableLong TotalScore = new BindableLong { MinValue = 0 }; /// <summary> /// The total number of points awarded for the score without including mod multipliers. /// </summary> /// <remarks> /// The purpose of this property is to enable future lossless rebalances of mod multipliers. /// </remarks> public readonly BindableLong TotalScoreWithoutMods = new BindableLong { MinValue = 0 }; /// <summary> /// The current accuracy. /// </summary> public readonly BindableDouble Accuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; /// <summary> /// The minimum achievable accuracy for the whole beatmap at this stage of gameplay. /// Assumes that all objects that have not been judged yet will receive the minimum hit result. /// </summary> public readonly BindableDouble MinimumAccuracy = new BindableDouble { MinValue = 0, MaxValue = 1 }; /// <summary> /// The maximum achievable accuracy for the whole beatmap at this stage of gameplay. /// Assumes that all objects that have not been judged yet will receive the maximum hit result. /// </summary> public readonly BindableDouble MaximumAccuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; /// <summary> /// The current combo. /// </summary> public readonly BindableInt Combo = new BindableInt(); /// <summary> /// The current selected mods /// </summary> public readonly Bindable<IReadOnlyList<Mod>> Mods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); /// <summary> /// The current rank. /// </summary> public IBindable<ScoreRank> Rank => rank; private readonly Bindable<ScoreRank> rank = new Bindable<ScoreRank>(ScoreRank.X); /// <summary> /// The highest combo achieved by this score. /// </summary> public readonly BindableInt HighestCombo = new BindableInt(); /// <summary> /// The <see cref="HitEvent"/>s collected during gameplay thus far. /// Intended for use with various statistics displays. /// </summary> public IReadOnlyList<HitEvent> HitEvents => hitEvents; /// <summary> /// The ruleset this score processor is valid for. /// </summary> public readonly Ruleset Ruleset; /// <summary> /// The maximum achievable total score. /// </summary> public long MaximumTotalScore { get; private set; } /// <summary> /// The maximum achievable combo. /// </summary> public int MaximumCombo { get; private set; } /// <summary> /// The maximum sum of accuracy-affecting judgements at the current point in time. /// </summary> /// <remarks> /// Used to compute accuracy. /// </remarks> private double currentMaximumBaseScore; /// <summary> /// The sum of all accuracy-affecting judgements at the current point in time. /// </summary> /// <remarks> /// Used to compute accuracy. /// </remarks> private double currentBaseScore; /// <summary> /// The maximum sum of all accuracy-affecting judgements in the beatmap. /// </summary> private double maximumBaseScore; /// <summary> /// The count of all accuracy-affecting judgements in the beatmap. /// </summary> private int maximumAccuracyJudgementCount; /// <summary> /// The count of accuracy-affecting judgements at the current point in time. /// </summary> private int currentAccuracyJudgementCount; /// <summary> /// The maximum combo score in the beatmap. /// </summary> private double maximumComboPortion; /// <summary> /// The combo score at the current point in time. /// </summary> private double currentComboPortion; /// <summary> /// The bonus score at the current point in time. /// </summary> private double currentBonusPortion; /// <summary> /// The total score multiplier. /// </summary> private double scoreMultiplier = 1; public Dictionary<HitResult, int> MaximumStatistics { get { if (!beatmapApplied) throw new InvalidOperationException($"Cannot access maximum statistics before calling {nameof(ApplyBeatmap)}."); return new Dictionary<HitResult, int>(MaximumResultCounts); } } public IReadOnlyDictionary<HitResult, int> Statistics => ScoreResultCounts; private bool beatmapApplied; protected readonly Dictionary<HitResult, int> ScoreResultCounts = new Dictionary<HitResult, int>(); protected readonly Dictionary<HitResult, int> MaximumResultCounts = new Dictionary<HitResult, int>(); private readonly List<HitEvent> hitEvents = new List<HitEvent>(); private HitObject? lastHitObject; public bool ApplyNewJudgementsWhenFailed { get; set; } public ScoreProcessor(Ruleset ruleset) { Ruleset = ruleset; Accuracy.ValueChanged += _ => updateRank(); Mods.ValueChanged += mods => { scoreMultiplier = 1; foreach (var m in mods.NewValue) scoreMultiplier *= m.ScoreMultiplier; updateScore(); updateRank(); }; } public override void ApplyBeatmap(IBeatmap beatmap) { base.ApplyBeatmap(beatmap); beatmapApplied = true; } protected sealed override void ApplyResultInternal(JudgementResult result) { result.ComboAtJudgement = Combo.Value; result.HighestComboAtJudgement = HighestCombo.Value; if (result.FailedAtJudgement && !ApplyNewJudgementsWhenFailed) return; ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1; if (result.Type.IncreasesCombo()) Combo.Value++; else if (result.Type.BreaksCombo()) Combo.Value = 0; HighestCombo.Value = Math.Max(HighestCombo.Value, Combo.Value); result.ComboAfterJudgement = Combo.Value; result.HighestComboAfterJudgement = HighestCombo.Value; if (result.Judgement.MaxResult.AffectsAccuracy()) { currentMaximumBaseScore += GetBaseScoreForResult(result.Judgement.MaxResult); currentAccuracyJudgementCount++; } if (result.Type.AffectsAccuracy()) currentBaseScore += GetBaseScoreForResult(result.Type); if (result.Type.IsBonus()) currentBonusPortion += GetBonusScoreChange(result); else if (result.Type.IsScorable()) currentComboPortion += GetComboScoreChange(result); ApplyScoreChange(result); if (!IsSimulating) { if (TrackHitEvents) { hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; } updateScore(); } } /// <summary> /// Creates the <see cref="HitEvent"/> that describes a <see cref="JudgementResult"/>. /// </summary> /// <param name="result">The <see cref="JudgementResult"/> to describe.</param> /// <returns>The <see cref="HitEvent"/>.</returns> protected virtual HitEvent CreateHitEvent(JudgementResult result) => new HitEvent(result.TimeOffset, result.GameplayRate, result.Type, result.HitObject, lastHitObject, null); protected sealed override void RevertResultInternal(JudgementResult result) { if (!TrackHitEvents) throw new InvalidOperationException(@$"Rewind is not supported when {nameof(TrackHitEvents)} is disabled."); // the reason this is written so funnily rather than just using `ComboAtJudgement` // is to nullify impact of ordering when reverting concurrent judgement results // (think mania and multiple judgements within a frame). Combo.Value -= (result.ComboAfterJudgement - result.ComboAtJudgement); HighestCombo.Value -= (result.HighestComboAfterJudgement - result.HighestComboAtJudgement); if (result.FailedAtJudgement && !ApplyNewJudgementsWhenFailed) return; ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) - 1; if (result.Judgement.MaxResult.AffectsAccuracy()) { currentMaximumBaseScore -= GetBaseScoreForResult(result.Judgement.MaxResult); currentAccuracyJudgementCount--; } if (result.Type.AffectsAccuracy()) currentBaseScore -= GetBaseScoreForResult(result.Type); if (result.Type.IsBonus()) currentBonusPortion -= GetBonusScoreChange(result); else if (result.Type.IsScorable()) currentComboPortion -= GetComboScoreChange(result); RemoveScoreChange(result); Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; hitEvents.RemoveAt(hitEvents.Count - 1); updateScore(); } /// <summary> /// Gets the final score change to be applied to the bonus portion of the score. /// </summary> /// <param name="result">The judgement result.</param> protected virtual double GetBonusScoreChange(JudgementResult result) => GetBaseScoreForResult(result.Type); /// <summary> /// Gets the final score change to be applied to the combo portion of the score. /// </summary> /// <param name="result">The judgement result.</param> protected virtual double GetComboScoreChange(JudgementResult result) => GetBaseScoreForResult(result.Judgement.MaxResult) * Math.Pow(result.ComboAfterJudgement, COMBO_EXPONENT); public virtual int GetBaseScoreForResult(HitResult result) { switch (result) { default: return 0; case HitResult.SmallTickHit: return 10; case HitResult.LargeTickHit: return 30; case HitResult.SliderTailHit: return 150; case HitResult.Meh: return 50; case HitResult.Ok: return 100; case HitResult.Good: return 200; case HitResult.Great: case HitResult.Perfect: // Perfect doesn't actually give more score / accuracy directly. return 300; case HitResult.SmallBonus: return 10; case HitResult.LargeBonus: return 50; } } protected virtual void ApplyScoreChange(JudgementResult result) { } protected virtual void RemoveScoreChange(JudgementResult result) { } private void updateScore() { Accuracy.Value = currentMaximumBaseScore > 0 ? currentBaseScore / currentMaximumBaseScore : 1; MinimumAccuracy.Value = maximumBaseScore > 0 ? currentBaseScore / maximumBaseScore : 0; MaximumAccuracy.Value = maximumBaseScore > 0 ? (currentBaseScore + (maximumBaseScore - currentMaximumBaseScore)) / maximumBaseScore : 1; double comboProgress = maximumComboPortion > 0 ? currentComboPortion / maximumComboPortion : 1; double accuracyProgress = maximumAccuracyJudgementCount > 0 ? (double)currentAccuracyJudgementCount / maximumAccuracyJudgementCount : 1; TotalScoreWithoutMods.Value = (long)Math.Round(ComputeTotalScore(comboProgress, accuracyProgress, currentBonusPortion)); TotalScore.Value = (long)Math.Round(TotalScoreWithoutMods.Value * scoreMultiplier); } private void updateRank() { // Once failed, we shouldn't update the rank anymore. if (rank.Value == ScoreRank.F) return; ScoreRank newRank = RankFromScore(Accuracy.Value, ScoreResultCounts); foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>()) newRank = mod.AdjustRank(newRank, Accuracy.Value); rank.Value = newRank; } protected virtual double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { return 500000 * Accuracy.Value * comboProgress + 500000 * Math.Pow(Accuracy.Value, 5) * accuracyProgress + bonusPortion; } /// <summary> /// Resets this ScoreProcessor to a default state. /// </summary> /// <param name="storeResults">Whether to store the current state of the <see cref="ScoreProcessor"/> for future use.</param> protected override void Reset(bool storeResults) { // Run one last time to store max values. updateScore(); base.Reset(storeResults); hitEvents.Clear(); lastHitObject = null; if (storeResults) { maximumBaseScore = currentBaseScore; maximumComboPortion = currentComboPortion; maximumAccuracyJudgementCount = currentAccuracyJudgementCount; MaximumResultCounts.Clear(); MaximumResultCounts.AddRange(ScoreResultCounts); MaximumTotalScore = TotalScore.Value; MaximumCombo = HighestCombo.Value; } ScoreResultCounts.Clear(); currentBaseScore = 0; currentMaximumBaseScore = 0; currentAccuracyJudgementCount = 0; currentComboPortion = 0; currentBonusPortion = 0; TotalScore.Value = 0; Accuracy.Value = 1; Combo.Value = 0; HighestCombo.Value = 0; updateRank(); } /// <summary> /// Retrieve a score populated with data for the current play this processor is responsible for. /// </summary> public virtual void PopulateScore(ScoreInfo score) { score.Combo = Combo.Value; score.MaxCombo = HighestCombo.Value; score.Accuracy = Accuracy.Value; score.Rank = Rank.Value; score.HitEvents = hitEvents; score.Statistics.Clear(); score.MaximumStatistics.Clear(); foreach (var result in HitResultExtensions.ALL_TYPES) score.Statistics[result] = ScoreResultCounts.GetValueOrDefault(result); foreach (var result in HitResultExtensions.ALL_TYPES) score.MaximumStatistics[result] = MaximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. score.TotalScoreWithoutMods = TotalScoreWithoutMods.Value; score.TotalScore = TotalScore.Value; } /// <summary> /// Populates a failed score, marking it with the <see cref="ScoreRank.F"/> rank. /// </summary> public void FailScore(ScoreInfo score) { if (Rank.Value == ScoreRank.F) return; score.Passed = false; rank.Value = ScoreRank.F; PopulateScore(score); } public override void ResetFromReplayFrame(ReplayFrame frame) { base.ResetFromReplayFrame(frame); if (frame.Header == null) return; Combo.Value = frame.Header.Combo; HighestCombo.Value = frame.Header.MaxCombo; TotalScore.Value = frame.Header.TotalScore; ScoreResultCounts.Clear(); ScoreResultCounts.AddRange(frame.Header.Statistics); SetScoreProcessorStatistics(frame.Header.ScoreProcessorStatistics); updateScore(); OnResetFromReplayFrame?.Invoke(); } public ScoreProcessorStatistics GetScoreProcessorStatistics() => new ScoreProcessorStatistics { MaximumBaseScore = currentMaximumBaseScore, BaseScore = currentBaseScore, AccuracyJudgementCount = currentAccuracyJudgementCount, ComboPortion = currentComboPortion, BonusPortion = currentBonusPortion }; public void SetScoreProcessorStatistics(ScoreProcessorStatistics statistics) { currentMaximumBaseScore = statistics.MaximumBaseScore; currentBaseScore = statistics.BaseScore; currentAccuracyJudgementCount = statistics.AccuracyJudgementCount; currentComboPortion = statistics.ComboPortion; currentBonusPortion = statistics.BonusPortion; } #region Static helper methods /// <summary> /// Given an accuracy (0..1), return the correct <see cref="ScoreRank"/>. /// </summary> public virtual ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results) { if (accuracy == accuracy_cutoff_x) return ScoreRank.X; if (accuracy >= accuracy_cutoff_s) return ScoreRank.S; if (accuracy >= accuracy_cutoff_a) return ScoreRank.A; if (accuracy >= accuracy_cutoff_b) return ScoreRank.B; if (accuracy >= accuracy_cutoff_c) return ScoreRank.C; return ScoreRank.D; } /// <summary> /// Given a <see cref="ScoreRank"/>, return the cutoff accuracy (0..1). /// Accuracy must be greater than or equal to the cutoff to qualify for the provided rank. /// </summary> public virtual double AccuracyCutoffFromRank(ScoreRank rank) { switch (rank) { case ScoreRank.X: case ScoreRank.XH: return accuracy_cutoff_x; case ScoreRank.S: case ScoreRank.SH: return accuracy_cutoff_s; case ScoreRank.A: return accuracy_cutoff_a; case ScoreRank.B: return accuracy_cutoff_b; case ScoreRank.C: return accuracy_cutoff_c; case ScoreRank.D: return accuracy_cutoff_d; default: throw new ArgumentOutOfRangeException(nameof(rank), rank, null); } } #endregion protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); hitEvents.Clear(); } } public enum ScoringMode { [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.StandardisedScoreDisplay))] Standardised, [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.ClassicScoreDisplay))] Classic } [Serializable] [MessagePackObject] public class ScoreProcessorStatistics { /// <summary> /// The sum of all accuracy-affecting judgements at the current point in time. /// </summary> /// <remarks> /// Used to compute accuracy. /// See: <see cref="HitResultExtensions.IsBasic"/> and <see cref="ScoreProcessor.GetBaseScoreForResult"/>. /// </remarks> [Key(0)] public double BaseScore { get; set; } /// <summary> /// The maximum sum of accuracy-affecting judgements at the current point in time. /// </summary> /// <remarks> /// Used to compute accuracy. /// </remarks> [Key(1)] public double MaximumBaseScore { get; set; } /// <summary> /// The count of accuracy-affecting judgements at the current point in time. /// </summary> [Key(2)] public int AccuracyJudgementCount { get; set; } /// <summary> /// The combo score at the current point in time. /// </summary> [Key(3)] public double ComboPortion { get; set; } /// <summary> /// The bonus score at the current point in time. /// </summary> [Key(4)] public double BonusPortion { get; set; } } }