Merge branch 'master' into osu-distance-spacing

This commit is contained in:
Salman Ahmed
2022-04-24 05:23:30 +03:00
791 changed files with 21280 additions and 6782 deletions

View File

@ -0,0 +1,51 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Linq;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Platform;
#nullable enable
namespace osu.Game.Rulesets
{
/// <summary>
/// A ruleset store that populates from loaded assemblies (and optionally, assemblies in a storage).
/// </summary>
public class AssemblyRulesetStore : RulesetStore
{
public override IEnumerable<RulesetInfo> AvailableRulesets => availableRulesets;
private readonly List<RulesetInfo> availableRulesets = new List<RulesetInfo>();
/// <summary>
/// Create an assembly ruleset store that populates from loaded assemblies and an external location.
/// </summary>
/// <param name="path">An path containing ruleset DLLs.</param>
public AssemblyRulesetStore(string path)
: this(new NativeStorage(path))
{
}
/// <summary>
/// Create an assembly ruleset store that populates from loaded assemblies and an optional storage source.
/// </summary>
/// <param name="storage">An optional storage containing ruleset DLLs.</param>
public AssemblyRulesetStore(Storage? storage = null)
: base(storage)
{
List<Ruleset> instances = LoadedAssemblies.Values
.Select(r => Activator.CreateInstance(r) as Ruleset)
.Where(r => r != null)
.Select(r => r.AsNonNull())
.ToList();
// add all legacy rulesets first to ensure they have exclusive choice of primary key.
foreach (var r in instances.Where(r => r is ILegacyRuleset))
availableRulesets.Add(new RulesetInfo(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.OnlineID));
}
}
}

View File

@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Difficulty
public Mod[] Mods { get; set; }
/// <summary>
/// The combined star rating of all skill.
/// The combined star rating of all skills.
/// </summary>
[JsonProperty("star_rating", Order = -3)]
public double StarRating { get; set; }

View File

@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Difficulty
{
@ -119,15 +120,23 @@ namespace osu.Game.Rulesets.Difficulty
/// <summary>
/// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
/// </summary>
/// <remarks>
/// This can only be used to compute difficulties for legacy mod combinations.
/// </remarks>
/// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
public IEnumerable<DifficultyAttributes> CalculateAllLegacyCombinations(CancellationToken cancellationToken = default)
{
var rulesetInstance = ruleset.CreateInstance();
foreach (var combination in CreateDifficultyAdjustmentModCombinations())
{
if (combination is MultiMod multi)
yield return Calculate(multi.Mods, cancellationToken);
else
yield return Calculate(combination.Yield(), cancellationToken);
Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic);
var finalCombination = ModUtils.FlattenMod(combination);
if (classicMod != null)
finalCombination = finalCombination.Append(classicMod);
yield return Calculate(finalCombination.ToArray(), cancellationToken);
}
}

View File

@ -63,9 +63,8 @@ namespace osu.Game.Rulesets.Difficulty
// calculate total score
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
scoreProcessor.HighestCombo.Value = perfectPlay.MaxCombo;
scoreProcessor.Mods.Value = perfectPlay.Mods;
perfectPlay.TotalScore = (long)scoreProcessor.GetImmediateScore(ScoringMode.Standardised, perfectPlay.MaxCombo, statistics);
perfectPlay.TotalScore = (long)scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, perfectPlay);
// compute rank achieved
// default to SS, then adjust the rank with mods
@ -85,7 +84,7 @@ namespace osu.Game.Rulesets.Difficulty
).ConfigureAwait(false);
// ScorePerformanceCache is not used to avoid caching multiple copies of essentially identical perfect performance attributes
return difficulty == null ? null : ruleset.CreatePerformanceCalculator(difficulty.Value.Attributes, perfectPlay)?.Calculate();
return difficulty == null ? null : ruleset.CreatePerformanceCalculator()?.Calculate(perfectPlay, difficulty.Value.Attributes);
}, cancellationToken);
}

View File

@ -1,41 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Mods;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Difficulty
{
public abstract class PerformanceCalculator
{
protected readonly DifficultyAttributes Attributes;
protected readonly Ruleset Ruleset;
protected readonly ScoreInfo Score;
protected double TimeRate { get; private set; } = 1;
protected PerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score)
protected PerformanceCalculator(Ruleset ruleset)
{
Ruleset = ruleset;
Score = score;
Attributes = attributes ?? throw new ArgumentNullException(nameof(attributes));
ApplyMods(score.Mods);
}
protected virtual void ApplyMods(Mod[] mods)
{
var track = new TrackVirtual(10000);
mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
TimeRate = track.Rate;
}
public PerformanceAttributes Calculate(ScoreInfo score, DifficultyAttributes attributes)
=> CreatePerformanceAttributes(score, attributes);
public abstract PerformanceAttributes Calculate();
public PerformanceAttributes Calculate(ScoreInfo score, IWorkingBeatmap beatmap)
=> Calculate(score, Ruleset.CreateDifficultyCalculator(beatmap).Calculate(score.Mods));
/// <summary>
/// Creates <see cref="PerformanceAttributes"/> to describe a score's performance.
/// </summary>
/// <param name="score">The score to create the attributes for.</param>
/// <param name="attributes">The difficulty attributes for the beatmap relating to the score.</param>
protected abstract PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes);
}
}

View File

@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Edit
private void regenerateAutoplay()
{
var autoplayMod = drawableRuleset.Mods.OfType<ModAutoplay>().Single();
drawableRuleset.SetReplayScore(autoplayMod.CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods));
drawableRuleset.SetReplayScore(autoplayMod.CreateScoreFromReplayData(drawableRuleset.Beatmap, drawableRuleset.Mods));
}
private void addHitObject(HitObject hitObject)

View File

@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Edit
{
protected readonly OsuScrollContainer Scroll;
protected readonly FillFlowContainer FillFlow;
protected override Container<Drawable> Content { get; }
public ScrollingToolboxGroup(string title, float scrollAreaHeight)
@ -20,7 +22,7 @@ namespace osu.Game.Rulesets.Edit
{
RelativeSizeAxes = Axes.X,
Height = scrollAreaHeight,
Child = Content = new FillFlowContainer
Child = Content = FillFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,

View File

@ -1,14 +1,22 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mods
{
public interface ICreateReplay
[Obsolete("Use ICreateReplayData instead")] // Can be removed 20220929
public interface ICreateReplay : ICreateReplayData
{
public Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList<Mod> mods);
ModReplayData ICreateReplayData.CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
{
var replayScore = CreateReplayScore(beatmap, mods);
return new ModReplayData(replayScore.Replay, new ModCreatedUser { Username = replayScore.ScoreInfo.User.Username });
}
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Replays;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Rulesets.Mods
{
/// <summary>
/// A mod which creates full replay data, which is to be played back in place of a local user playing the game.
/// </summary>
public interface ICreateReplayData
{
/// <summary>
/// Create replay data.
/// </summary>
/// <param name="beatmap">The beatmap to create replay data for.</param>
/// <param name="mods">The mods to take into account when creating the replay data.</param>
/// <returns>A <see cref="ModReplayData"/> structure, containing the generated replay data.</returns>
/// <remarks>
/// For callers that want to receive a directly usable <see cref="Score"/> instance,
/// the <see cref="ModExtensions.CreateScoreFromReplayData"/> extension method is provided for convenience.
/// </remarks>
ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods);
}
/// <summary>
/// Data created by a mod that implements <see cref="ICreateReplayData"/>.
/// </summary>
public class ModReplayData
{
/// <summary>
/// The full replay data.
/// </summary>
public readonly Replay Replay;
/// <summary>
/// Placeholder user data to show in place of the local user when the associated mod is active.
/// </summary>
public readonly ModCreatedUser User;
public ModReplayData(Replay replay, ModCreatedUser user = null)
{
Replay = replay;
User = user ?? new ModCreatedUser();
}
}
/// <summary>
/// A user which is associated with a replay that was created by a mod (ie. autoplay or cinema).
/// </summary>
public class ModCreatedUser : IUser
{
public int OnlineID => APIUser.SYSTEM_USER_ID;
public bool IsBot => true;
public string Username { get; set; } = string.Empty;
}
}

View File

@ -5,8 +5,19 @@ using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mods
{
/// <summary>
/// An interface for <see cref="Mod"/>s that are updated every frame by a <see cref="Playfield"/>.
/// </summary>
public interface IUpdatableByPlayfield : IApplicableMod
{
/// <summary>
/// Update this <see cref="Mod"/>.
/// </summary>
/// <param name="playfield">The main <see cref="Playfield"/></param>
/// <remarks>
/// This method is called once per frame during gameplay by the main <see cref="Playfield"/> only.
/// To access nested <see cref="Playfield"/>s, use <see cref="Playfield.NestedPlayfields"/>.
/// </remarks>
void Update(Playfield playfield);
}
}

View File

@ -192,7 +192,7 @@ namespace osu.Game.Rulesets.Mods
hashCode.Add(GetType());
foreach (var setting in Settings)
hashCode.Add(ModUtils.GetSettingUnderlyingValue(setting));
hashCode.Add(setting.GetUnderlyingSettingValue());
return hashCode.ToHashCode();
}
@ -208,13 +208,13 @@ namespace osu.Game.Rulesets.Mods
public bool Equals(IBindable x, IBindable y)
{
object xValue = x == null ? null : ModUtils.GetSettingUnderlyingValue(x);
object yValue = y == null ? null : ModUtils.GetSettingUnderlyingValue(y);
object xValue = x?.GetUnderlyingSettingValue();
object yValue = y?.GetUnderlyingSettingValue();
return EqualityComparer<object>.Default.Equals(xValue, yValue);
}
public int GetHashCode(IBindable obj) => ModUtils.GetSettingUnderlyingValue(obj).GetHashCode();
public int GetHashCode(IBindable obj) => obj.GetUnderlyingSettingValue().GetHashCode();
}
}
}

View File

@ -0,0 +1,269 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Linq;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Audio;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mods
{
public class ModAdaptiveSpeed : Mod, IApplicableToRate, IApplicableToDrawableHitObject, IApplicableToBeatmap, IUpdatableByPlayfield
{
public override string Name => "Adaptive Speed";
public override string Acronym => "AS";
public override string Description => "Let track speed adapt to you.";
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp) };
[SettingSource("Initial rate", "The starting speed of the track")]
public BindableNumber<double> InitialRate { get; } = new BindableDouble
{
MinValue = 0.5,
MaxValue = 2,
Default = 1,
Value = 1,
Precision = 0.01
};
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
public BindableBool AdjustPitch { get; } = new BindableBool
{
Default = true,
Value = true
};
/// <summary>
/// The instantaneous rate of the track.
/// Every frame this mod will attempt to smoothly adjust this to meet <see cref="targetRate"/>.
/// </summary>
public BindableNumber<double> SpeedChange { get; } = new BindableDouble
{
MinValue = min_allowable_rate,
MaxValue = max_allowable_rate,
Default = 1,
Value = 1
};
// The two constants below denote the maximum allowable range of rates that `SpeedChange` can take.
// The range is purposefully wider than the range of values that `InitialRate` allows
// in order to give some leeway for change even when extreme initial rates are chosen.
private const double min_allowable_rate = 0.4d;
private const double max_allowable_rate = 2.5d;
// The two constants below denote the maximum allowable change in rate caused by a single hit
// This prevents sudden jolts caused by a badly-timed hit.
private const double min_allowable_rate_change = 0.9d;
private const double max_allowable_rate_change = 1.11d;
// Apply a fixed rate change when missing, allowing the player to catch up when the rate is too fast.
private const double rate_change_on_miss = 0.95d;
private ITrack track;
private double targetRate = 1d;
/// <summary>
/// The number of most recent track rates (approximated from how early/late each object was hit relative to the previous object)
/// which should be averaged to calculate <see cref="targetRate"/>.
/// </summary>
private const int recent_rate_count = 8;
/// <summary>
/// Stores the most recent <see cref="recent_rate_count"/> approximated track rates
/// which are averaged to calculate the value of <see cref="targetRate"/>.
/// </summary>
/// <remarks>
/// This list is used as a double-ended queue with fixed capacity
/// (items can be enqueued/dequeued at either end of the list).
/// When time is elapsing forward, items are dequeued from the start and enqueued onto the end of the list.
/// When time is being rewound, items are dequeued from the end and enqueued onto the start of the list.
/// </remarks>
/// <example>
/// <para>
/// The track rate approximation is calculated as follows:
/// </para>
/// <para>
/// Consider a hitobject which ends at 1000ms, and assume that its preceding hitobject ends at 500ms.
/// This gives a time difference of 1000 - 500 = 500ms.
/// </para>
/// <para>
/// Now assume that the user hit this object at 980ms rather than 1000ms.
/// When compared to the preceding hitobject, this gives 980 - 500 = 480ms.
/// </para>
/// <para>
/// With the above assumptions, the player is rushing / hitting early, which means that the track should speed up to match.
/// Therefore, the approximated target rate for this object would be equal to 500 / 480 * <see cref="InitialRate"/>.
/// </para>
/// </example>
private readonly List<double> recentRates = Enumerable.Repeat(1d, recent_rate_count).ToList();
/// <summary>
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the latest end time of any other object
/// that precedes the end time of the given object.
/// This can be loosely interpreted as the end time of the preceding hit object in rulesets that do not have overlapping hit objects.
/// </summary>
private readonly Dictionary<HitObject, double> precedingEndTimes = new Dictionary<HitObject, double>();
/// <summary>
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the track rate dequeued from
/// <see cref="recentRates"/> (i.e. the oldest value in the queue) when the object is hit. If the hit is then reverted,
/// the mapped value can be re-introduced to <see cref="recentRates"/> to properly rewind the queue.
/// </summary>
private readonly Dictionary<HitObject, double> ratesForRewinding = new Dictionary<HitObject, double>();
public ModAdaptiveSpeed()
{
InitialRate.BindValueChanged(val =>
{
SpeedChange.Value = val.NewValue;
targetRate = val.NewValue;
});
AdjustPitch.BindValueChanged(adjustPitchChanged);
}
public void ApplyToTrack(ITrack track)
{
this.track = track;
InitialRate.TriggerChange();
AdjustPitch.TriggerChange();
recentRates.Clear();
recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count));
}
public void ApplyToSample(DrawableSample sample)
{
sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange);
}
public void Update(Playfield playfield)
{
SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetRate, 50, playfield.Clock.ElapsedFrameTime);
}
public double ApplyToRate(double time, double rate = 1) => rate * InitialRate.Value;
public void ApplyToDrawableHitObject(DrawableHitObject drawable)
{
drawable.OnNewResult += (o, result) =>
{
if (ratesForRewinding.ContainsKey(result.HitObject)) return;
if (!shouldProcessResult(result)) return;
ratesForRewinding.Add(result.HitObject, recentRates[0]);
recentRates.RemoveAt(0);
recentRates.Add(Math.Clamp(getRelativeRateChange(result) * SpeedChange.Value, min_allowable_rate, max_allowable_rate));
updateTargetRate();
};
drawable.OnRevertResult += (o, result) =>
{
if (!ratesForRewinding.ContainsKey(result.HitObject)) return;
if (!shouldProcessResult(result)) return;
recentRates.Insert(0, ratesForRewinding[result.HitObject]);
ratesForRewinding.Remove(result.HitObject);
recentRates.RemoveAt(recentRates.Count - 1);
updateTargetRate();
};
}
public void ApplyToBeatmap(IBeatmap beatmap)
{
var hitObjects = getAllApplicableHitObjects(beatmap.HitObjects).ToList();
var endTimes = hitObjects.Select(x => x.GetEndTime()).OrderBy(x => x).Distinct().ToList();
foreach (HitObject hitObject in hitObjects)
{
int index = endTimes.BinarySearch(hitObject.GetEndTime());
if (index < 0) index = ~index; // BinarySearch returns the next larger element in bitwise complement if there's no exact match
index -= 1;
if (index >= 0)
precedingEndTimes.Add(hitObject, endTimes[index]);
}
}
private void adjustPitchChanged(ValueChangedEvent<bool> adjustPitchSetting)
{
track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange);
track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange);
}
private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue)
=> adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo;
private IEnumerable<HitObject> getAllApplicableHitObjects(IEnumerable<HitObject> hitObjects)
{
foreach (var hitObject in hitObjects)
{
if (!(hitObject.HitWindows is HitWindows.EmptyHitWindows))
yield return hitObject;
foreach (HitObject nested in getAllApplicableHitObjects(hitObject.NestedHitObjects))
yield return nested;
}
}
private bool shouldProcessResult(JudgementResult result)
{
if (!result.Type.AffectsAccuracy()) return false;
if (!precedingEndTimes.ContainsKey(result.HitObject)) return false;
return true;
}
private double getRelativeRateChange(JudgementResult result)
{
if (!result.IsHit)
return rate_change_on_miss;
double prevEndTime = precedingEndTimes[result.HitObject];
return Math.Clamp(
(result.HitObject.GetEndTime() - prevEndTime) / (result.TimeAbsolute - prevEndTime),
min_allowable_rate_change,
max_allowable_rate_change
);
}
/// <summary>
/// Update <see cref="targetRate"/> based on the values in <see cref="recentRates"/>.
/// </summary>
private void updateTargetRate()
{
// Compare values in recentRates to see how consistent the player's speed is
// If the player hits half of the notes too fast and the other half too slow: Abs(consistency) = 0
// If the player hits all their notes too fast or too slow: Abs(consistency) = recent_rate_count - 1
int consistency = 0;
for (int i = 1; i < recentRates.Count; i++)
{
consistency += Math.Sign(recentRates[i] - recentRates[i - 1]);
}
// Scale the rate adjustment based on consistency
targetRate = Interpolation.Lerp(targetRate, recentRates.Average(), Math.Abs(consistency) / (recent_rate_count - 1d));
}
}
}

View File

@ -11,7 +11,7 @@ using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mods
{
public abstract class ModAutoplay : Mod, IApplicableFailOverride, ICreateReplay
public abstract class ModAutoplay : Mod, IApplicableFailOverride, ICreateReplayData
{
public override string Name => "Autoplay";
public override string Acronym => "AT";
@ -26,10 +26,20 @@ namespace osu.Game.Rulesets.Mods
public override bool UserPlayable => false;
public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) };
public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) };
public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0;
[Obsolete("Override CreateReplayData(IBeatmap, IReadOnlyList<Mod>) instead")] // Can be removed 20220929
public virtual Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList<Mod> mods) => new Score { Replay = new Replay() };
public virtual ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
{
#pragma warning disable CS0618
var replayScore = CreateReplayScore(beatmap, mods);
#pragma warning restore CS0618
return new ModReplayData(replayScore.Replay, new ModCreatedUser { Username = replayScore.ScoreInfo.User.Username });
}
}
}

View File

@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mods
public override string Description => "The whole playfield is on a wheel!";
public override double ScoreMultiplier => 1;
public override string SettingDescription => $"{SpinSpeed.Value} rpm {Direction.Value.GetDescription().ToLowerInvariant()}";
public override string SettingDescription => $"{SpinSpeed.Value:N2} rpm {Direction.Value.GetDescription().ToLowerInvariant()}";
public void Update(Playfield playfield)
{

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Objects;
@ -14,8 +16,6 @@ namespace osu.Game.Rulesets.Mods
{
public virtual void ApplyToDrawableRuleset(DrawableRuleset<T> drawableRuleset)
{
drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods));
// AlwaysPresent required for hitsounds
drawableRuleset.AlwaysPresent = true;
drawableRuleset.Hide();
@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.ModCinema;
public override string Description => "Watch the video without visual distractions.";
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAutoplay)).ToArray();
public void ApplyToHUD(HUDOverlay overlay)
{
overlay.ShowHud.Value = false;

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
@ -18,8 +16,6 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Zoooooooooom...";
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModHalfTime)).ToArray();
[SettingSource("Speed increase", "The actual increase to apply")]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble
{

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mods
{
public static class ModExtensions
{
public static Score CreateScoreFromReplayData(this ICreateReplayData mod, IBeatmap beatmap, IReadOnlyList<Mod> mods)
{
var replayData = mod.CreateReplayData(beatmap, mods);
return new Score
{
Replay = replayData.Replay,
ScoreInfo =
{
User = new APIUser
{
Id = APIUser.SYSTEM_USER_ID,
Username = replayData.User.Username,
}
}
};
}
}
}

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
@ -18,8 +16,6 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyReduction;
public override string Description => "Less zoom...";
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModDoubleTime)).ToArray();
[SettingSource("Speed decrease", "The actual decrease to apply")]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble
{

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mods
public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value;
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) };
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) };
public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x";
}

View File

@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
public abstract BindableBool AdjustPitch { get; }
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust) };
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) };
public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x";

View File

@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Rulesets.Mods
{
public class UnknownMod : Mod
{
/// <summary>
/// The acronym of the mod which could not be resolved.
/// </summary>
public readonly string OriginalAcronym;
public override string Name => $"Unknown mod ({OriginalAcronym})";
public override string Acronym => $"{OriginalAcronym}??";
public override string Description => "This mod could not be resolved by the game.";
public override double ScoreMultiplier => 0;
public override bool UserPlayable => false;
public override ModType Type => ModType.System;
public UnknownMod(string acronym)
{
OriginalAcronym = acronym;
}
public override Mod DeepClone() => new UnknownMod(OriginalAcronym);
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using JetBrains.Annotations;
using Newtonsoft.Json;
@ -67,6 +68,12 @@ namespace osu.Game.Rulesets.Objects
}
}
/// <summary>
/// Any samples which may be used by this hit object that are non-standard.
/// This is used only to preload these samples ahead of time.
/// </summary>
public virtual IList<HitSampleInfo> AuxiliarySamples => ImmutableList<HitSampleInfo>.Empty;
public SampleControlPoint SampleControlPoint = SampleControlPoint.DEFAULT;
public DifficultyControlPoint DifficultyControlPoint = DifficultyControlPoint.DEFAULT;

View File

@ -500,6 +500,9 @@ namespace osu.Game.Rulesets.Objects.Legacy
=> new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered));
public bool Equals(LegacyHitSampleInfo? other)
// The additions to equality checks here are *required* to ensure that pooling works correctly.
// Of note, `IsLayered` may cause the usage of `SampleVirtual` instead of an actual sample (in cases playback is not required).
// Removing it would cause samples which may actually require playback to potentially source for a `SampleVirtual` sample pool.
=> base.Equals(other) && CustomSampleBank == other.CustomSampleBank && IsLayered == other.IsLayered;
public override bool Equals(object? obj)

View File

@ -0,0 +1,101 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Linq;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database;
#nullable enable
namespace osu.Game.Rulesets
{
public class RealmRulesetStore : RulesetStore
{
public override IEnumerable<RulesetInfo> AvailableRulesets => availableRulesets;
private readonly List<RulesetInfo> availableRulesets = new List<RulesetInfo>();
public RealmRulesetStore(RealmAccess realm, Storage? storage = null)
: base(storage)
{
prepareDetachedRulesets(realm);
}
private void prepareDetachedRulesets(RealmAccess realmAccess)
{
realmAccess.Write(realm =>
{
var rulesets = realm.All<RulesetInfo>();
List<Ruleset> instances = LoadedAssemblies.Values
.Select(r => Activator.CreateInstance(r) as Ruleset)
.Where(r => r != null)
.Select(r => r.AsNonNull())
.ToList();
// add all legacy rulesets first to ensure they have exclusive choice of primary key.
foreach (var r in instances.Where(r => r is ILegacyRuleset))
{
if (realm.All<RulesetInfo>().FirstOrDefault(rr => rr.OnlineID == r.RulesetInfo.OnlineID) == null)
realm.Add(new RulesetInfo(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.OnlineID));
}
// add any other rulesets which have assemblies present but are not yet in the database.
foreach (var r in instances.Where(r => !(r is ILegacyRuleset)))
{
if (rulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null)
{
var existingSameShortName = rulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName);
if (existingSameShortName != null)
{
// even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName.
// this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one.
// in such cases, update the instantiation info of the existing entry to point to the new one.
existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo;
}
else
realm.Add(new RulesetInfo(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.OnlineID));
}
}
List<RulesetInfo> detachedRulesets = new List<RulesetInfo>();
// perform a consistency check and detach final rulesets from realm for cross-thread runtime usage.
foreach (var r in rulesets.OrderBy(r => r.OnlineID))
{
try
{
var resolvedType = Type.GetType(r.InstantiationInfo)
?? throw new RulesetLoadException(@"Type could not be resolved");
var instanceInfo = (Activator.CreateInstance(resolvedType) as Ruleset)?.RulesetInfo
?? throw new RulesetLoadException(@"Instantiation failure");
// If a ruleset isn't up-to-date with the API, it could cause a crash at an arbitrary point of execution.
// To eagerly handle cases of missing implementations, enumerate all types here and mark as non-available on throw.
resolvedType.Assembly.GetTypes();
r.Name = instanceInfo.Name;
r.ShortName = instanceInfo.ShortName;
r.InstantiationInfo = instanceInfo.InstantiationInfo;
r.Available = true;
detachedRulesets.Add(r.Clone());
}
catch (Exception ex)
{
r.Available = false;
Logger.Log($"Could not load ruleset {r}: {ex.Message}");
}
}
availableRulesets.AddRange(detachedRulesets.OrderBy(r => r));
});
}
}
}

View File

@ -201,7 +201,7 @@ namespace osu.Game.Rulesets
/// Creates a <see cref="ScoreProcessor"/> for this <see cref="Ruleset"/>.
/// </summary>
/// <returns>The score processor.</returns>
public virtual ScoreProcessor CreateScoreProcessor() => new ScoreProcessor();
public virtual ScoreProcessor CreateScoreProcessor() => new ScoreProcessor(this);
/// <summary>
/// Creates a <see cref="HealthProcessor"/> for this <see cref="Ruleset"/>.
@ -228,25 +228,9 @@ namespace osu.Game.Rulesets
/// <summary>
/// Optionally creates a <see cref="PerformanceCalculator"/> to generate performance data from the provided score.
/// </summary>
/// <param name="attributes">Difficulty attributes for the beatmap related to the provided score.</param>
/// <param name="score">The score to be processed.</param>
/// <returns>A performance calculator instance for the provided score.</returns>
[CanBeNull]
public virtual PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => null;
/// <summary>
/// Optionally creates a <see cref="PerformanceCalculator"/> to generate performance data from the provided score.
/// </summary>
/// <param name="beatmap">The beatmap to use as a source for generating <see cref="DifficultyAttributes"/>.</param>
/// <param name="score">The score to be processed.</param>
/// <returns>A performance calculator instance for the provided score.</returns>
[CanBeNull]
public PerformanceCalculator CreatePerformanceCalculator(IWorkingBeatmap beatmap, ScoreInfo score)
{
var difficultyCalculator = CreateDifficultyCalculator(beatmap);
var difficultyAttributes = difficultyCalculator.Calculate(score.Mods);
return CreatePerformanceCalculator(difficultyAttributes, score);
}
public virtual PerformanceCalculator CreatePerformanceCalculator() => null;
public virtual HitObjectComposer CreateHitObjectComposer() => null;

View File

@ -7,34 +7,26 @@ using System.IO;
using System.Linq;
using System.Reflection;
using osu.Framework;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database;
#nullable enable
namespace osu.Game.Rulesets
{
public class RulesetStore : IDisposable, IRulesetStore
public abstract class RulesetStore : IDisposable, IRulesetStore
{
private readonly RealmAccess realmAccess;
private const string ruleset_library_prefix = @"osu.Game.Rulesets";
private readonly Dictionary<Assembly, Type> loadedAssemblies = new Dictionary<Assembly, Type>();
protected readonly Dictionary<Assembly, Type> LoadedAssemblies = new Dictionary<Assembly, Type>();
/// <summary>
/// All available rulesets.
/// </summary>
public IEnumerable<RulesetInfo> AvailableRulesets => availableRulesets;
public abstract IEnumerable<RulesetInfo> AvailableRulesets { get; }
private readonly List<RulesetInfo> availableRulesets = new List<RulesetInfo>();
public RulesetStore(RealmAccess realm, Storage? storage = null)
protected RulesetStore(Storage? storage = null)
{
realmAccess = realm;
// On android in release configuration assemblies are loaded from the apk directly into memory.
// We cannot read assemblies from cwd, so should check loaded assemblies instead.
loadFromAppDomain();
@ -53,8 +45,6 @@ namespace osu.Game.Rulesets
var rulesetStorage = storage?.GetStorageForDirectory(@"rulesets");
if (rulesetStorage != null)
loadUserRulesets(rulesetStorage);
addMissingRulesets();
}
/// <summary>
@ -95,80 +85,7 @@ namespace osu.Game.Rulesets
if (domainAssembly != null)
return domainAssembly;
return loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == asm.FullName);
}
private void addMissingRulesets()
{
realmAccess.Write(realm =>
{
var rulesets = realm.All<RulesetInfo>();
List<Ruleset> instances = loadedAssemblies.Values
.Select(r => Activator.CreateInstance(r) as Ruleset)
.Where(r => r != null)
.Select(r => r.AsNonNull())
.ToList();
// add all legacy rulesets first to ensure they have exclusive choice of primary key.
foreach (var r in instances.Where(r => r is ILegacyRuleset))
{
if (realm.All<RulesetInfo>().FirstOrDefault(rr => rr.OnlineID == r.RulesetInfo.OnlineID) == null)
realm.Add(new RulesetInfo(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.OnlineID));
}
// add any other rulesets which have assemblies present but are not yet in the database.
foreach (var r in instances.Where(r => !(r is ILegacyRuleset)))
{
if (rulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null)
{
var existingSameShortName = rulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName);
if (existingSameShortName != null)
{
// even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName.
// this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one.
// in such cases, update the instantiation info of the existing entry to point to the new one.
existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo;
}
else
realm.Add(new RulesetInfo(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.OnlineID));
}
}
List<RulesetInfo> detachedRulesets = new List<RulesetInfo>();
// perform a consistency check and detach final rulesets from realm for cross-thread runtime usage.
foreach (var r in rulesets.OrderBy(r => r.OnlineID))
{
try
{
var resolvedType = Type.GetType(r.InstantiationInfo)
?? throw new RulesetLoadException(@"Type could not be resolved");
var instanceInfo = (Activator.CreateInstance(resolvedType) as Ruleset)?.RulesetInfo
?? throw new RulesetLoadException(@"Instantiation failure");
// If a ruleset isn't up-to-date with the API, it could cause a crash at an arbitrary point of execution.
// To eagerly handle cases of missing implementations, enumerate all types here and mark as non-available on throw.
resolvedType.Assembly.GetTypes();
r.Name = instanceInfo.Name;
r.ShortName = instanceInfo.ShortName;
r.InstantiationInfo = instanceInfo.InstantiationInfo;
r.Available = true;
detachedRulesets.Add(r.Clone());
}
catch (Exception ex)
{
r.Available = false;
Logger.Log($"Could not load ruleset {r}: {ex.Message}");
}
}
availableRulesets.AddRange(detachedRulesets.OrderBy(r => r));
});
return LoadedAssemblies.Keys.FirstOrDefault(a => a.FullName == asm.FullName);
}
private void loadFromAppDomain()
@ -214,7 +131,7 @@ namespace osu.Game.Rulesets
{
string? filename = Path.GetFileNameWithoutExtension(file);
if (loadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename))
if (LoadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename))
return;
try
@ -229,17 +146,17 @@ namespace osu.Game.Rulesets
private void addRuleset(Assembly assembly)
{
if (loadedAssemblies.ContainsKey(assembly))
if (LoadedAssemblies.ContainsKey(assembly))
return;
// the same assembly may be loaded twice in the same AppDomain (currently a thing in certain Rider versions https://youtrack.jetbrains.com/issue/RIDER-48799).
// as a failsafe, also compare by FullName.
if (loadedAssemblies.Any(a => a.Key.FullName == assembly.FullName))
if (LoadedAssemblies.Any(a => a.Key.FullName == assembly.FullName))
return;
try
{
loadedAssemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset)));
LoadedAssemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset)));
}
catch (Exception e)
{

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System;
using osu.Framework.Bindables;
using osu.Framework.Utils;
@ -14,12 +16,12 @@ namespace osu.Game.Rulesets.Scoring
/// Invoked when the <see cref="ScoreProcessor"/> is in a failed state.
/// Return true if the fail was permitted.
/// </summary>
public event Func<bool> Failed;
public event Func<bool>? Failed;
/// <summary>
/// Additional conditions on top of <see cref="DefaultFailCondition"/> that cause a failing state.
/// </summary>
public event Func<HealthProcessor, JudgementResult, bool> FailConditions;
public event Func<HealthProcessor, JudgementResult, bool>? FailConditions;
/// <summary>
/// The current health.

View File

@ -22,6 +22,23 @@ namespace osu.Game.Rulesets.Scoring
return 10 * standardDeviation(timeOffsets);
}
/// <summary>
/// Calculates the average hit offset/error for a sequence of <see cref="HitEvent"/>s, where negative numbers mean the user hit too early on average.
/// </summary>
/// <returns>
/// A non-null <see langword="double"/> value if unstable rate could be calculated,
/// and <see langword="null"/> if unstable rate cannot be calculated due to <paramref name="hitEvents"/> being empty.
/// </returns>
public static double? CalculateAverageHitError(this IEnumerable<HitEvent> hitEvents)
{
double[] timeOffsets = hitEvents.Where(affectsUnstableRate).Select(ev => ev.TimeOffset).ToArray();
if (timeOffsets.Length == 0)
return null;
return timeOffsets.Average();
}
private static bool affectsUnstableRate(HitEvent e) => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit();
private static double? standardDeviation(double[] timeOffsets)

View File

@ -5,6 +5,7 @@ using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Runtime.Serialization;
using osu.Framework.Utils;
namespace osu.Game.Rulesets.Scoring
@ -16,6 +17,7 @@ namespace osu.Game.Rulesets.Scoring
/// Indicates that the object has not been judged yet.
/// </summary>
[Description(@"")]
[EnumMember(Value = "none")]
[Order(14)]
None,
@ -27,32 +29,39 @@ namespace osu.Game.Rulesets.Scoring
/// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time).
/// </remarks>
[Description(@"Miss")]
[EnumMember(Value = "miss")]
[Order(5)]
Miss,
[Description(@"Meh")]
[EnumMember(Value = "meh")]
[Order(4)]
Meh,
[Description(@"OK")]
[EnumMember(Value = "ok")]
[Order(3)]
Ok,
[Description(@"Good")]
[EnumMember(Value = "good")]
[Order(2)]
Good,
[Description(@"Great")]
[EnumMember(Value = "great")]
[Order(1)]
Great,
[Description(@"Perfect")]
[EnumMember(Value = "perfect")]
[Order(0)]
Perfect,
/// <summary>
/// Indicates small tick miss.
/// </summary>
[EnumMember(Value = "small_tick_miss")]
[Order(11)]
SmallTickMiss,
@ -60,12 +69,14 @@ namespace osu.Game.Rulesets.Scoring
/// Indicates a small tick hit.
/// </summary>
[Description(@"S Tick")]
[EnumMember(Value = "small_tick_hit")]
[Order(7)]
SmallTickHit,
/// <summary>
/// Indicates a large tick miss.
/// </summary>
[EnumMember(Value = "large_tick_miss")]
[Order(10)]
LargeTickMiss,
@ -73,6 +84,7 @@ namespace osu.Game.Rulesets.Scoring
/// Indicates a large tick hit.
/// </summary>
[Description(@"L Tick")]
[EnumMember(Value = "large_tick_hit")]
[Order(6)]
LargeTickHit,
@ -80,6 +92,7 @@ namespace osu.Game.Rulesets.Scoring
/// Indicates a small bonus.
/// </summary>
[Description("S Bonus")]
[EnumMember(Value = "small_bonus")]
[Order(9)]
SmallBonus,
@ -87,18 +100,21 @@ namespace osu.Game.Rulesets.Scoring
/// Indicates a large bonus.
/// </summary>
[Description("L Bonus")]
[EnumMember(Value = "large_bonus")]
[Order(8)]
LargeBonus,
/// <summary>
/// Indicates a miss that should be ignored for scoring purposes.
/// </summary>
[EnumMember(Value = "ignore_miss")]
[Order(13)]
IgnoreMiss,
/// <summary>
/// Indicates a hit that should be ignored for scoring purposes.
/// </summary>
[EnumMember(Value = "ignore_hit")]
[Order(12)]
IgnoreHit,
}
@ -106,7 +122,19 @@ namespace osu.Game.Rulesets.Scoring
public static class HitResultExtensions
{
/// <summary>
/// Whether a <see cref="HitResult"/> increases/decreases the combo, and affects the combo portion of the score.
/// Whether a <see cref="HitResult"/> increases the combo.
/// </summary>
public static bool IncreasesCombo(this HitResult result)
=> AffectsCombo(result) && IsHit(result);
/// <summary>
/// Whether a <see cref="HitResult"/> breaks the combo and resets it back to zero.
/// </summary>
public static bool BreaksCombo(this HitResult result)
=> AffectsCombo(result) && !IsHit(result);
/// <summary>
/// Whether a <see cref="HitResult"/> increases/breaks the combo, and affects the combo portion of the score.
/// </summary>
public static bool AffectsCombo(this HitResult result)
{
@ -133,6 +161,30 @@ namespace osu.Game.Rulesets.Scoring
public static bool AffectsAccuracy(this HitResult result)
=> IsScorable(result) && !IsBonus(result);
/// <summary>
/// Whether a <see cref="HitResult"/> is a non-tick and non-bonus result.
/// </summary>
public static bool IsBasic(this HitResult result)
=> IsScorable(result) && !IsTick(result) && !IsBonus(result);
/// <summary>
/// Whether a <see cref="HitResult"/> should be counted as a tick.
/// </summary>
public static bool IsTick(this HitResult result)
{
switch (result)
{
case HitResult.LargeTickHit:
case HitResult.LargeTickMiss:
case HitResult.SmallTickHit:
case HitResult.SmallTickMiss:
return true;
default:
return false;
}
}
/// <summary>
/// Whether a <see cref="HitResult"/> should be counted as bonus score.
/// </summary>

View File

@ -1,8 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
@ -17,12 +20,12 @@ namespace osu.Game.Rulesets.Scoring
/// <summary>
/// Invoked when a new judgement has occurred. This occurs after the judgement has been processed by this <see cref="JudgementProcessor"/>.
/// </summary>
public event Action<JudgementResult> NewJudgement;
public event Action<JudgementResult>? NewJudgement;
/// <summary>
/// Invoked when a judgement is reverted, usually due to rewinding gameplay.
/// </summary>
public event Action<JudgementResult> JudgementReverted;
public event Action<JudgementResult>? JudgementReverted;
/// <summary>
/// The maximum number of hits that can be judged.
@ -34,7 +37,7 @@ namespace osu.Game.Rulesets.Scoring
/// </summary>
public int JudgedHits { get; private set; }
private JudgementResult lastAppliedResult;
private JudgementResult? lastAppliedResult;
private readonly BindableBool hasCompleted = new BindableBool();
@ -163,7 +166,12 @@ namespace osu.Game.Rulesets.Scoring
protected override void Update()
{
base.Update();
hasCompleted.Value = JudgedHits == MaxHits && (JudgedHits == 0 || lastAppliedResult.TimeAbsolute < Clock.CurrentTime);
hasCompleted.Value =
JudgedHits == MaxHits
&& (JudgedHits == 0
// Last applied result is guaranteed to be non-null when JudgedHits > 0.
|| lastAppliedResult.AsNonNull().TimeAbsolute < Clock.CurrentTime);
}
/// <summary>

View File

@ -1,12 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
@ -23,7 +26,7 @@ namespace osu.Game.Rulesets.Scoring
/// <summary>
/// Invoked when this <see cref="ScoreProcessor"/> was reset from a replay frame.
/// </summary>
public event Action OnResetFromReplayFrame;
public event Action? OnResetFromReplayFrame;
/// <summary>
/// The current total score.
@ -76,6 +79,12 @@ namespace osu.Game.Rulesets.Scoring
/// </summary>
protected virtual double DefaultComboPortion => 0.7;
/// <summary>
/// An arbitrary multiplier to scale scores in the <see cref="ScoringMode.Classic"/> scoring mode.
/// </summary>
protected virtual double ClassicScoreMultiplier => 36;
private readonly Ruleset ruleset;
private readonly double accuracyPortion;
private readonly double comboPortion;
@ -86,16 +95,32 @@ namespace osu.Game.Rulesets.Scoring
/// </summary>
private double maxBaseScore;
/// <summary>
/// The maximum number of basic (non-tick and non-bonus) hitobjects.
/// </summary>
private int maxBasicHitObjects;
/// <summary>
/// The maximum <see cref="HitResult"/> of a basic (non-tick and non-bonus) hitobject.
/// Only populated via <see cref="ComputeFinalScore"/> or <see cref="ResetFromReplayFrame"/>.
/// </summary>
private HitResult? maxBasicResult;
private double rollingMaxBaseScore;
private double baseScore;
private int basicHitObjects;
private bool beatmapApplied;
private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>();
private readonly List<HitEvent> hitEvents = new List<HitEvent>();
private HitObject lastHitObject;
private HitObject? lastHitObject;
private double scoreMultiplier = 1;
public ScoreProcessor()
public ScoreProcessor(Ruleset ruleset)
{
this.ruleset = ruleset;
accuracyPortion = DefaultAccuracyPortion;
comboPortion = DefaultComboPortion;
@ -122,7 +147,11 @@ namespace osu.Game.Rulesets.Scoring
};
}
private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>();
public override void ApplyBeatmap(IBeatmap beatmap)
{
base.ApplyBeatmap(beatmap);
beatmapApplied = true;
}
protected sealed override void ApplyResultInternal(JudgementResult result)
{
@ -137,20 +166,10 @@ namespace osu.Game.Rulesets.Scoring
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;
}
}
if (result.Type.IncreasesCombo())
Combo.Value++;
else if (result.Type.BreaksCombo())
Combo.Value = 0;
double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0;
@ -160,6 +179,9 @@ namespace osu.Game.Rulesets.Scoring
rollingMaxBaseScore += result.Judgement.MaxNumericResult;
}
if (result.Type.IsBasic())
basicHitObjects++;
hitEvents.Add(CreateHitEvent(result));
lastHitObject = result.HitObject;
@ -195,6 +217,9 @@ namespace osu.Game.Rulesets.Scoring
rollingMaxBaseScore -= result.Judgement.MaxNumericResult;
}
if (result.Type.IsBasic())
basicHitObjects--;
Debug.Assert(hitEvents.Count > 0);
lastHitObject = hitEvents[^1].LastHitObject;
hitEvents.RemoveAt(hitEvents.Count - 1);
@ -204,30 +229,122 @@ namespace osu.Game.Rulesets.Scoring
private void updateScore()
{
if (rollingMaxBaseScore != 0)
Accuracy.Value = calculateAccuracyRatio(baseScore, true);
double rollingAccuracyRatio = rollingMaxBaseScore > 0 ? baseScore / rollingMaxBaseScore : 1;
double accuracyRatio = maxBaseScore > 0 ? baseScore / maxBaseScore : 1;
double comboRatio = maxAchievableCombo > 0 ? (double)HighestCombo.Value / maxAchievableCombo : 1;
TotalScore.Value = getScore(Mode.Value);
}
private double getScore(ScoringMode mode)
{
return GetScore(mode, maxAchievableCombo,
calculateAccuracyRatio(baseScore),
calculateComboRatio(HighestCombo.Value),
scoreResultCounts);
Accuracy.Value = rollingAccuracyRatio;
TotalScore.Value = ComputeScore(Mode.Value, accuracyRatio, comboRatio, getBonusScore(scoreResultCounts), maxBasicHitObjects);
}
/// <summary>
/// Computes the total score.
/// Computes the total score of a given finalised <see cref="ScoreInfo"/>. This should be used when a score is known to be complete.
/// </summary>
/// <param name="mode">The <see cref="ScoringMode"/> to compute the total score in.</param>
/// <param name="maxCombo">The maximum combo achievable in the beatmap.</param>
/// <remarks>
/// Does not require <see cref="JudgementProcessor.ApplyBeatmap"/> to have been called before use.
/// </remarks>
/// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param>
/// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param>
/// <returns>The total score in the given <see cref="ScoringMode"/>.</returns>
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);
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);
}
/// <summary>
/// Computes the total score of a partially-completed <see cref="ScoreInfo"/>. This should be used when it is unknown whether a score is complete.
/// </summary>
/// <remarks>
/// Requires <see cref="JudgementProcessor.ApplyBeatmap"/> to have been called before use.
/// </remarks>
/// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param>
/// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param>
/// <returns>The total score in the given <see cref="ScoringMode"/>.</returns>
public double ComputePartialScore(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}\".");
if (!beatmapApplied)
throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}.");
extractFromStatistics(ruleset,
scoreInfo.Statistics,
out double extractedBaseScore,
out _,
out _,
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);
}
/// <summary>
/// Computes the total score of a given <see cref="ScoreInfo"/> with a given custom max achievable combo.
/// </summary>
/// <remarks>
/// This is useful for processing legacy scores in which the maximum achievable combo can be more accurately determined via external means (e.g. database values or difficulty calculation).
/// <p>Does not require <see cref="JudgementProcessor.ApplyBeatmap"/> to have been called before use.</p>
/// </remarks>
/// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param>
/// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param>
/// <param name="maxAchievableCombo">The maximum achievable combo for the provided beatmap.</param>
/// <returns>The total score in the given <see cref="ScoringMode"/>.</returns>
public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo)
{
if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset))
throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\".");
double accuracyRatio = scoreInfo.Accuracy;
double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1;
// 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 (computedMaxBaseScore > 0)
accuracyRatio = computedBaseScore / computedMaxBaseScore;
}
int computedBasicHitObjects = scoreInfo.Statistics.Where(kvp => kvp.Key.IsBasic()).Select(kvp => kvp.Value).Sum();
return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), computedBasicHitObjects);
}
/// <summary>
/// Computes the total score from individual scoring components.
/// </summary>
/// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param>
/// <param name="accuracyRatio">The accuracy percentage achieved by the player.</param>
/// <param name="comboRatio">The proportion of <paramref name="maxCombo"/> achieved by the player.</param>
/// <param name="statistics">Any statistics to be factored in.</param>
/// <returns>The total score.</returns>
public double GetScore(ScoringMode mode, int maxCombo, double accuracyRatio, double comboRatio, Dictionary<HitResult, int> statistics)
/// <param name="comboRatio">The portion of the max combo achieved by the player.</param>
/// <param name="bonusScore">The total bonus score.</param>
/// <param name="totalBasicHitObjects">The total number of basic (non-tick and non-bonus) hitobjects in the beatmap.</param>
/// <returns>The total score computed from the given scoring component ratios.</returns>
public double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects)
{
switch (mode)
{
@ -235,56 +352,22 @@ namespace osu.Game.Rulesets.Scoring
case ScoringMode.Standardised:
double accuracyScore = accuracyPortion * accuracyRatio;
double comboScore = comboPortion * comboRatio;
return (max_score * (accuracyScore + comboScore) + getBonusScore(statistics)) * scoreMultiplier;
return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier;
case ScoringMode.Classic:
// This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring.
// The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes.
double scaledStandardised = GetScore(ScoringMode.Standardised, maxCombo, accuracyRatio, comboRatio, statistics) / max_score;
return Math.Pow(scaledStandardised * (maxCombo + 1), 2) * 18;
double scaledStandardised = ComputeScore(ScoringMode.Standardised, accuracyRatio, comboRatio, bonusScore, totalBasicHitObjects) / max_score;
return Math.Pow(scaledStandardised * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier;
}
}
/// <summary>
/// Given a minimal set of inputs, return the computed score for the tracked beatmap / mods combination, at the current point in time.
/// Calculates the total bonus score from score statistics.
/// </summary>
/// <param name="mode">The <see cref="ScoringMode"/> to compute the total score in.</param>
/// <param name="maxCombo">The maximum combo achievable in the beatmap.</param>
/// <param name="statistics">Statistics to be used for calculating accuracy, bonus score, etc.</param>
/// <returns>The computed score for provided inputs.</returns>
public double GetImmediateScore(ScoringMode mode, int maxCombo, Dictionary<HitResult, int> 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;
}
return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), statistics);
}
/// <summary>
/// Get the accuracy fraction for the provided base score.
/// </summary>
/// <param name="baseScore">The score to be used for accuracy calculation.</param>
/// <param name="preferRolling">Whether the rolling base score should be used (ie. for the current point in time based on Apply/Reverted results).</param>
/// <returns>The computed accuracy.</returns>
private double calculateAccuracyRatio(double baseScore, bool preferRolling = false)
{
if (preferRolling && rollingMaxBaseScore != 0)
return baseScore / rollingMaxBaseScore;
return maxBaseScore > 0 ? baseScore / maxBaseScore : 1;
}
private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1;
private double getBonusScore(Dictionary<HitResult, int> statistics)
/// <param name="statistics">The score statistics.</param>
/// <returns>The total bonus score.</returns>
private double getBonusScore(IReadOnlyDictionary<HitResult, int> statistics)
=> statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE
+ statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE;
@ -306,8 +389,6 @@ namespace osu.Game.Rulesets.Scoring
public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result);
public double GetStandardisedScore() => getScore(ScoringMode.Standardised);
/// <summary>
/// Resets this ScoreProcessor to a default state.
/// </summary>
@ -324,10 +405,12 @@ namespace osu.Game.Rulesets.Scoring
{
maxAchievableCombo = HighestCombo.Value;
maxBaseScore = baseScore;
maxBasicHitObjects = basicHitObjects;
}
baseScore = 0;
rollingMaxBaseScore = 0;
basicHitObjects = 0;
TotalScore.Value = 0;
Accuracy.Value = 1;
@ -341,23 +424,19 @@ namespace osu.Game.Rulesets.Scoring
/// </summary>
public virtual void PopulateScore(ScoreInfo score)
{
score.TotalScore = (long)Math.Round(GetStandardisedScore());
score.Combo = Combo.Value;
score.MaxCombo = HighestCombo.Value;
score.Accuracy = Accuracy.Value;
score.Rank = Rank.Value;
score.HitEvents = hitEvents;
foreach (var result in HitResultExtensions.ALL_TYPES)
score.Statistics[result] = GetStatistic(result);
score.HitEvents = hitEvents;
// Populate total score after everything else.
score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score));
}
/// <summary>
/// Maximum <see cref="HitResult"/> for a normal hit (i.e. not tick/bonus) for this ruleset. Only populated via <see cref="ResetFromReplayFrame"/>.
/// </summary>
private HitResult? maxNormalResult;
public override void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame)
{
base.ResetFromReplayFrame(ruleset, frame);
@ -365,11 +444,26 @@ namespace osu.Game.Rulesets.Scoring
if (frame.Header == null)
return;
baseScore = 0;
rollingMaxBaseScore = 0;
extractFromStatistics(ruleset, frame.Header.Statistics, out baseScore, out rollingMaxBaseScore, out _, out _);
HighestCombo.Value = frame.Header.MaxCombo;
foreach ((HitResult result, int count) in frame.Header.Statistics)
scoreResultCounts.Clear();
scoreResultCounts.AddRange(frame.Header.Statistics);
updateScore();
OnResetFromReplayFrame?.Invoke();
}
private void extractFromStatistics(Ruleset ruleset, IReadOnlyDictionary<HitResult, int> statistics, out double baseScore, out double maxBaseScore, out int maxCombo,
out int basicHitObjects)
{
baseScore = 0;
maxBaseScore = 0;
maxCombo = 0;
basicHitObjects = 0;
foreach ((HitResult result, int count) in statistics)
{
// Bonus scores are counted separately directly from the statistics dictionary later on.
if (!result.IsScorable() || result.IsBonus())
@ -392,20 +486,19 @@ namespace osu.Game.Rulesets.Scoring
break;
default:
maxResult = maxNormalResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result;
maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result;
break;
}
baseScore += count * Judgement.ToNumericResult(result);
rollingMaxBaseScore += count * Judgement.ToNumericResult(maxResult);
maxBaseScore += count * Judgement.ToNumericResult(maxResult);
if (result.AffectsCombo())
maxCombo += count;
if (result.IsBasic())
basicHitObjects += count;
}
scoreResultCounts.Clear();
scoreResultCounts.AddRange(frame.Header.Statistics);
updateScore();
OnResetFromReplayFrame?.Invoke();
}
protected override void Dispose(bool isDisposing)

View File

@ -133,6 +133,11 @@ namespace osu.Game.Rulesets.UI
p.NewResult += (_, r) => NewResult?.Invoke(r);
p.RevertResult += (_, r) => RevertResult?.Invoke(r);
}));
}
protected override void LoadComplete()
{
base.LoadComplete();
IsPaused.ValueChanged += paused =>
{

View File

@ -0,0 +1,109 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Overlays;
using osu.Game.Rulesets.Mods;
using osuTK;
using osuTK.Graphics;
#nullable enable
namespace osu.Game.Rulesets.UI
{
public class ModSwitchSmall : CompositeDrawable
{
public BindableBool Active { get; } = new BindableBool();
public const float DEFAULT_SIZE = 60;
private readonly IMod mod;
private readonly SpriteIcon background;
private readonly SpriteIcon? modIcon;
private Color4 activeForegroundColour;
private Color4 inactiveForegroundColour;
private Color4 activeBackgroundColour;
private Color4 inactiveBackgroundColour;
public ModSwitchSmall(IMod mod)
{
this.mod = mod;
AutoSizeAxes = Axes.Both;
FillFlowContainer contentFlow;
ModSwitchTiny tinySwitch;
InternalChildren = new Drawable[]
{
background = new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(DEFAULT_SIZE),
Icon = OsuIcon.ModBg
},
contentFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Spacing = new Vector2(0, 4),
Direction = FillDirection.Vertical,
Child = tinySwitch = new ModSwitchTiny(mod)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Scale = new Vector2(0.6f),
Active = { BindTarget = Active }
}
}
};
if (mod.Icon != null)
{
contentFlow.Insert(-1, modIcon = new SpriteIcon
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Size = new Vector2(21),
Icon = mod.Icon.Value
});
tinySwitch.Scale = new Vector2(0.3f);
}
}
[BackgroundDependencyLoader(true)]
private void load(OsuColour colours, OverlayColourProvider? colourProvider)
{
inactiveForegroundColour = colourProvider?.Background5 ?? colours.Gray3;
activeForegroundColour = colours.ForModType(mod.Type);
inactiveBackgroundColour = colourProvider?.Background2 ?? colours.Gray5;
activeBackgroundColour = Interpolation.ValueAt<Colour4>(0.1f, Colour4.Black, activeForegroundColour, 0, 1);
}
protected override void LoadComplete()
{
base.LoadComplete();
Active.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
}
private void updateState()
{
modIcon?.FadeColour(Active.Value ? activeForegroundColour : inactiveForegroundColour, 200, Easing.OutQuint);
background.FadeColour(Active.Value ? activeBackgroundColour : inactiveBackgroundColour, 200, Easing.OutQuint);
}
}
}

View File

@ -0,0 +1,93 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osu.Game.Rulesets.Mods;
using osuTK;
using osuTK.Graphics;
#nullable enable
namespace osu.Game.Rulesets.UI
{
public class ModSwitchTiny : CompositeDrawable
{
public BindableBool Active { get; } = new BindableBool();
public const float DEFAULT_HEIGHT = 30;
private readonly IMod mod;
private readonly Box background;
private readonly OsuSpriteText acronymText;
private Color4 activeForegroundColour;
private Color4 inactiveForegroundColour;
private Color4 activeBackgroundColour;
private Color4 inactiveBackgroundColour;
public ModSwitchTiny(IMod mod)
{
this.mod = mod;
Size = new Vector2(73, DEFAULT_HEIGHT);
InternalChild = new CircularContainer
{
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both
},
acronymText = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Shadow = false,
Font = OsuFont.Numeric.With(size: 24, weight: FontWeight.Black),
Text = mod.Acronym,
Margin = new MarginPadding
{
Top = 4
}
}
}
};
}
[BackgroundDependencyLoader(true)]
private void load(OsuColour colours, OverlayColourProvider? colourProvider)
{
inactiveBackgroundColour = colourProvider?.Background5 ?? colours.Gray3;
activeBackgroundColour = colours.ForModType(mod.Type);
inactiveForegroundColour = colourProvider?.Background2 ?? colours.Gray5;
activeForegroundColour = Interpolation.ValueAt<Colour4>(0.1f, Colour4.Black, activeForegroundColour, 0, 1);
}
protected override void LoadComplete()
{
base.LoadComplete();
Active.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
}
private void updateState()
{
acronymText.FadeColour(Active.Value ? activeForegroundColour : inactiveForegroundColour, 200, Easing.OutQuint);
background.FadeColour(Active.Value ? activeBackgroundColour : inactiveBackgroundColour, 200, Easing.OutQuint);
}
}
}

View File

@ -3,22 +3,22 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Game.Audio;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Skinning;
using osuTK;
using System.Diagnostics;
namespace osu.Game.Rulesets.UI
{
@ -79,6 +79,11 @@ namespace osu.Game.Rulesets.UI
private readonly List<Playfield> nestedPlayfields = new List<Playfield>();
/// <summary>
/// Whether this <see cref="Playfield"/> is nested in another <see cref="Playfield"/>.
/// </summary>
public bool IsNested { get; private set; }
/// <summary>
/// Whether judgements should be displayed by this and and all nested <see cref="Playfield"/>s.
/// </summary>
@ -206,6 +211,8 @@ namespace osu.Game.Rulesets.UI
/// <param name="otherPlayfield">The <see cref="Playfield"/> to add.</param>
protected void AddNested(Playfield otherPlayfield)
{
otherPlayfield.IsNested = true;
otherPlayfield.DisplayJudgements.BindTo(DisplayJudgements);
otherPlayfield.NewResult += (d, r) => NewResult?.Invoke(d, r);
@ -229,7 +236,7 @@ namespace osu.Game.Rulesets.UI
{
base.Update();
if (mods != null)
if (!IsNested && mods != null)
{
foreach (var mod in mods)
{
@ -257,10 +264,25 @@ namespace osu.Game.Rulesets.UI
var entry = CreateLifetimeEntry(hitObject);
lifetimeEntryMap[entry.HitObject] = entry;
preloadSamples(hitObject);
HitObjectContainer.Add(entry);
OnHitObjectAdded(entry.HitObject);
}
private void preloadSamples(HitObject hitObject)
{
// prepare sample pools ahead of time so we're not initialising at runtime.
foreach (var sample in hitObject.Samples)
prepareSamplePool(hitObject.SampleControlPoint.ApplyTo(sample));
foreach (var sample in hitObject.AuxiliarySamples)
prepareSamplePool(hitObject.SampleControlPoint.ApplyTo(sample));
foreach (var nestedObject in hitObject.NestedHitObjects)
preloadSamples(nestedObject);
}
/// <summary>
/// Removes a <see cref="HitObjectLifetimeEntry"/> for a pooled <see cref="HitObject"/> from this <see cref="Playfield"/>.
/// </summary>
@ -301,7 +323,7 @@ namespace osu.Game.Rulesets.UI
/// </param>
/// <typeparam name="TObject">The <see cref="HitObject"/> type.</typeparam>
/// <typeparam name="TDrawable">The <see cref="DrawableHitObject"/> receiver for <typeparamref name="TObject"/>s.</typeparam>
protected void RegisterPool<TObject, TDrawable>(int initialSize, int? maximumSize = null)
public void RegisterPool<TObject, TDrawable>(int initialSize, int? maximumSize = null)
where TObject : HitObject
where TDrawable : DrawableHitObject, new()
=> RegisterPool<TObject, TDrawable>(new DrawablePool<TDrawable>(initialSize, maximumSize));
@ -323,22 +345,7 @@ namespace osu.Game.Rulesets.UI
DrawableHitObject IPooledHitObjectProvider.GetPooledDrawableRepresentation(HitObject hitObject, DrawableHitObject parent)
{
var lookupType = hitObject.GetType();
IDrawablePool pool;
// Tests may add derived hitobject instances for which pools don't exist. Try to find any applicable pool and dynamically assign the type if the pool exists.
if (!pools.TryGetValue(lookupType, out pool))
{
foreach (var (t, p) in pools)
{
if (!t.IsInstanceOfType(hitObject))
continue;
pools[lookupType] = pool = p;
break;
}
}
var pool = prepareDrawableHitObjectPool(hitObject);
return (DrawableHitObject)pool?.Get(d =>
{
@ -365,14 +372,39 @@ namespace osu.Game.Rulesets.UI
});
}
private IDrawablePool prepareDrawableHitObjectPool(HitObject hitObject)
{
var lookupType = hitObject.GetType();
IDrawablePool pool;
// Tests may add derived hitobject instances for which pools don't exist. Try to find any applicable pool and dynamically assign the type if the pool exists.
if (!pools.TryGetValue(lookupType, out pool))
{
foreach (var (t, p) in pools)
{
if (!t.IsInstanceOfType(hitObject))
continue;
pools[lookupType] = pool = p;
break;
}
}
return pool;
}
private readonly Dictionary<ISampleInfo, DrawablePool<PoolableSkinnableSample>> samplePools = new Dictionary<ISampleInfo, DrawablePool<PoolableSkinnableSample>>();
public PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo)
{
if (!samplePools.TryGetValue(sampleInfo, out var existingPool))
AddInternal(samplePools[sampleInfo] = existingPool = new DrawableSamplePool(sampleInfo, 1));
public PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo) => prepareSamplePool(sampleInfo).Get();
return existingPool.Get();
private DrawablePool<PoolableSkinnableSample> prepareSamplePool(ISampleInfo sampleInfo)
{
if (samplePools.TryGetValue(sampleInfo, out var pool)) return pool;
AddInternal(samplePools[sampleInfo] = pool = new DrawableSamplePool(sampleInfo, 1));
return pool;
}
private class DrawableSamplePool : DrawablePool<PoolableSkinnableSample>