// 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.Extensions.TypeExtensions; using osu.Framework.MathUtils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { public abstract class ScoreProcessor { /// /// Invoked when the is in a failed state. /// This may occur regardless of whether an event is invoked. /// Return true if the fail was permitted. /// public event Func Failed; /// /// Invoked when all s have been judged. /// public event Action AllJudged; /// /// Invoked when a new judgement has occurred. This occurs after the judgement has been processed by the . /// public event Action NewJudgement; /// /// Additional conditions on top of that cause a failing state. /// public event Func FailConditions; /// /// 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 health. /// public readonly BindableDouble Health = 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()); /// /// Create a for this processor. /// public virtual HitWindows CreateHitWindows() => new HitWindows(); /// /// 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(); /// /// Whether all s have been processed. /// public virtual bool HasCompleted => false; /// /// The total number of judged s at the current point in time. /// public int JudgedHits { get; protected set; } /// /// Whether this ScoreProcessor has already triggered the failed state. /// public virtual bool HasFailed { get; private set; } /// /// The default conditions for failing. /// protected virtual bool DefaultFailCondition => Precision.AlmostBigger(Health.MinValue, Health.Value); protected ScoreProcessor() { Combo.ValueChanged += delegate { HighestCombo.Value = Math.Max(HighestCombo.Value, Combo.Value); }; Accuracy.ValueChanged += delegate { Rank.Value = rankFrom(Accuracy.Value); foreach (var mod in Mods.Value.OfType()) Rank.Value = mod.AdjustRank(Rank.Value, Accuracy.Value); }; } 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; } /// /// Resets this ScoreProcessor to a default state. /// /// Whether to store the current state of the for future use. protected virtual void Reset(bool storeResults) { TotalScore.Value = 0; Accuracy.Value = 1; Health.Value = 1; Combo.Value = 0; Rank.Value = ScoreRank.X; HighestCombo.Value = 0; JudgedHits = 0; HasFailed = false; } /// /// Checks if the score is in a failed state and notifies subscribers. /// /// This can only ever notify subscribers once. /// /// protected void UpdateFailed(JudgementResult result) { if (HasFailed) return; if (!DefaultFailCondition && FailConditions?.Invoke(this, result) != true) return; if (Failed?.Invoke() != false) HasFailed = true; } /// /// Notifies subscribers of that a new judgement has occurred. /// /// The judgement scoring result to notify subscribers of. protected void NotifyNewJudgement(JudgementResult result) { NewJudgement?.Invoke(result); if (HasCompleted) AllJudged?.Invoke(); } /// /// 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(TotalScore.Value); score.Combo = Combo.Value; score.MaxCombo = HighestCombo.Value; score.Accuracy = Math.Round(Accuracy.Value, 4); score.Rank = Rank.Value; score.Date = DateTimeOffset.Now; var hitWindows = CreateHitWindows(); foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) score.Statistics[result] = GetStatistic(result); } public abstract int GetStatistic(HitResult result); public abstract double GetStandardisedScore(); } public class ScoreProcessor : ScoreProcessor where TObject : HitObject { private const double base_portion = 0.3; private const double combo_portion = 0.7; private const double max_score = 1000000; public sealed override bool HasCompleted => JudgedHits == MaxHits; protected int MaxHits { get; private set; } private double maxHighestCombo; private double maxBaseScore; private double rollingMaxBaseScore; private double baseScore; private double bonusScore; private double scoreMultiplier = 1; public ScoreProcessor(DrawableRuleset drawableRuleset) { Debug.Assert(base_portion + combo_portion == 1.0); drawableRuleset.OnNewResult += applyResult; drawableRuleset.OnRevertResult += revertResult; ApplyBeatmap(drawableRuleset.Beatmap); SimulateAutoplay(drawableRuleset.Beatmap); Reset(true); if (maxBaseScore == 0 || maxHighestCombo == 0) { Mode.Value = ScoringMode.Classic; Mode.Disabled = true; } Mode.ValueChanged += _ => updateScore(); Mods.ValueChanged += mods => { scoreMultiplier = 1; foreach (var m in mods.NewValue) scoreMultiplier *= m.ScoreMultiplier; updateScore(); }; } /// /// Applies any properties of the which affect scoring to this . /// /// The to read properties from. protected virtual void ApplyBeatmap(Beatmap beatmap) { } /// /// Simulates an autoplay of the to determine scoring values. /// /// This provided temporarily. DO NOT USE. /// The to simulate. protected virtual void SimulateAutoplay(Beatmap beatmap) { foreach (var obj in beatmap.HitObjects) simulate(obj); void simulate(HitObject obj) { foreach (var nested in obj.NestedHitObjects) simulate(nested); var judgement = obj.CreateJudgement(); if (judgement == null) return; var result = CreateResult(obj, judgement); if (result == null) throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); result.Type = judgement.MaxResult; applyResult(result); } } /// /// Applies the score change of a to this . /// /// The to apply. private void applyResult(JudgementResult result) { ApplyResult(result); updateScore(); UpdateFailed(result); NotifyNewJudgement(result); } /// /// Reverts the score change of a that was applied to this . /// /// The judgement scoring result. private void revertResult(JudgementResult result) { RevertResult(result); updateScore(); } private readonly Dictionary scoreResultCounts = new Dictionary(); /// /// Applies the score change of a to this . /// /// /// Any changes applied via this method can be reverted via . /// /// The to apply. protected virtual void ApplyResult(JudgementResult result) { result.ComboAtJudgement = Combo.Value; result.HighestComboAtJudgement = HighestCombo.Value; result.HealthAtJudgement = Health.Value; result.FailedAtJudgement = HasFailed; if (HasFailed) return; JudgedHits++; if (result.Judgement.AffectsCombo) { switch (result.Type) { case HitResult.None: break; case HitResult.Miss: Combo.Value = 0; break; default: Combo.Value++; break; } } if (result.Judgement.IsBonus) { if (result.IsHit) bonusScore += result.Judgement.NumericResultFor(result); } else { if (result.HasResult) scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) + 1; baseScore += result.Judgement.NumericResultFor(result); rollingMaxBaseScore += result.Judgement.MaxNumericResult; } Health.Value += HealthAdjustmentFactorFor(result) * result.Judgement.HealthIncreaseFor(result); } /// /// Reverts the score change of a that was applied to this via . /// /// The judgement scoring result. protected virtual void RevertResult(JudgementResult result) { Combo.Value = result.ComboAtJudgement; HighestCombo.Value = result.HighestComboAtJudgement; Health.Value = result.HealthAtJudgement; // Todo: Revert HasFailed state with proper player support if (result.FailedAtJudgement) return; JudgedHits--; if (result.Judgement.IsBonus) { if (result.IsHit) bonusScore -= result.Judgement.NumericResultFor(result); } else { if (result.HasResult) scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) - 1; baseScore -= result.Judgement.NumericResultFor(result); rollingMaxBaseScore -= result.Judgement.MaxNumericResult; } } /// /// An adjustment factor which is multiplied into the health increase provided by a . /// /// The for which the adjustment should apply. /// The adjustment factor. protected virtual double HealthAdjustmentFactorFor(JudgementResult result) => 1; private void updateScore() { if (rollingMaxBaseScore != 0) Accuracy.Value = baseScore / rollingMaxBaseScore; TotalScore.Value = getScore(Mode.Value); } private double getScore(ScoringMode mode) { switch (mode) { default: case ScoringMode.Standardised: return (max_score * (base_portion * baseScore / maxBaseScore + combo_portion * HighestCombo.Value / maxHighestCombo) + bonusScore) * scoreMultiplier; case ScoringMode.Classic: // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) return bonusScore + baseScore * ((1 + Math.Max(0, HighestCombo.Value - 1) * scoreMultiplier) / 25); } } public override int GetStatistic(HitResult result) => scoreResultCounts.GetOrDefault(result); public override double GetStandardisedScore() => getScore(ScoringMode.Standardised); protected override void Reset(bool storeResults) { scoreResultCounts.Clear(); if (storeResults) { MaxHits = JudgedHits; maxHighestCombo = HighestCombo.Value; maxBaseScore = baseScore; } base.Reset(storeResults); baseScore = 0; rollingMaxBaseScore = 0; bonusScore = 0; } /// /// Creates the that represents the scoring result for a . /// /// The which was judged. /// The that provides the scoring information. protected virtual JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new JudgementResult(hitObject, judgement); } public enum ScoringMode { Standardised, Classic } }