diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 5e8a0b1216..6a24c26844 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -41,7 +41,6 @@ namespace osu.Game.Rulesets.Catch.Beatmaps X = xPositionData?.X ?? 0, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0, LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y, SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1 }.Yield(); diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index bd6b857fe8..efd4b46782 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -25,6 +25,7 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics; diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 0b56405299..b826c1f546 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty public override int Version => 20220701; - private readonly IWorkingBeatmap workingBeatmap; - public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { - workingBeatmap = beatmap; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -49,15 +46,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)), }; - if (ComputeLegacyScoringValues) - { - CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator(); - sv1Simulator.Simulate(workingBeatmap, beatmap, mods); - attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; - attributes.LegacyComboScore = sv1Simulator.ComboScore; - attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; - } - return attributes; } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchLegacyScoreSimulator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchLegacyScoreSimulator.cs index c79fd36d96..0183d723b2 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchLegacyScoreSimulator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchLegacyScoreSimulator.cs @@ -2,33 +2,26 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; namespace osu.Game.Rulesets.Catch.Difficulty { internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator { - public int AccuracyScore { get; private set; } - - public int ComboScore { get; private set; } - - public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore; - private int legacyBonusScore; - private int modernBonusScore; + private int standardisedBonusScore; private int combo; private double scoreMultiplier; - public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap) { IBeatmap baseBeatmap = workingBeatmap.Beatmap; @@ -70,13 +63,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty + baseBeatmap.Difficulty.CircleSize + Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5); - scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); + scoreMultiplier = difficultyPeppyStars; + + LegacyScoreAttributes attributes = new LegacyScoreAttributes(); foreach (var obj in playableBeatmap.HitObjects) - simulateHit(obj); + simulateHit(obj, ref attributes); + + attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore; + + return attributes; } - private void simulateHit(HitObject hitObject) + private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes) { bool increaseCombo = true; bool addScoreComboMultiplier = false; @@ -112,28 +111,28 @@ namespace osu.Game.Rulesets.Catch.Difficulty case JuiceStream: foreach (var nested in hitObject.NestedHitObjects) - simulateHit(nested); + simulateHit(nested, ref attributes); return; case BananaShower: foreach (var nested in hitObject.NestedHitObjects) - simulateHit(nested); + simulateHit(nested, ref attributes); return; } if (addScoreComboMultiplier) { // ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...) - ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier)); + attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier)); } if (isBonus) { legacyBonusScore += scoreIncrease; - modernBonusScore += Judgement.ToNumericResult(bonusResult); + standardisedBonusScore += Judgement.ToNumericResult(bonusResult); } else - AccuracyScore += scoreIncrease; + attributes.AccuracyScore += scoreIncrease; if (increaseCombo) combo++; diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 724bdc3401..fb1a86d8c0 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects int nodeIndex = 0; SliderEventDescriptor? lastEvent = null; - foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) + foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken)) { // generate tiny droplets since the last point if (lastEvent != null) @@ -104,8 +104,8 @@ namespace osu.Game.Rulesets.Catch.Objects } } - // this also includes LegacyLastTick and this is used for TinyDroplet generation above. - // this means that the final segment of TinyDroplets are increasingly mistimed where LegacyLastTickOffset is being applied. + // this also includes LastTick and this is used for TinyDroplet generation above. + // this means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied. lastEvent = e; switch (e.Type) @@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects public double Distance => Path.Distance; public IList> NodeSamples { get; set; } = new List>(); - - public double? LegacyLastTickOffset { get; set; } } } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs index 26832b7271..c6f32e2014 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs @@ -2,17 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Textures; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece { + private static readonly Vector2 banana_max_size = new Vector2(128); + protected override void LoadComplete() { base.LoadComplete(); - Texture? texture = Skin.GetTexture("fruit-bananas"); - Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay"); + Texture? texture = Skin.GetTexture("fruit-bananas")?.WithMaximumSize(banana_max_size); + Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay")?.WithMaximumSize(banana_max_size); SetTexture(texture, overlayTexture); } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs index 7ffd682698..c6c0839fba 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs @@ -2,12 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Textures; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece { + private static readonly Vector2 droplet_max_size = new Vector2(82, 103); + public LegacyDropletPiece() { Scale = new Vector2(0.8f); @@ -17,8 +20,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { base.LoadComplete(); - Texture? texture = Skin.GetTexture("fruit-drop"); - Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay"); + Texture? texture = Skin.GetTexture("fruit-drop")?.WithMaximumSize(droplet_max_size); + Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay")?.WithMaximumSize(droplet_max_size); SetTexture(texture, overlayTexture); } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs index 85b60561dd..62097d79bd 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs @@ -2,11 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece { + private static readonly Vector2 fruit_max_size = new Vector2(128); + protected override void LoadComplete() { base.LoadComplete(); @@ -22,21 +26,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy switch (visualRepresentation) { case FruitVisualRepresentation.Pear: - SetTexture(Skin.GetTexture("fruit-pear"), Skin.GetTexture("fruit-pear-overlay")); + setTextures("pear"); break; case FruitVisualRepresentation.Grape: - SetTexture(Skin.GetTexture("fruit-grapes"), Skin.GetTexture("fruit-grapes-overlay")); + setTextures("grapes"); break; case FruitVisualRepresentation.Pineapple: - SetTexture(Skin.GetTexture("fruit-apple"), Skin.GetTexture("fruit-apple-overlay")); + setTextures("apple"); break; case FruitVisualRepresentation.Raspberry: - SetTexture(Skin.GetTexture("fruit-orange"), Skin.GetTexture("fruit-orange-overlay")); + setTextures("orange"); break; } + + void setTextures(string fruitName) => SetTexture( + Skin.GetTexture($"fruit-{fruitName}")?.WithMaximumSize(fruit_max_size), + Skin.GetTexture($"fruit-{fruitName}-overlay")?.WithMaximumSize(fruit_max_size) + ); } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index de9f0d91ae..6bb6879052 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -31,13 +31,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty public override int Version => 20220902; - private readonly IWorkingBeatmap workingBeatmap; - public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { - workingBeatmap = beatmap; - isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty; } @@ -60,15 +56,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty MaxCombo = beatmap.HitObjects.Sum(maxComboForObject), }; - if (ComputeLegacyScoringValues) - { - ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator(); - sv1Simulator.Simulate(workingBeatmap, beatmap, mods); - attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; - attributes.LegacyComboScore = sv1Simulator.ComboScore; - attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; - } - return attributes; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaLegacyScoreSimulator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaLegacyScoreSimulator.cs index e544428979..098ccdf21d 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaLegacyScoreSimulator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaLegacyScoreSimulator.cs @@ -1,28 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Mania.Mods; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; namespace osu.Game.Rulesets.Mania.Difficulty { internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator { - public int AccuracyScore => 0; - public int ComboScore { get; private set; } - public double BonusScoreRatio => 0; - - public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap) { - double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn)) - .Select(m => m.ScoreMultiplier) - .Aggregate(1.0, (c, n) => c * n); - - ComboScore = (int)(1000000 * multiplier); + return new LegacyScoreAttributes { ComboScore = 1000000 }; } } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 4507015169..b977c07dfb 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -33,6 +33,7 @@ using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 065534eec4..30eca0636c 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -41,6 +41,7 @@ namespace osu.Game.Rulesets.Mania }, new SettingsCheckbox { + Keywords = new[] { "color" }, LabelText = RulesetSettingsStrings.TimingBasedColouring, Current = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring), } diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs index cd85901a65..ef75e9df11 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Rulesets.Mania.Objects.Drawables; @@ -25,33 +26,42 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default { RelativeSizeAxes = Axes.Both; + // Avoid flickering due to no anti-aliasing of boxes by default. + var edgeSmoothness = new Vector2(0.3f); + AddInternal(mainLine = new Box { Name = "Bar line", + EdgeSmoothness = edgeSmoothness, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.Both, }); - Vector2 size = new Vector2(22, 6); - const float line_offset = 4; + const float major_extension = 10; - AddInternal(leftAnchor = new Circle + AddInternal(leftAnchor = new Box { Name = "Left anchor", + EdgeSmoothness = edgeSmoothness, + Blending = BlendingParameters.Additive, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreRight, - Size = size, - X = -line_offset, + Width = major_extension, + RelativeSizeAxes = Axes.Y, + Colour = ColourInfo.GradientHorizontal(Colour4.Transparent, Colour4.White), }); - AddInternal(rightAnchor = new Circle + AddInternal(rightAnchor = new Box { Name = "Right anchor", + EdgeSmoothness = edgeSmoothness, + Blending = BlendingParameters.Additive, Anchor = Anchor.CentreRight, Origin = Anchor.CentreLeft, - Size = size, - X = line_offset, + Width = major_extension, + RelativeSizeAxes = Axes.Y, + Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.Transparent), }); major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy(); @@ -66,7 +76,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default private void updateMajor(ValueChangedEvent major) { mainLine.Alpha = major.NewValue ? 0.5f : 0.2f; - leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? 1 : 0; + leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs index 8ba97892fe..7315344295 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -163,7 +163,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor slider = new Slider { Position = new Vector2(0, 50), - LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png new file mode 100644 index 0000000000..7ebdec37d3 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 8cfd674f88..b1d9c453d6 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -43,7 +43,8 @@ namespace osu.Game.Rulesets.Osu.Tests AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8); AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8); - PausableSkinnableSound getSpinningSample() => drawableSpinner.ChildrenOfType().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin")))); + PausableSkinnableSound getSpinningSample() => + drawableSpinner.ChildrenOfType().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin")))); } [TestCase(false)] @@ -64,6 +65,39 @@ namespace osu.Game.Rulesets.Osu.Tests AddUntilStep("Short spinner implicitly completes", () => drawableSpinner.Progress == 1); } + [TestCase(0, 4, 6)] + [TestCase(5, 7, 10)] + [TestCase(10, 11, 8)] + public void TestSpinnerSpinRequirements(int od, int normalTicks, int bonusTicks) + { + Spinner spinner = null; + + AddStep("add spinner", () => SetContents(_ => + { + spinner = new Spinner + { + StartTime = Time.Current, + EndTime = Time.Current + 3000, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + } + }; + + spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od }); + + return drawableSpinner = new TestDrawableSpinner(spinner, true) + { + Anchor = Anchor.Centre, + Depth = depthIndex++, + Scale = new Vector2(0.75f) + }; + })); + + AddAssert("number of normal ticks matches", () => spinner.SpinsRequired, () => Is.EqualTo(normalTicks)); + AddAssert("number of bonus ticks matches", () => spinner.MaximumBonusSpins, () => Is.EqualTo(bonusTicks)); + } + private Drawable testSingle(float circleSize, bool auto = false, double length = 3000) { const double delay = 2000; diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index f3aaf831d3..3c051a6bb1 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -44,7 +44,6 @@ namespace osu.Game.Rulesets.Osu.Beatmaps Position = positionData?.Position ?? Vector2.Zero, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = (original as IHasLegacyLastTickOffset)?.LegacyLastTickOffset, // prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance. // this results in more (or less) ticks being generated in 20220902; - private readonly IWorkingBeatmap workingBeatmap; - public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { - workingBeatmap = beatmap; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -109,15 +106,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpinnerCount = spinnerCount, }; - if (ComputeLegacyScoringValues) - { - OsuLegacyScoreSimulator sv1Simulator = new OsuLegacyScoreSimulator(); - sv1Simulator.Simulate(workingBeatmap, beatmap, mods); - attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; - attributes.LegacyComboScore = sv1Simulator.ComboScore; - attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; - } - return attributes; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreSimulator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreSimulator.cs index 980d86e4ad..952ffa5e7a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreSimulator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreSimulator.cs @@ -2,37 +2,27 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; namespace osu.Game.Rulesets.Osu.Difficulty { internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator { - public int AccuracyScore { get; private set; } - - public int ComboScore { get; private set; } - - public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore; - private int legacyBonusScore; - private int modernBonusScore; + private int standardisedBonusScore; private int combo; private double scoreMultiplier; - private IBeatmap playableBeatmap = null!; - public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap) { - this.playableBeatmap = playableBeatmap; - IBeatmap baseBeatmap = workingBeatmap.Beatmap; int countNormal = 0; @@ -73,13 +63,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty + baseBeatmap.Difficulty.CircleSize + Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5); - scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); + scoreMultiplier = difficultyPeppyStars; + + LegacyScoreAttributes attributes = new LegacyScoreAttributes(); foreach (var obj in playableBeatmap.HitObjects) - simulateHit(obj); + simulateHit(obj, ref attributes); + + attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore; + + return attributes; } - private void simulateHit(HitObject hitObject) + private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes) { bool increaseCombo = true; bool addScoreComboMultiplier = false; @@ -122,7 +118,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty case Slider: foreach (var nested in hitObject.NestedHitObjects) - simulateHit(nested); + simulateHit(nested, ref attributes); scoreIncrease = 300; increaseCombo = false; @@ -133,22 +129,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty // The spinner object applies a lenience because gameplay mechanics differ from osu-stable. // We'll redo the calculations to match osu-stable here... const double maximum_rotations_per_second = 477.0 / 60; - double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5); + + // Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score. + // As we're primarily concerned with computing the maximum theoretical final score, + // this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1. + const double minimum_rotations_per_second = 3; + double secondsDuration = spinner.Duration / 1000; // The total amount of half spins possible for the entire spinner. int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2); // The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). - int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond); + int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second); // To be able to receive bonus points, the spinner must be rotated another 1.5 times. int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3; for (int i = 0; i <= totalHalfSpinsPossible; i++) { if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0) - simulateHit(new SpinnerBonusTick()); + simulateHit(new SpinnerBonusTick(), ref attributes); else if (i > 1 && i % 2 == 0) - simulateHit(new SpinnerTick()); + simulateHit(new SpinnerTick(), ref attributes); } scoreIncrease = 300; @@ -159,16 +160,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (addScoreComboMultiplier) { // ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...) - ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier)); + attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier)); } if (isBonus) { legacyBonusScore += scoreIncrease; - modernBonusScore += Judgement.ToNumericResult(bonusResult); + standardisedBonusScore += Judgement.ToNumericResult(bonusResult); } else - AccuracyScore += scoreIncrease; + attributes.AccuracyScore += scoreIncrease; if (increaseCombo) combo++; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs index e5cc8595d1..3cba0610a1 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components { Origin = Anchor.Centre; - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; InternalChild = content = new Container { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs index 670e98ca50..c585f09b00 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components { Origin = Anchor.Centre; - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; CornerRadius = Size.X / 2; CornerExponent = 2; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 6685507ee0..02023decd6 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -315,7 +315,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders StartTime = HitObject.StartTime, Position = HitObject.Position + splitControlPoints[0].Position, NewCombo = HitObject.NewCombo, - LegacyLastTickOffset = HitObject.LegacyLastTickOffset, Samples = HitObject.Samples.Select(s => s.With()).ToList(), RepeatCount = HitObject.RepeatCount, NodeSamples = HitObject.NodeSamples.Select(n => (IList)n.Select(s => s.With()).ToList()).ToList(), diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs index b74b722bad..9c0e43e96f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs @@ -61,10 +61,12 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { + OsuHitObject firstObject = drawableRuleset.Beatmap.HitObjects.First(); + // Multiplying by 2 results in an initial size that is too large, hence 1.90 has been chosen // Also avoids the HitObject bleeding around the edges of the bubble drawable at minimum size - bubbleSize = (float)(drawableRuleset.Beatmap.HitObjects.OfType().First().Radius * 1.90f); - bubbleFade = drawableRuleset.Beatmap.HitObjects.OfType().First().TimePreempt * 2; + bubbleSize = (float)firstObject.Radius * 1.90f; + bubbleFade = firstObject.TimePreempt * 2; // We want to hide the judgements since they are obscured by the BubbleDrawable (due to layering) drawableRuleset.Playfield.DisplayJudgements.Value = false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 9b5d405025..78062a0632 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -96,14 +96,13 @@ namespace osu.Game.Rulesets.Osu.Mods Position = original.Position; NewCombo = original.NewCombo; ComboOffset = original.ComboOffset; - LegacyLastTickOffset = original.LegacyLastTickOffset; TickDistanceMultiplier = original.TickDistanceMultiplier; SliderVelocityMultiplier = original.SliderVelocityMultiplier; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken); + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken); foreach (var e in sliderEvents) { @@ -130,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods }); break; - case SliderEventType.LegacyLastTick: + case SliderEventType.LastTick: AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) { RepeatIndex = e.SpanIndex, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs index 9537f8b388..e1123807cd 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs @@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Mods d.HitObjectApplied += _ => { - // slider tails are a painful edge case, as their start time is offset 36ms back (see `LegacyLastTick`). + // slider tails are a painful edge case, as their start time is offset 36ms back (see `LastTick`). // to work around this, look up the slider tail's parenting slider's end time instead to ensure proper snap. double snapTime = d is DrawableSliderTail tail ? tail.Slider.GetEndTime() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 6beed0294d..e87a075a11 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Collections.Generic; using System.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -34,6 +35,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public HitReceptor HitArea { get; private set; } public SkinnableDrawable CirclePiece { get; private set; } + protected override IEnumerable DimmablePieces => new[] + { + CirclePiece, + }; + Drawable IHasApproachCircle.ApproachCircle => ApproachCircle; private Container scaleContainer; @@ -191,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables CirclePiece.FadeInFromZero(HitObject.TimeFadeIn); - ApproachCircle.FadeIn(Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt)); + ApproachCircle.FadeTo(0.9f, Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt)); ApproachCircle.ScaleTo(1f, HitObject.TimePreempt); ApproachCircle.Expire(true); } @@ -244,7 +250,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public HitReceptor() { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 920dfcab03..ed981d2d5d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -4,6 +4,8 @@ #nullable disable using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -71,20 +73,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ScaleBindable.UnbindFrom(HitObject.ScaleBindable); } + protected virtual IEnumerable DimmablePieces => Enumerable.Empty(); + protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); - // Dim should only be applied at a top level, as it will be implicitly applied to nested objects. - if (ParentHitObject == null) + foreach (var piece in DimmablePieces) { - // Of note, no one noticed this was missing for years, but it definitely feels like it should still exist. - // For now this is applied across all skins, and matches stable. - // For simplicity, dim colour is applied to the DrawableHitObject itself. - // We may need to make a nested container setup if this even causes a usage conflict (ie. with a mod). - this.FadeColour(new Color4(195, 195, 195, 255)); - using (BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) - this.FadeColour(Color4.White, 100); + piece.FadeColour(new Color4(195, 195, 195, 255)); + using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) + piece.FadeColour(Color4.White, 100); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 1a6a0a9ecc..789755652f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -35,6 +36,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private ShakeContainer shakeContainer; + protected override IEnumerable DimmablePieces => new Drawable[] + { + HeadCircle, + TailCircle, + Body, + }; + /// /// A target container which can be used to add top level elements to the slider's display. /// Intended to be used for proxy purposes only. @@ -288,7 +296,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override void PlaySamples() { // rather than doing it this way, we should probably attach the sample to the tail circle. - // this can only be done after we stop using LegacyLastTick. + // this can only be done if we stop using LastTick. if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit) base.PlaySamples(); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index d06fb5b4de..47214f1e53 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Children = new[] { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index fc4863f164..ac4d733672 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public SkinnableDrawable CirclePiece { get; private set; } - public ReverseArrowPiece Arrow { get; private set; } + public SkinnableDrawable Arrow { get; private set; } private Drawable scaleContainer; @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private void load() { Origin = Anchor.Centre; - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; AddInternal(scaleContainer = new Container { @@ -65,7 +65,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - Arrow = new ReverseArrowPiece(), + Arrow = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new DefaultReverseArrow()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, } }); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index d9501f7d58..9fbc97c484 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private void load() { Origin = Anchor.Centre; - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; AddRangeInternal(new Drawable[] { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index 6d0ae93e62..a947580d2f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [BackgroundDependencyLoader] private void load() { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Origin = Anchor.Centre; AddInternal(scaleContainer = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index fd5741698a..0bdbfaa760 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -21,6 +21,11 @@ namespace osu.Game.Rulesets.Osu.Objects /// public const float OBJECT_RADIUS = 64; + /// + /// The width and height any element participating in display of a hitcircle (or similarly sized object) should be. + /// + public static readonly Vector2 OBJECT_DIMENSIONS = new Vector2(OBJECT_RADIUS * 2); + /// /// Scoring distance with a speed-adjusted beat length of 1 second (ie. the speed slider balls move through their track). /// diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index e05dbd8ea6..443e4229d2 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -71,8 +71,6 @@ namespace osu.Game.Rulesets.Osu.Objects } } - public double? LegacyLastTickOffset { get; set; } - /// /// The position of the cursor at the point of completion of this if it was hit /// with as few movements as possible. This is set and used by difficulty calculation. @@ -179,7 +177,7 @@ namespace osu.Game.Rulesets.Osu.Objects { base.CreateNestedHitObjects(cancellationToken); - var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken); + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken); foreach (var e in sliderEvents) { @@ -206,10 +204,11 @@ namespace osu.Game.Rulesets.Osu.Objects }); break; - case SliderEventType.LegacyLastTick: - // we need to use the LegacyLastTick here for compatibility reasons (difficulty). - // it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay. - // if this is to change, we should revisit this. + case SliderEventType.LastTick: + // Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle. + // It is required as difficulty calculation and gameplay relies on reading this value. + // (although it is displayed in classic skins, which may be a concern). + // If this is to change, we should revisit this. AddNested(TailCircle = new SliderTailCircle(this) { RepeatIndex = e.SpanIndex, @@ -264,7 +263,9 @@ namespace osu.Game.Rulesets.Osu.Objects if (HeadCircle != null) HeadCircle.Samples = this.GetNodeSamples(0); - // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to. + // The samples should be attached to the slider tail, however this can only be done if LastTick is removed otherwise they would play earlier than they're intended to. + // (see mapping logic in `CreateNestedHitObjects` above) + // // For now, the samples are played by the slider itself at the correct end time. TailSamples = this.GetNodeSamples(repeatCount + 1); } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index b4574791d2..fb81936837 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Objects { /// /// Note that this should not be used for timing correctness. - /// See usage in for more information. + /// See usage in for more information. /// public class SliderTailCircle : SliderEndCircle { diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 5ef670c739..e3dfe8e69a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -18,6 +18,16 @@ namespace osu.Game.Rulesets.Osu.Objects { public class Spinner : OsuHitObject, IHasDuration { + /// + /// The RPM required to clear the spinner at ODs [ 0, 5, 10 ]. + /// + private static readonly (int min, int mid, int max) clear_rpm_range = (90, 150, 225); + + /// + /// The RPM required to complete the spinner and receive full score at ODs [ 0, 5, 10 ]. + /// + private static readonly (int min, int mid, int max) complete_rpm_range = (250, 380, 430); + public double EndTime { get => StartTime + Duration; @@ -52,13 +62,19 @@ namespace osu.Game.Rulesets.Osu.Objects { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - const double maximum_rotations_per_second = 477f / 60f; + // The average RPS required over the length of the spinner to clear the spinner. + double minRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, clear_rpm_range) / 60; + + // The RPS required over the length of the spinner to receive full score (all normal + bonus ticks). + double maxRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, complete_rpm_range) / 60; double secondsDuration = Duration / 1000; - double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, 1.5, 2.5, 3.75); - SpinsRequired = (int)(secondsDuration * minimumRotationsPerSecond); - MaximumBonusSpins = (int)((maximum_rotations_per_second - minimumRotationsPerSecond) * secondsDuration) - bonus_spins_gap; + // Allow a 0.1ms floating point precision error in the calculation of the duration. + const double duration_error = 0.0001; + + SpinsRequired = (int)(minRps * secondsDuration + duration_error); + MaximumBonusSpins = Math.Max(0, (int)(maxRps * secondsDuration + duration_error) - SpinsRequired - bonus_spins_gap); } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 220dc53705..607b83d379 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -33,6 +33,7 @@ using osu.Game.Rulesets.Osu.Statistics; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs index 3427031dc8..7508a689d2 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs @@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon private Bindable configHitLighting = null!; - private static readonly Vector2 circle_size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + private static readonly Vector2 circle_size = OsuHitObject.OBJECT_DIMENSIONS; [Resolved] private DrawableHitObject drawableObject { get; set; } = null!; diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs index f93e26b2ca..160edb6f67 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs @@ -4,10 +4,12 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osuTK; @@ -17,38 +19,92 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon { public partial class ArgonReverseArrow : CompositeDrawable { + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; + private Bindable accentColour = null!; private SpriteIcon icon = null!; + private Container main = null!; + private Sprite side = null!; [BackgroundDependencyLoader] - private void load(DrawableHitObject hitObject) + private void load(TextureStore textures) { Anchor = Anchor.Centre; Origin = Anchor.Centre; - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; InternalChildren = new Drawable[] { - new Circle + main = new Container { - Size = new Vector2(40, 20), - Colour = Color4.White, + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, + Children = new Drawable[] + { + new Circle + { + Size = new Vector2(40, 20), + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + icon = new SpriteIcon + { + Icon = FontAwesome.Solid.AngleDoubleRight, + Size = new Vector2(16), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } }, - icon = new SpriteIcon + side = new Sprite { - Icon = FontAwesome.Solid.AngleDoubleRight, - Size = new Vector2(16), Anchor = Anchor.Centre, Origin = Anchor.Centre, - }, + Texture = textures.Get("Gameplay/osu/repeat-edge-piece"), + Size = new Vector2(ArgonMainCirclePiece.OUTER_GRADIENT_SIZE), + } }; - accentColour = hitObject.AccentColour.GetBoundCopy(); + accentColour = drawableObject.AccentColour.GetBoundCopy(); accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true); + + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + } + + private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + { + const float move_distance = -12; + const double move_out_duration = 35; + const double move_in_duration = 250; + const double total = 300; + + switch (state) + { + case ArmedState.Idle: + main.ScaleTo(1.3f, move_out_duration, Easing.Out) + .Then() + .ScaleTo(1f, move_in_duration, Easing.Out) + .Loop(total - (move_in_duration + move_out_duration)); + side + .MoveToX(move_distance, move_out_duration, Easing.Out) + .Then() + .MoveToX(0, move_in_duration, Easing.Out) + .Loop(total - (move_in_duration + move_out_duration)); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject.IsNotNull()) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs index f4761e0ea8..65a7b1328b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; -using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { @@ -22,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public CirclePiece() { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Masking = true; CornerRadius = Size.X / 2; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs new file mode 100644 index 0000000000..b44f6571b9 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public partial class DefaultReverseArrow : CompositeDrawable + { + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; + + public DefaultReverseArrow() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = OsuHitObject.OBJECT_DIMENSIONS; + + InternalChild = new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(0.35f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + } + + private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + { + const double move_out_duration = 35; + const double move_in_duration = 250; + const double total = 300; + + switch (state) + { + case ArmedState.Idle: + InternalChild.ScaleTo(1.3f, move_out_duration, Easing.Out) + .Then() + .ScaleTo(1f, move_in_duration, Easing.Out) + .Loop(total - (move_in_duration + move_out_duration)); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject.IsNotNull()) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs index 91bf75617a..7beb16f7d7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; -using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { @@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public ExplodePiece() { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs index 789137117e..86087ac50d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Rulesets.Osu.Objects; -using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { public FlashPiece() { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs index 20fa4e5342..bcea33f63c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default @@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public MainCirclePiece() { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs deleted file mode 100644 index 3fe7872ff7..0000000000 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Audio.Track; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.Containers; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Skinning.Default -{ - public partial class ReverseArrowPiece : BeatSyncedContainer - { - [Resolved] - private DrawableHitObject drawableRepeat { get; set; } = null!; - - public ReverseArrowPiece() - { - Divisor = 2; - MinimumBeatLength = 200; - - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - - Child = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon - { - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - Icon = FontAwesome.Solid.ChevronRight, - Size = new Vector2(0.35f) - }) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - } - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) - { - if (!drawableRepeat.IsHit) - Child.ScaleTo(1.3f).ScaleTo(1f, timingPoint.BeatLength, Easing.Out); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs index 46d48f62e7..c3bbd89ab6 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Rulesets.Osu.Objects; -using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default @@ -14,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { public RingPiece(float thickness = 9) { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs index e9342bbdbb..403a14214e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs @@ -5,12 +5,14 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { + // todo: this should probably not be a SkinnableSprite, as this is always created for legacy skins and is recreated on skin change. public partial class LegacyApproachCircle : SkinnableSprite { private readonly IBindable accentColour = new Bindable(); @@ -19,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private DrawableHitObject drawableObject { get; set; } = null!; public LegacyApproachCircle() - : base("Gameplay/osu/approachcircle") + : base("Gameplay/osu/approachcircle", OsuHitObject.OBJECT_DIMENSIONS) { } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index cadac4d319..8990204931 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -14,7 +14,6 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Skinning; -using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy @@ -51,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy this.priorityLookupPrefix = priorityLookupPrefix; this.hasNumber = hasNumber; - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = OsuHitObject.OBJECT_DIMENSIONS; } [BackgroundDependencyLoader] @@ -68,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png. InternalChildren = new[] { - CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) }) + CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -77,7 +76,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d)) + Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d, maxSize: OsuHitObject.OBJECT_DIMENSIONS)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs index e6166e9441..25de6d2381 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs @@ -4,9 +4,11 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Skinning; using osuTK.Graphics; @@ -15,8 +17,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public partial class LegacyReverseArrow : CompositeDrawable { - [Resolved(canBeNull: true)] - private DrawableHitObject? drawableHitObject { get; set; } + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; private Drawable proxy = null!; @@ -26,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private Drawable arrow = null!; + private bool shouldRotate; + [BackgroundDependencyLoader] private void load(ISkinSource skinSource) { @@ -35,8 +39,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null); - InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true) ?? Empty()); + InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true, maxSize: OsuHitObject.OBJECT_DIMENSIONS) ?? Empty()).With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }); + textureIsDefaultSkin = skin is ISkinTransformer transformer && transformer.Skin is DefaultLegacySkin; + + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + + shouldRotate = skinSource.GetConfig(SkinConfiguration.LegacySetting.Version)?.Value <= 1; } protected override void LoadComplete() @@ -45,17 +58,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy proxy = CreateProxy(); - if (drawableHitObject != null) - { - drawableHitObject.HitObjectApplied += onHitObjectApplied; - onHitObjectApplied(drawableHitObject); + drawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableObject); - accentColour = drawableHitObject.AccentColour.GetBoundCopy(); - accentColour.BindValueChanged(c => - { - arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White; - }, true); - } + accentColour = drawableObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(c => + { + arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White; + }, true); } private void onHitObjectApplied(DrawableHitObject drawableObject) @@ -67,11 +77,43 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy .OverlayElementContainer.Add(proxy); } + private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + { + const double duration = 300; + const float rotation = 5.625f; + + switch (state) + { + case ArmedState.Idle: + if (shouldRotate) + { + InternalChild.ScaleTo(1.3f) + .RotateTo(rotation) + .Then() + .ScaleTo(1f, duration) + .RotateTo(-rotation, duration) + .Loop(); + } + else + { + InternalChild.ScaleTo(1.3f).Then() + .ScaleTo(1f, duration, Easing.Out) + .Loop(); + } + + break; + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (drawableHitObject != null) - drawableHitObject.HitObjectApplied -= onHitObjectApplied; + + if (drawableObject.IsNotNull()) + { + drawableObject.HitObjectApplied -= onHitObjectApplied; + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs index 2aa843581e..c3beb5bc35 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Skinning; using osuTK.Graphics; @@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Texture = skin.GetTexture("sliderb-nd"), + Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS), Colour = new Color4(5, 5, 5, 255), }, LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d => @@ -58,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Texture = skin.GetTexture("sliderb-spec"), + Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS), Blending = BlendingParameters.Additive, }, }; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index f049aa088f..ea6f6fe6ce 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Skinning; using osuTK; @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy /// Their hittable area is 128px, but the actual circle portion is 118px. /// We must account for some gameplay elements such as slider bodies, where this padding is not present. /// - public const float LEGACY_CIRCLE_RADIUS = 64 - 5; + public const float LEGACY_CIRCLE_RADIUS = OsuHitObject.OBJECT_RADIUS - 5; public OsuLegacySkinTransformer(ISkin skin) : base(skin) @@ -41,14 +42,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return this.GetAnimation("sliderscorepoint", false, false); case OsuSkinComponents.SliderFollowCircle: - var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true); + var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: new Vector2(308f)); if (followCircleContent != null) return new LegacyFollowCircle(followCircleContent); return null; case OsuSkinComponents.SliderBall: - var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: ""); + var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "", maxSize: OsuHitObject.OBJECT_DIMENSIONS); // todo: slider ball has a custom frame delay based on velocity // Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME); @@ -138,7 +139,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (!this.HasFont(LegacyFont.HitCircle)) return null; - return new LegacySpriteText(LegacyFont.HitCircle) + return new LegacySpriteText(LegacyFont.HitCircle, OsuHitObject.OBJECT_DIMENSIONS) { // stable applies a blanket 0.8x scale to hitcircle fonts Scale = new Vector2(0.8f), diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorRippleVisualiser.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorRippleVisualiser.cs index 076d97d06a..52486b701a 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorRippleVisualiser.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorRippleVisualiser.cs @@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { new RingPiece(3) { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2), + Size = OsuHitObject.OBJECT_DIMENSIONS, Alpha = 0.1f, } }; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 25adba5ab6..ab193caaa3 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public override int Version => 20220902; - private readonly IWorkingBeatmap workingBeatmap; - public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { - workingBeatmap = beatmap; } protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) @@ -99,15 +96,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty MaxCombo = beatmap.HitObjects.Count(h => h is Hit), }; - if (ComputeLegacyScoringValues) - { - TaikoLegacyScoreSimulator sv1Simulator = new TaikoLegacyScoreSimulator(); - sv1Simulator.Simulate(workingBeatmap, beatmap, mods); - attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; - attributes.LegacyComboScore = sv1Simulator.ComboScore; - attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; - } - return attributes; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs index e77327d622..d4538d3c00 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs @@ -2,39 +2,29 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty { internal class TaikoLegacyScoreSimulator : ILegacyScoreSimulator { - public int AccuracyScore { get; private set; } - - public int ComboScore { get; private set; } - - public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore; - private int legacyBonusScore; - private int modernBonusScore; + private int standardisedBonusScore; private int combo; - private double modMultiplier; private int difficultyPeppyStars; private IBeatmap playableBeatmap = null!; - private IReadOnlyList mods = null!; - public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap) { this.playableBeatmap = playableBeatmap; - this.mods = mods; IBeatmap baseBeatmap = workingBeatmap.Beatmap; @@ -76,13 +66,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty + baseBeatmap.Difficulty.CircleSize + Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5); - modMultiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); + LegacyScoreAttributes attributes = new LegacyScoreAttributes(); foreach (var obj in playableBeatmap.HitObjects) - simulateHit(obj); + simulateHit(obj, ref attributes); + + attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore; + + return attributes; } - private void simulateHit(HitObject hitObject) + private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes) { bool increaseCombo = true; bool addScoreComboMultiplier = false; @@ -109,21 +103,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty case Swell swell: // The taiko swell generally does not match the osu-stable implementation in any way. // We'll redo the calculations to match osu-stable here... - double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5); - double secondsDuration = swell.Duration / 1000; + + // Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises rotations. + const double minimum_rotations_per_second = 7.5; // The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). - int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond); - + int halfSpinsRequiredForCompletion = (int)(swell.Duration / 1000 * minimum_rotations_per_second); halfSpinsRequiredForCompletion = (int)Math.Max(1, halfSpinsRequiredForCompletion * 1.65f); - if (mods.Any(m => m is ModDoubleTime)) - halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 0.75f)); - if (mods.Any(m => m is ModHalfTime)) - halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f)); + // + // Normally, this multiplier depends on the active mods (DT = 0.75, HT = 1.5). For simplicity, we'll only consider the worst case that maximises rotations. + // This way, scores remain beatable at the cost of the conversion being slightly inaccurate. + // - A perfect DT/NM score will have less than 1M total score (excluding bonus). + // - A perfect HT score will have 1M total score (excluding bonus). + // + halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f)); for (int i = 0; i <= halfSpinsRequiredForCompletion; i++) - simulateHit(new SwellTick()); + simulateHit(new SwellTick(), ref attributes); scoreIncrease = 300; addScoreComboMultiplier = true; @@ -139,7 +136,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty case DrumRoll: foreach (var nested in hitObject.NestedHitObjects) - simulateHit(nested); + simulateHit(nested, ref attributes); return; } @@ -159,8 +156,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { int oldScoreIncrease = scoreIncrease; - // ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...) - scoreIncrease += (int)(scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * modMultiplier) * (Math.Min(100, combo) / 10); + scoreIncrease += scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * (Math.Min(100, combo) / 10); if (hitObject is Swell) { @@ -185,15 +181,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty scoreIncrease -= comboScoreIncrease; if (addScoreComboMultiplier) - ComboScore += comboScoreIncrease; + attributes.ComboScore += comboScoreIncrease; if (isBonus) { legacyBonusScore += scoreIncrease; - modernBonusScore += Judgement.ToNumericResult(bonusResult); + standardisedBonusScore += Judgement.ToNumericResult(bonusResult); } else - AccuracyScore += scoreIncrease; + attributes.AccuracyScore += scoreIncrease; if (increaseCombo) combo++; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs index 5516e025cd..c94016d2b1 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs @@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { public partial class LegacyCirclePiece : CompositeDrawable, IHasAccentColour { + private static readonly Vector2 circle_piece_size = new Vector2(128); + private Drawable backgroundLayer = null!; private Drawable? foregroundLayer; @@ -52,9 +54,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy string prefix = ((drawableHitObject.HitObject as TaikoStrongableHitObject)?.IsStrong ?? false) ? big_hit : normal_hit; - return skin.GetAnimation($"{prefix}{lookup}", true, false) ?? + return skin.GetAnimation($"{prefix}{lookup}", true, false, maxSize: circle_piece_size) ?? // fallback to regular size if "big" version doesn't exist. - skin.GetAnimation($"{normal_hit}{lookup}", true, false); + skin.GetAnimation($"{normal_hit}{lookup}", true, false, maxSize: circle_piece_size); } // backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer. @@ -96,7 +98,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy // Not all skins (including the default osu-stable) have similar sizes for "hitcircle" and "hitcircleoverlay". // This ensures they are scaled relative to each other but also match the expected DrawableHit size. foreach (var c in InternalChildren) - c.Scale = new Vector2(DrawHeight / 128); + c.Scale = new Vector2(DrawHeight / circle_piece_size.Y); if (foregroundLayer is IFramedAnimation animatableForegroundLayer) animateForegroundLayer(animatableForegroundLayer); diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 584a91bfdc..072653dcbf 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -34,6 +34,7 @@ using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; using osu.Game.Rulesets.Configuration; using osu.Game.Configuration; +using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Rulesets.Taiko.Configuration; namespace osu.Game.Rulesets.Taiko diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index a26c8121dd..37a91c8611 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestSingleSpan() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestRepeat() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestNonEvenTicks() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -83,12 +83,12 @@ namespace osu.Game.Tests.Beatmaps } [Test] - public void TestLegacyLastTickOffset() + public void TestLastTickOffset() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray(); - Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick)); - Assert.That(events[2].Time, Is.EqualTo(900)); + Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LastTick)); + Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET)); } [Test] @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Beatmaps const double velocity = 5; const double min_distance = velocity * 10; - var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2, 0).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2).ToArray(); Assert.Multiple(() => { diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs index 0144c0bf97..5f722e381c 100644 --- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs +++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs @@ -44,17 +44,23 @@ namespace osu.Game.Tests.Database createFile(subdirectory2, Path.Combine("beatmap5", "beatmap.osu")); createFile(subdirectory2, Path.Combine("beatmap6", "beatmap.osu")); + // songs subdirectory with random file + var subdirectory3 = songsStorage.GetStorageForDirectory("subdirectory3"); + createFile(subdirectory3, "silly readme.txt"); + createFile(subdirectory3, Path.Combine("beatmap7", "beatmap.osu")); + // empty songs subdirectory songsStorage.GetStorageForDirectory("subdirectory3"); string[] paths = importer.GetStableImportPaths(songsStorage).ToArray(); - Assert.That(paths.Length, Is.EqualTo(6)); + Assert.That(paths.Length, Is.EqualTo(7)); Assert.That(paths.Contains(songsStorage.GetFullPath("beatmap1"))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap2")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap3")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "sub-subdirectory", "beatmap4")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap5")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap6")))); + Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory3", "beatmap7")))); } static void createFile(Storage storage, string path) diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index e5e96d2033..17a4c80f7f 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -45,9 +45,9 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Standardised, HitResult.Meh, 116_667)] [TestCase(ScoringMode.Standardised, HitResult.Ok, 233_338)] [TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)] - [TestCase(ScoringMode.Classic, HitResult.Meh, 0)] - [TestCase(ScoringMode.Classic, HitResult.Ok, 2)] - [TestCase(ScoringMode.Classic, HitResult.Great, 36)] + [TestCase(ScoringMode.Classic, HitResult.Meh, 11_670)] + [TestCase(ScoringMode.Classic, HitResult.Ok, 23_341)] + [TestCase(ScoringMode.Classic, HitResult.Great, 100_033)] public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore) { scoreProcessor.ApplyBeatmap(beatmap); @@ -84,17 +84,17 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] - [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 4)] - [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15)] - [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 53)] - [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 140)] - [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 140)] + [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 7_975)] + [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15_949)] + [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 30_398)] + [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 49_546)] + [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 49_546)] [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] - [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 11)] + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 54_189)] [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] - [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 9)] - [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 36)] - [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 36)] + [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 49_289)] + [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 100_003)] + [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 100_015)] public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) { var minResult = new TestJudgement(hitResult).MinResult; diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index 1c1ebe271e..ab3e099c3a 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "skin.osk")); // When the import filename doesn't match, it should be appended (and update the skin.ini). - assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); + assertCorrectMetadata(import1, "test skin [skin]", "skinner", 1.0m, osu); }); [Test] @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner", iniFilename: "Skin.InI"), "skin.osk")); // When the import filename doesn't match, it should be appended (and update the skin.ini). - assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); + assertCorrectMetadata(import1, "test skin [skin]", "skinner", 1.0m, osu); }); [Test] @@ -45,7 +45,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner", includeSectionHeader: false), "skin.osk")); // When the import filename doesn't match, it should be appended (and update the skin.ini). - assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); + assertCorrectMetadata(import1, "test skin [skin]", "skinner", 1.0m, osu); }); [Test] @@ -54,7 +54,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "test skin.osk")); // When the import filename matches it shouldn't be appended. - assertCorrectMetadata(import1, "test skin", "skinner", osu); + assertCorrectMetadata(import1, "test skin", "skinner", 1.0m, osu); }); [Test] @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithNonIniFile(), "test skin.osk")); // When the import filename matches it shouldn't be appended. - assertCorrectMetadata(import1, "test skin", "Unknown", osu); + assertCorrectMetadata(import1, "test skin", "Unknown", SkinConfiguration.LATEST_VERSION, osu); }); [Test] @@ -72,7 +72,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createEmptyOsk(), "test skin.osk")); // When the import filename matches it shouldn't be appended. - assertCorrectMetadata(import1, "test skin", "Unknown", osu); + assertCorrectMetadata(import1, "test skin", "Unknown", SkinConfiguration.LATEST_VERSION, osu); }); #endregion @@ -102,7 +102,7 @@ namespace osu.Game.Tests.Skins.IO public Task TestImportUpperCasedOskArchive() => runSkinTest(async osu => { var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "name 1.OsK")); - assertCorrectMetadata(import1, "name 1", "author 1", osu); + assertCorrectMetadata(import1, "name 1", "author 1", 1.0m, osu); var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "name 1.oSK")); @@ -115,14 +115,14 @@ namespace osu.Game.Tests.Skins.IO MemoryStream exportStream = new MemoryStream(); var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "custom.osk")); - assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu); + assertCorrectMetadata(import1, "name 1 [custom]", "author 1", 1.0m, osu); await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(import1, exportStream); string exportFilename = import1.GetDisplayString(); var import2 = await loadSkinIntoOsu(osu, new ImportTask(exportStream, $"{exportFilename}.osk")); - assertCorrectMetadata(import2, "name 1 [custom]", "author 1", osu); + assertCorrectMetadata(import2, "name 1 [custom]", "author 1", 1.0m, osu); assertImportedOnce(import1, import2); }); @@ -133,14 +133,14 @@ namespace osu.Game.Tests.Skins.IO MemoryStream exportStream = new MemoryStream(); var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 『1』", "author 1"), "custom.osk")); - assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", osu); + assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", 1.0m, osu); await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(import1, exportStream); string exportFilename = import1.GetDisplayString().GetValidFilename(); var import2 = await loadSkinIntoOsu(osu, new ImportTask(exportStream, $"{exportFilename}.osk")); - assertCorrectMetadata(import2, "name 『1』 [custom]", "author 1", osu); + assertCorrectMetadata(import2, "name 『1』 [custom]", "author 1", 1.0m, osu); }); [Test] @@ -150,7 +150,7 @@ namespace osu.Game.Tests.Skins.IO var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 1"), batchImport); assertImportedOnce(import1, import2); - assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", osu); + assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", 1.0m, osu); }); #endregion @@ -183,8 +183,8 @@ namespace osu.Game.Tests.Skins.IO var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin v2.1", "skinner"), "skin.osk")); assertImportedBoth(import1, import2); - assertCorrectMetadata(import1, "test skin v2 [skin]", "skinner", osu); - assertCorrectMetadata(import2, "test skin v2.1 [skin]", "skinner", osu); + assertCorrectMetadata(import1, "test skin v2 [skin]", "skinner", 1.0m, osu); + assertCorrectMetadata(import2, "test skin v2.1 [skin]", "skinner", 1.0m, osu); }); [Test] @@ -194,8 +194,8 @@ namespace osu.Game.Tests.Skins.IO var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 2")); assertImportedBoth(import1, import2); - assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", osu); - assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", osu); + assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", 1.0m, osu); + assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", 1.0m, osu); }); [Test] @@ -264,7 +264,7 @@ namespace osu.Game.Tests.Skins.IO #endregion - private void assertCorrectMetadata(Live import1, string name, string creator, OsuGameBase osu) + private void assertCorrectMetadata(Live import1, string name, string creator, decimal version, OsuGameBase osu) { import1.PerformRead(i => { @@ -276,6 +276,7 @@ namespace osu.Game.Tests.Skins.IO Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name)); Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator)); + Assert.That(instance.Configuration.LegacyVersion, Is.EqualTo(version)); }); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 920e560018..e6c3eea925 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -125,7 +125,7 @@ namespace osu.Game.Tests.Visual.Editing }); AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual); - AddAssert("track length changed", () => Beatmap.Value.Track.Length > 60000); + AddUntilStep("track length changed", () => Beatmap.Value.Track.Length > 60000); AddStep("test play", () => Editor.TestGameplay()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs index 12a2611a76..06a7763711 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs @@ -35,7 +35,7 @@ namespace osu.Game.Tests.Visual.Gameplay new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, + Colour = Color4.Gray, }, new ArgonHealthDisplay { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index ec4bb1a86b..32693c2bb2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -2,17 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Storyboards; using osu.Game.Storyboards.Drawables; +using osu.Game.Tests.Resources; using osuTK; namespace osu.Game.Tests.Visual.Gameplay @@ -21,17 +27,21 @@ namespace osu.Game.Tests.Visual.Gameplay { protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); - [Cached] - private Storyboard storyboard { get; set; } = new Storyboard(); + [Cached(typeof(Storyboard))] + private TestStoryboard storyboard { get; set; } = new TestStoryboard(); private IEnumerable sprites => this.ChildrenOfType(); + private const string lookup_name = "hitcircleoverlay"; + [Test] public void TestSkinSpriteDisallowedByDefault() { - const string lookup_name = "hitcircleoverlay"; - - AddStep("allow skin lookup", () => storyboard.UseSkinSprites = false); + AddStep("disallow all lookups", () => + { + storyboard.UseSkinSprites = false; + storyboard.AlwaysProvideTexture = false; + }); AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); @@ -40,11 +50,13 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestAllowLookupFromSkin() + public void TestLookupFromStoryboard() { - const string lookup_name = "hitcircleoverlay"; - - AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); + AddStep("allow storyboard lookup", () => + { + storyboard.UseSkinSprites = false; + storyboard.AlwaysProvideTexture = true; + }); AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); @@ -52,16 +64,54 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("sprite found texture", () => sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Texture != null))); - AddAssert("skinnable sprite has correct size", () => - sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Size == new Vector2(128)))); + assertStoryboardSourced(); + } + + [Test] + public void TestSkinLookupPreferredOverStoryboard() + { + AddStep("allow all lookups", () => + { + storyboard.UseSkinSprites = true; + storyboard.AlwaysProvideTexture = true; + }); + + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + + // Only checking for at least one sprite that succeeded, as not all skins in this test provide the hitcircleoverlay texture. + AddAssert("sprite found texture", () => + sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Texture != null))); + + assertSkinSourced(); + } + + [Test] + public void TestAllowLookupFromSkin() + { + AddStep("allow skin lookup", () => + { + storyboard.UseSkinSprites = true; + storyboard.AlwaysProvideTexture = false; + }); + + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + + // Only checking for at least one sprite that succeeded, as not all skins in this test provide the hitcircleoverlay texture. + AddAssert("sprite found texture", () => + sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Texture != null))); + + assertSkinSourced(); } [Test] public void TestFlippedSprite() { - const string lookup_name = "hitcircleoverlay"; + AddStep("allow all lookups", () => + { + storyboard.UseSkinSprites = true; + storyboard.AlwaysProvideTexture = true; + }); - AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); AddStep("flip sprites", () => sprites.ForEach(s => { @@ -74,9 +124,12 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestZeroScale() { - const string lookup_name = "hitcircleoverlay"; + AddStep("allow all lookups", () => + { + storyboard.UseSkinSprites = true; + storyboard.AlwaysProvideTexture = true; + }); - AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); AddAssert("sprites present", () => sprites.All(s => s.IsPresent)); AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(0, 1))); @@ -86,9 +139,12 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestNegativeScale() { - const string lookup_name = "hitcircleoverlay"; + AddStep("allow all lookups", () => + { + storyboard.UseSkinSprites = true; + storyboard.AlwaysProvideTexture = true; + }); - AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(-1))); AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight)); @@ -97,9 +153,12 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestNegativeScaleWithFlippedSprite() { - const string lookup_name = "hitcircleoverlay"; + AddStep("allow all lookups", () => + { + storyboard.UseSkinSprites = true; + storyboard.AlwaysProvideTexture = true; + }); - AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(-1))); AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight)); @@ -111,13 +170,78 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("origin back", () => sprites.All(s => s.Origin == Anchor.TopLeft)); } - private DrawableStoryboardSprite createSprite(string lookupName, Anchor origin, Vector2 initialPosition) - => new DrawableStoryboardSprite( - new StoryboardSprite(lookupName, origin, initialPosition) - ).With(s => + private DrawableStoryboard createSprite(string lookupName, Anchor origin, Vector2 initialPosition) + { + var layer = storyboard.GetLayer("Background"); + + var sprite = new StoryboardSprite(lookupName, origin, initialPosition); + sprite.AddLoop(Time.Current, 100).Alpha.Add(Easing.None, 0, 10000, 1, 1); + + layer.Elements.Clear(); + layer.Add(sprite); + + return storyboard.CreateDrawable().With(s => s.RelativeSizeAxes = Axes.Both); + } + + private void assertStoryboardSourced() + { + AddAssert("sprite came from storyboard", () => + sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Size == new Vector2(200)))); + } + + private void assertSkinSourced() + { + AddAssert("sprite came from skin", () => + sprites.Any(sprite => sprite.ChildrenOfType().All(s => s.Size == new Vector2(128)))); + } + + private partial class TestStoryboard : Storyboard + { + public override DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) { - s.LifetimeStart = double.MinValue; - s.LifetimeEnd = double.MaxValue; - }); + return new TestDrawableStoryboard(this, mods); + } + + public bool AlwaysProvideTexture { get; set; } + + public override string GetStoragePathFromStoryboardPath(string path) => AlwaysProvideTexture ? path : string.Empty; + + private partial class TestDrawableStoryboard : DrawableStoryboard + { + private readonly bool alwaysProvideTexture; + + public TestDrawableStoryboard(TestStoryboard storyboard, IReadOnlyList? mods) + : base(storyboard, mods) + { + alwaysProvideTexture = storyboard.AlwaysProvideTexture; + } + + protected override IResourceStore CreateResourceLookupStore() => alwaysProvideTexture + ? new AlwaysReturnsTextureStore() + : new ResourceStore(); + + internal class AlwaysReturnsTextureStore : IResourceStore + { + private const string test_image = "Resources/Textures/test-image.png"; + + private readonly DllResourceStore store; + + public AlwaysReturnsTextureStore() + { + store = TestResources.GetStore(); + } + + public void Dispose() => store.Dispose(); + + public byte[] Get(string name) => store.Get(test_image); + + public Task GetAsync(string name, CancellationToken cancellationToken = new CancellationToken()) => store.GetAsync(test_image, cancellationToken); + + public Stream GetStream(string name) => store.GetStream(test_image); + + public IEnumerable GetAvailableResources() => store.GetAvailableResources(); + } + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerMaxDimensions.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerMaxDimensions.cs new file mode 100644 index 0000000000..a8ed44c7f8 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerMaxDimensions.cs @@ -0,0 +1,80 @@ +// 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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Textures; +using osu.Framework.Testing; +using osu.Game.IO; +using osu.Game.Rulesets; +using osu.Game.Screens.Play; +using osu.Game.Skinning; + +namespace osu.Game.Tests.Visual.Gameplay +{ + /// + /// Upscales all gameplay sprites by a huge amount, to aid in manually checking skin texture size limits + /// on individual elements. + /// + /// + /// The HUD is hidden as it does't really affect game balance if HUD elements are larger than they should be. + /// + public partial class TestScenePlayerMaxDimensions : TestSceneAllRulesetPlayers + { + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + // for now this only applies to legacy skins, as modern skins don't have texture-based gameplay elements yet. + dependencies.CacheAs(new UpscaledLegacySkin(dependencies.Get())); + + return dependencies; + } + + protected override void AddCheckSteps() + { + } + + protected override Player CreatePlayer(Ruleset ruleset) + { + var player = base.CreatePlayer(ruleset); + player.OnLoadComplete += _ => + { + // this test scene focuses on gameplay elements, so let's hide the hud. + var hudOverlay = player.ChildrenOfType().Single(); + hudOverlay.ShowHud.Value = false; + hudOverlay.ShowHud.Disabled = true; + }; + return player; + } + + private class UpscaledLegacySkin : DefaultLegacySkin, ISkinSource + { + public UpscaledLegacySkin(IStorageResourceProvider resources) + : base(resources) + { + } + + public event Action? SourceChanged + { + add { } + remove { } + } + + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + var texture = base.GetTexture(componentName, wrapModeS, wrapModeT); + + if (texture != null) + texture.ScaleAdjust /= 8f; + + return texture; + } + + public ISkin FindProvider(Func lookupFunction) => this; + public IEnumerable AllSources => new[] { this }; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 43a7da0c28..6737ec9739 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -312,7 +312,9 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); - addRulesetImportStep(0); + // We need to use one real beatmap to trigger the "same-track-transfer" logic that we're looking to test here. + // See `SongSelect.ensurePlayingSelected` and `WorkingBeatmap.TryTransferTrack`. + AddStep("import test beatmap", () => manager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).WaitSafely()); addRulesetImportStep(0); checkMusicPlaying(true); @@ -321,6 +323,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("manual pause", () => music.TogglePause()); checkMusicPlaying(false); + + // Track should not have changed, so music should still not be playing. AddStep("select next difficulty", () => songSelect!.Carousel.SelectNext(skipDifficulties: false)); checkMusicPlaying(false); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs index 897d5fd9f5..11cd122c99 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -1,10 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; @@ -25,6 +29,53 @@ namespace osu.Game.Tests.Visual.UserInterface ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods().Select(m => new ModIcon(m)), }; }); + + AddStep("toggle selected", () => + { + foreach (var icon in this.ChildrenOfType()) + icon.Selected.Toggle(); + }); + } + + [Test] + public void TestShowRateAdjusts() + { + AddStep("create mod icons", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Full, + ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods() + .OfType() + .SelectMany(m => + { + List icons = new List { new ModIcon(m) }; + + for (double i = m.SpeedChange.MinValue; i < m.SpeedChange.MaxValue; i += m.SpeedChange.Precision * 10) + { + m = (ModRateAdjust)m.DeepClone(); + m.SpeedChange.Value = i; + icons.Add(new ModIcon(m)); + } + + return icons; + }), + }; + }); + + AddStep("adjust rates", () => + { + foreach (var icon in this.ChildrenOfType()) + { + if (icon.Mod is ModRateAdjust rateAdjust) + { + rateAdjust.SpeedChange.Value = RNG.NextDouble() > 0.9 + ? rateAdjust.SpeedChange.Default + : RNG.NextDouble(rateAdjust.SpeedChange.MinValue, rateAdjust.SpeedChange.MaxValue); + } + } + }); } [Test] diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index b497b8e8dc..e89e5339e1 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -280,7 +280,7 @@ namespace osu.Game.Beatmaps public override string HumanisedModelName => "beatmap"; - protected override BeatmapSetInfo? CreateModel(ArchiveReader reader) + protected override BeatmapSetInfo? CreateModel(ArchiveReader reader, ImportParameters parameters) { // let's make sure there are actually .osu files to import. string? mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); diff --git a/osu.Game/Database/LegacyBeatmapImporter.cs b/osu.Game/Database/LegacyBeatmapImporter.cs index 20add54949..a090698a68 100644 --- a/osu.Game/Database/LegacyBeatmapImporter.cs +++ b/osu.Game/Database/LegacyBeatmapImporter.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -34,9 +33,9 @@ namespace osu.Game.Database try { - if (!directoryStorage.GetFiles(string.Empty).ExcludeSystemFileNames().Any()) + if (!directoryStorage.GetFiles(string.Empty, "*.osu").Any()) { - // if a directory doesn't contain files, attempt looking for beatmaps inside of that directory. + // if a directory doesn't contain any beatmap files, look for further nested beatmap directories. // this is a special behaviour in stable for beatmaps only, see https://github.com/ppy/osu/issues/18615. foreach (string subDirectory in GetStableImportPaths(directoryStorage)) paths.Add(subDirectory); diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index 9d06c14b4b..730465e1b0 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -149,7 +149,7 @@ namespace osu.Game.Database return imported; } - notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!"; + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed! Check logs for more information."; notification.State = ProgressNotificationState.Cancelled; } else @@ -229,7 +229,7 @@ namespace osu.Game.Database try { - model = CreateModel(archive); + model = CreateModel(archive, parameters); if (model == null) return null; @@ -474,8 +474,9 @@ namespace osu.Game.Database /// Actual expensive population should be done in ; this should just prepare for duplicate checking. /// /// The archive to create the model for. + /// Parameters to further configure the import process. /// A model populated with minimal information. Returning a null will abort importing silently. - protected abstract TModel? CreateModel(ArchiveReader archive); + protected abstract TModel? CreateModel(ArchiveReader archive, ImportParameters parameters); /// /// Populate the provided model completely from the given archive. diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 11a5e252a3..dd7d7c7b1b 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Scoring; namespace osu.Game.Database @@ -222,15 +223,9 @@ namespace osu.Game.Database throw new InvalidOperationException("Beatmap contains no hit objects!"); ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator(); + LegacyScoreAttributes attributes = sv1Simulator.Simulate(beatmap, playableBeatmap); - sv1Simulator.Simulate(beatmap, playableBeatmap, mods); - - return ConvertFromLegacyTotalScore(score, new DifficultyAttributes - { - LegacyAccuracyScore = sv1Simulator.AccuracyScore, - LegacyComboScore = sv1Simulator.ComboScore, - LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio - }); + return ConvertFromLegacyTotalScore(score, attributes); } /// @@ -241,20 +236,21 @@ namespace osu.Game.Database /// (, , and ) /// for the beatmap which the score was set on. /// The standardised total score. - public static long ConvertFromLegacyTotalScore(ScoreInfo score, DifficultyAttributes attributes) + public static long ConvertFromLegacyTotalScore(ScoreInfo score, LegacyScoreAttributes attributes) { if (!score.IsLegacyScore) return score.TotalScore; Debug.Assert(score.LegacyTotalScore != null); - int maximumLegacyAccuracyScore = attributes.LegacyAccuracyScore; - int maximumLegacyComboScore = attributes.LegacyComboScore; - double maximumLegacyBonusRatio = attributes.LegacyBonusScoreRatio; double modMultiplier = score.Mods.Select(m => m.ScoreMultiplier).Aggregate(1.0, (c, n) => c * n); + int maximumLegacyAccuracyScore = attributes.AccuracyScore; + long maximumLegacyComboScore = (long)Math.Round(attributes.ComboScore * modMultiplier); + double maximumLegacyBonusRatio = attributes.BonusScoreRatio; + // The part of total score that doesn't include bonus. - int maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore; + long maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore; // The combo proportion is calculated as a proportion of maximumLegacyBaseScore. double comboProportion = Math.Min(1, (double)score.LegacyTotalScore / maximumLegacyBaseScore); diff --git a/osu.Game/Graphics/OsuIcon.cs b/osu.Game/Graphics/OsuIcon.cs index 1f4d6a62da..15af8f000b 100644 --- a/osu.Game/Graphics/OsuIcon.cs +++ b/osu.Game/Graphics/OsuIcon.cs @@ -89,6 +89,8 @@ namespace osu.Game.Graphics public static IconUsage ModSpunOut => Get(0xe046); public static IconUsage ModSuddenDeath => Get(0xe047); public static IconUsage ModTarget => Get(0xe048); - public static IconUsage ModBg => Get(0xe04a); + + // Use "Icons/BeatmapDetails/mod-icon" instead + // public static IconUsage ModBg => Get(0xe04a); } } diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index 58306c1938..1f26ab5458 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.cs @@ -99,9 +99,14 @@ namespace osu.Game.Online.API return true; } } + catch (HttpRequestException) + { + // Network failure. + return false; + } catch { - //todo: potentially only kill the refresh token on certain exception types. + // Force a full re-reauthentication. Token.Value = null; return false; } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 229cfd2aa6..136c9cc8e7 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -31,6 +31,7 @@ using osuTK; using osuTK.Graphics; using osu.Game.Online.API; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; using osu.Game.Utils; namespace osu.Game.Online.Leaderboards @@ -242,7 +243,7 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.BottomRight, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - ChildrenEnumerable = Score.Mods.Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) + ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) }, }, }, diff --git a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs index 0b2e401f57..ed3ee4d45e 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs @@ -118,7 +118,7 @@ namespace osu.Game.Online.Leaderboards topScoreStatistics.Clear(); bottomScoreStatistics.Clear(); - foreach (var mod in score.Mods) + foreach (var mod in score.Mods.AsOrdered()) { modStatistics.Add(new ModCell(mod)); } @@ -210,7 +210,7 @@ namespace osu.Game.Online.Leaderboards Spacing = new Vector2(2f, 0f), Children = new Drawable[] { - new ModIcon(mod, showTooltip: false).With(icon => + new ModIcon(mod, showTooltip: false, showExtendedInformation: false).With(icon => { icon.Origin = Anchor.CentreLeft; icon.Anchor = Anchor.CentreLeft; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 615a3e39af..1fc997fdad 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -24,6 +24,7 @@ using osu.Framework.Localisation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Cursor; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring.Drawables; namespace osu.Game.Overlays.BeatmapSet.Scores @@ -195,7 +196,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, Spacing = new Vector2(1), - ChildrenEnumerable = score.Mods.Select(m => new ModIcon(m) + ChildrenEnumerable = score.Mods.AsOrdered().Select(m => new ModIcon(m) { AutoSizeAxes = Axes.Both, Scale = new Vector2(0.3f) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index c92b79cb4d..72e590b009 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -275,7 +275,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set { modsContainer.Clear(); - modsContainer.Children = value.Select(mod => new ModIcon(mod) + modsContainer.Children = value.AsOrdered().Select(mod => new ModIcon(mod) { AutoSizeAxes = Axes.Both, Scale = new Vector2(0.25f), diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 2e9087fdbd..36a9baac67 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -3,6 +3,9 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -28,6 +31,9 @@ namespace osu.Game.Overlays.Dialog private readonly Vector2 ringMinifiedSize = new Vector2(20f); private readonly Vector2 buttonsEnterSpacing = new Vector2(0f, 50f); + private readonly Box flashLayer; + private Sample flashSample = null!; + private readonly Container content; private readonly Container ring; private readonly FillFlowContainer buttonsContainer; @@ -208,6 +214,13 @@ namespace osu.Game.Overlays.Dialog AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, }, + flashLayer = new Box + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Colour = Color4Extensions.FromHex(@"221a21"), + }, }, }, }; @@ -217,6 +230,12 @@ namespace osu.Game.Overlays.Dialog Show(); } + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + flashSample = audio.Samples.Get(@"UI/default-select-disabled"); + } + /// /// Programmatically clicks the first . /// @@ -232,6 +251,14 @@ namespace osu.Game.Overlays.Dialog Scheduler.AddOnce(() => Buttons.OfType().FirstOrDefault()?.TriggerClick()); } + public void Flash() + { + flashLayer.FadeInFromZero(80, Easing.OutQuint) + .Then() + .FadeOutFromOne(1500, Easing.OutQuint); + flashSample.Play(); + } + protected override bool OnKeyDown(KeyDownEvent e) { if (e.Repeat) return false; diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs index d755825a95..571021b0f8 100644 --- a/osu.Game/Overlays/Mods/EditPresetPopover.cs +++ b/osu.Game/Overlays/Mods/EditPresetPopover.cs @@ -159,7 +159,7 @@ namespace osu.Game.Overlays.Mods private void updateState() { - scrollContent.ChildrenEnumerable = saveableMods.Select(mod => new ModPresetRow(mod)); + scrollContent.ChildrenEnumerable = saveableMods.AsOrdered().Select(mod => new ModPresetRow(mod)); useCurrentModsButton.Enabled.Value = checkSelectedModsDiffersFromSaved(); } diff --git a/osu.Game/Overlays/Mods/ModPresetTooltip.cs b/osu.Game/Overlays/Mods/ModPresetTooltip.cs index 8e8259de45..077bd14751 100644 --- a/osu.Game/Overlays/Mods/ModPresetTooltip.cs +++ b/osu.Game/Overlays/Mods/ModPresetTooltip.cs @@ -50,7 +50,7 @@ namespace osu.Game.Overlays.Mods return; lastPreset = preset; - Content.ChildrenEnumerable = preset.Mods.Select(mod => new ModPresetRow(mod)); + Content.ChildrenEnumerable = preset.Mods.AsOrdered().Select(mod => new ModPresetRow(mod)); } protected override void PopIn() => this.FadeIn(transition_duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/Mods/ModSettingsArea.cs b/osu.Game/Overlays/Mods/ModSettingsArea.cs index fe1d683d59..6158c2c70f 100644 --- a/osu.Game/Overlays/Mods/ModSettingsArea.cs +++ b/osu.Game/Overlays/Mods/ModSettingsArea.cs @@ -84,7 +84,7 @@ namespace osu.Game.Overlays.Mods { modSettingsFlow.Clear(); - foreach (var mod in SelectedMods.Value.OrderBy(mod => mod.Type).ThenBy(mod => mod.Acronym)) + foreach (var mod in SelectedMods.Value.AsOrdered()) { var settings = mod.CreateSettingsControls().ToList(); diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 529e78a7cf..c26f2f19ba 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring.Drawables; using osu.Game.Utils; @@ -48,6 +49,8 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { + var ruleset = rulesets.GetRuleset(Score.RulesetID)?.CreateInstance() ?? throw new InvalidOperationException($"Ruleset with ID of {Score.RulesetID} not found locally"); + AddInternal(new ProfileItemContainer { Children = new Drawable[] @@ -132,14 +135,9 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.CentreRight, Direction = FillDirection.Horizontal, Spacing = new Vector2(2), - Children = Score.Mods.Select(mod => + Children = Score.Mods.Select(m => m.ToMod(ruleset)).AsOrdered().Select(mod => new ModIcon(mod) { - var ruleset = rulesets.GetRuleset(Score.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {Score.RulesetID} not found locally"); - - return new ModIcon(mod.ToMod(ruleset.CreateInstance())) - { - Scale = new Vector2(0.35f) - }; + Scale = new Vector2(0.35f) }).ToList(), } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs index 9efdfa9955..69566d85f4 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs @@ -30,7 +30,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay }, new SettingsCheckbox { - Keywords = new[] { "combo", "override" }, + Keywords = new[] { "combo", "override", "color" }, LabelText = SkinSettingsStrings.BeatmapColours, Current = config.GetBindable(OsuSetting.BeatmapColours) }, @@ -47,6 +47,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay }, new SettingsSlider { + Keywords = new[] { "color" }, LabelText = GraphicsSettingsStrings.ComboColourNormalisation, Current = comboColourNormalisation, DisplayAsPercentage = true, diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index f62eeab5d7..6043731e6e 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -27,9 +27,6 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; - protected const int ATTRIB_ID_LEGACY_ACCURACY_SCORE = 23; - protected const int ATTRIB_ID_LEGACY_COMBO_SCORE = 25; - protected const int ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO = 27; /// /// The mods which were applied to the beatmap. @@ -91,9 +88,6 @@ namespace osu.Game.Rulesets.Difficulty public virtual IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() { yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); - yield return (ATTRIB_ID_LEGACY_ACCURACY_SCORE, LegacyAccuracyScore); - yield return (ATTRIB_ID_LEGACY_COMBO_SCORE, LegacyComboScore); - yield return (ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO, LegacyBonusScoreRatio); } /// @@ -104,11 +98,6 @@ namespace osu.Game.Rulesets.Difficulty public virtual void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) { MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; - - // Temporarily allow these attributes to not exist so as to not block releases of server-side components while these attributes aren't populated/used yet. - LegacyAccuracyScore = (int)values.GetValueOrDefault(ATTRIB_ID_LEGACY_ACCURACY_SCORE); - LegacyComboScore = (int)values.GetValueOrDefault(ATTRIB_ID_LEGACY_COMBO_SCORE); - LegacyBonusScoreRatio = values.GetValueOrDefault(ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO); } } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index d005bbfc7a..00c90bd317 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -23,13 +23,6 @@ namespace osu.Game.Rulesets.Difficulty { public abstract class DifficultyCalculator { - /// - /// Whether legacy scoring values (ScoreV1) should be computed to populate the difficulty attributes - /// , , - /// and . - /// - public bool ComputeLegacyScoringValues; - /// /// The beatmap for which difficulty will be calculated. /// diff --git a/osu.Game/Rulesets/ILegacyRuleset.cs b/osu.Game/Rulesets/ILegacyRuleset.cs index 24aa672219..6900afa243 100644 --- a/osu.Game/Rulesets/ILegacyRuleset.cs +++ b/osu.Game/Rulesets/ILegacyRuleset.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Scoring.Legacy; namespace osu.Game.Rulesets { diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 05b2510e53..ce2d123884 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -19,6 +19,13 @@ namespace osu.Game.Rulesets.Mods /// string Name { get; } + /// + /// Short important information to display on the mod icon. For example, a rate adjust mod's rate + /// or similarly important setting. + /// Use if the icon should not display any additional info. + /// + string ExtendedIconInformation { get; } + /// /// The user readable description of this mod. /// diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index a9ecf89df1..a0bdc9ff51 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -27,6 +27,9 @@ namespace osu.Game.Rulesets.Mods public abstract string Acronym { get; } + [JsonIgnore] + public virtual string ExtendedIconInformation => string.Empty; + [JsonIgnore] public virtual IconUsage? Icon => null; diff --git a/osu.Game/Rulesets/Mods/ModExtensions.cs b/osu.Game/Rulesets/Mods/ModExtensions.cs index aa106f1203..bd2d42f3eb 100644 --- a/osu.Game/Rulesets/Mods/ModExtensions.cs +++ b/osu.Game/Rulesets/Mods/ModExtensions.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; using osu.Game.Scoring; @@ -28,5 +29,9 @@ namespace osu.Game.Rulesets.Mods } }; } + + public static IEnumerable AsOrdered(this IEnumerable mods) => mods + .OrderBy(m => m.Type) + .ThenBy(m => m.Acronym); } } diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 7b55ba4ad0..5dad01e015 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -28,5 +28,7 @@ namespace osu.Game.Rulesets.Mods public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) }; public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; + + public override string ExtendedIconInformation => SettingDescription; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index d91ecf956a..ab8fd2c662 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -13,7 +13,7 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Objects.Legacy { - internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasLegacyLastTickOffset, IHasSliderVelocity + internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity { /// /// Scoring distance with a speed-adjusted beat length of 1 second. @@ -59,7 +59,5 @@ namespace osu.Game.Rulesets.Objects.Legacy Velocity = scoringDistance / timingPoint.BeatLength; } - - public double LegacyLastTickOffset => 36; } } diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index 7013d32cbc..b3477a5fde 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -10,9 +10,17 @@ namespace osu.Game.Rulesets.Objects { public static class SliderEventGenerator { - // ReSharper disable once MethodOverloadWithOptionalParameter + /// + /// Historically, slider's final tick (aka the place where the slider would receive a final judgement) was offset by -36 ms. Originally this was + /// done to workaround a technical detail (unimportant), but over the years it has become an expectation of players that you don't need to hold + /// until the true end of the slider. This very small amount of leniency makes it easier to jump away from fast sliders to the next hit object. + /// + /// After discussion on how this should be handled going forward, players have unanimously stated that this lenience should remain in some way. + /// + public const double LAST_TICK_OFFSET = -36; + public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, - double? legacyLastTickOffset, CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) { // A very lenient maximum length of a slider for ticks to be generated. // This exists for edge cases such as /b/1573664 where the beatmap has been edited by the user, and should never be reached in normal usage. @@ -76,14 +84,14 @@ namespace osu.Game.Rulesets.Objects int finalSpanIndex = spanCount - 1; double finalSpanStartTime = startTime + finalSpanIndex * spanDuration; - double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) - (legacyLastTickOffset ?? 0)); + double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) + LAST_TICK_OFFSET); double finalProgress = (finalSpanEndTime - finalSpanStartTime) / spanDuration; if (spanCount % 2 == 0) finalProgress = 1 - finalProgress; yield return new SliderEventDescriptor { - Type = SliderEventType.LegacyLastTick, + Type = SliderEventType.LastTick, SpanIndex = finalSpanIndex, SpanStartTime = finalSpanStartTime, Time = finalSpanEndTime, @@ -173,7 +181,11 @@ namespace osu.Game.Rulesets.Objects public enum SliderEventType { Tick, - LegacyLastTick, + + /// + /// Occurs just before the tail. See . + /// + LastTick, Head, Tail, Repeat diff --git a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs b/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs deleted file mode 100644 index caf22c3023..0000000000 --- a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Rulesets.Objects.Types -{ - /// - /// A type of which may require the last tick to be offset. - /// This is specific to osu!stable conversion, and should not be used elsewhere. - /// - public interface IHasLegacyLastTickOffset - { - double LegacyLastTickOffset { get; } - } -} diff --git a/osu.Game/Rulesets/Scoring/ILegacyScoreSimulator.cs b/osu.Game/Rulesets/Scoring/ILegacyScoreSimulator.cs deleted file mode 100644 index 7240f0d73e..0000000000 --- a/osu.Game/Rulesets/Scoring/ILegacyScoreSimulator.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) ppy Pty Ltd . 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.Rulesets.Mods; - -namespace osu.Game.Rulesets.Scoring -{ - /// - /// Generates attributes which are required to calculate old-style Score V1 scores. - /// - public interface ILegacyScoreSimulator - { - /// - /// The accuracy portion of the legacy (ScoreV1) total score. - /// - int AccuracyScore { get; } - - /// - /// The combo-multiplied portion of the legacy (ScoreV1) total score. - /// - int ComboScore { get; } - - /// - /// A ratio of new_bonus_score / old_bonus_score for converting the bonus score of legacy scores to the new scoring. - /// This is made up of all judgements that would be or . - /// - double BonusScoreRatio { get; } - - /// - /// Performs the simulation, computing the maximum , , - /// and achievable for the given beatmap. - /// - /// The working beatmap. - /// A playable version of the beatmap for the ruleset. - /// The applied mods. - void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods); - } -} diff --git a/osu.Game/Rulesets/Scoring/Legacy/ILegacyScoreSimulator.cs b/osu.Game/Rulesets/Scoring/Legacy/ILegacyScoreSimulator.cs new file mode 100644 index 0000000000..fe7843a682 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/Legacy/ILegacyScoreSimulator.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Scoring.Legacy +{ + /// + /// Generates attributes which are required to calculate old-style Score V1 scores. + /// + public interface ILegacyScoreSimulator + { + /// + /// Performs the simulation, computing the maximum scoring values achievable for the given beatmap. + /// + /// The working beatmap. + /// A playable version of the beatmap for the ruleset. + LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap); + } +} diff --git a/osu.Game/Rulesets/Scoring/Legacy/LegacyScoreAttributes.cs b/osu.Game/Rulesets/Scoring/Legacy/LegacyScoreAttributes.cs new file mode 100644 index 0000000000..47ab68bf88 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/Legacy/LegacyScoreAttributes.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Scoring.Legacy +{ + public struct LegacyScoreAttributes + { + /// + /// The accuracy portion of the legacy (ScoreV1) total score. + /// + public int AccuracyScore; + + /// + /// The combo-multiplied portion of the legacy (ScoreV1) total score. + /// + public long ComboScore; + + /// + /// A ratio of standardised score to legacy score for the bonus part of total score. + /// + public double BonusScoreRatio; + } +} diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index bf212ad72f..5fd1507039 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -1,22 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osuTK; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Localisation; +using osuTK.Graphics; namespace osu.Game.Rulesets.UI { @@ -27,22 +27,27 @@ namespace osu.Game.Rulesets.UI { public readonly BindableBool Selected = new BindableBool(); - private readonly SpriteIcon modIcon; - private readonly SpriteText modAcronym; - private readonly SpriteIcon background; + private SpriteIcon modIcon = null!; + private SpriteText modAcronym = null!; + private Sprite background = null!; - private const float size = 80; + public static readonly Vector2 MOD_ICON_SIZE = new Vector2(80); - public virtual LocalisableString TooltipText => showTooltip ? ((mod as Mod)?.IconTooltip ?? mod.Name) : null; + public virtual LocalisableString TooltipText => showTooltip ? ((mod as Mod)?.IconTooltip ?? mod.Name) : string.Empty; private IMod mod; + private readonly bool showTooltip; + private readonly bool showExtendedInformation; public IMod Mod { get => mod; set { + if (mod == value) + return; + mod = value; if (IsLoaded) @@ -51,49 +56,103 @@ namespace osu.Game.Rulesets.UI } [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; private Color4 backgroundColour; + private Sprite extendedBackground = null!; + + private OsuSpriteText extendedText = null!; + + private Container extendedContent = null!; + + private ModSettingChangeTracker? modSettingsChangeTracker; + /// /// Construct a new instance. /// /// The mod to be displayed /// Whether a tooltip describing the mod should display on hover. - public ModIcon(IMod mod, bool showTooltip = true) + /// Whether to display a mod's extended information, if available. + public ModIcon(IMod mod, bool showTooltip = true, bool showExtendedInformation = true) { + // May expand due to expanded content, so autosize here. + AutoSizeAxes = Axes.X; + Height = MOD_ICON_SIZE.Y; + this.mod = mod ?? throw new ArgumentNullException(nameof(mod)); this.showTooltip = showTooltip; + this.showExtendedInformation = showExtendedInformation; + } - Size = new Vector2(size); - + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { Children = new Drawable[] { - background = new SpriteIcon + extendedContent = new Container { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(size), - Icon = OsuIcon.ModBg, - Shadow = true, + Name = "extended content", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(116, MOD_ICON_SIZE.Y), + X = MOD_ICON_SIZE.X - 22, + Children = new Drawable[] + { + extendedBackground = new Sprite + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Icons/BeatmapDetails/mod-icon-extender"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + extendedText = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 34f, weight: FontWeight.Bold), + UseFullGlyphHeight = false, + Text = mod.ExtendedIconInformation, + X = 6, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } }, - modAcronym = new OsuSpriteText + new Container { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Colour = OsuColour.Gray(84), - Alpha = 0, - Font = OsuFont.Numeric.With(null, 22f), - UseFullGlyphHeight = false, - Text = mod.Acronym - }, - modIcon = new SpriteIcon - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Colour = OsuColour.Gray(84), - Size = new Vector2(45), - Icon = FontAwesome.Solid.Question + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Name = "main content", + Size = MOD_ICON_SIZE, + Children = new Drawable[] + { + background = new Sprite + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Icons/BeatmapDetails/mod-icon"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + modAcronym = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = OsuColour.Gray(84), + Alpha = 0, + Font = OsuFont.Numeric.With(null, 22f), + UseFullGlyphHeight = false, + Text = mod.Acronym + }, + modIcon = new SpriteIcon + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = OsuColour.Gray(84), + Size = new Vector2(45), + Icon = FontAwesome.Solid.Question + }, + } }, }; } @@ -109,6 +168,14 @@ namespace osu.Game.Rulesets.UI private void updateMod(IMod value) { + modSettingsChangeTracker?.Dispose(); + + if (value is Mod actualMod) + { + modSettingsChangeTracker = new ModSettingChangeTracker(new[] { actualMod }); + modSettingsChangeTracker.SettingChanged = _ => updateExtendedInformation(); + } + modAcronym.Text = value.Acronym; modIcon.Icon = value.Icon ?? FontAwesome.Solid.Question; @@ -125,11 +192,28 @@ namespace osu.Game.Rulesets.UI backgroundColour = colours.ForModType(value.Type); updateColour(); + + updateExtendedInformation(); + } + + private void updateExtendedInformation() + { + bool showExtended = showExtendedInformation && !string.IsNullOrEmpty(mod.ExtendedIconInformation); + + extendedContent.Alpha = showExtended ? 1 : 0; + extendedText.Text = mod.ExtendedIconInformation; } private void updateColour() { - background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour; + extendedText.Colour = background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour; + extendedBackground.Colour = Selected.Value ? backgroundColour.Darken(2.4f) : backgroundColour.Darken(2.8f); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + modSettingsChangeTracker?.Dispose(); } } } diff --git a/osu.Game/Rulesets/UI/ModSwitchSmall.cs b/osu.Game/Rulesets/UI/ModSwitchSmall.cs index b6058c16ce..927379c684 100644 --- a/osu.Game/Rulesets/UI/ModSwitchSmall.cs +++ b/osu.Game/Rulesets/UI/ModSwitchSmall.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Overlays; @@ -23,8 +24,8 @@ namespace osu.Game.Rulesets.UI private readonly IMod mod; - private readonly SpriteIcon background; - private readonly SpriteIcon? modIcon; + private Drawable background = null!; + private SpriteIcon? modIcon; private Color4 activeForegroundColour; private Color4 inactiveForegroundColour; @@ -36,19 +37,24 @@ namespace osu.Game.Rulesets.UI { this.mod = mod; - AutoSizeAxes = Axes.Both; + Size = new Vector2(DEFAULT_SIZE); + } + [BackgroundDependencyLoader] + private void load(TextureStore textures, OsuColour colours, OverlayColourProvider? colourProvider) + { FillFlowContainer contentFlow; ModSwitchTiny tinySwitch; - InternalChildren = new Drawable[] + InternalChildren = new[] { - background = new SpriteIcon + background = new Sprite { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Icons/BeatmapDetails/mod-icon"), Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(DEFAULT_SIZE), - Icon = OsuIcon.ModBg }, contentFlow = new FillFlowContainer { @@ -78,11 +84,7 @@ namespace osu.Game.Rulesets.UI }); 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); diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index 980b742585..f6ea5aa455 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring.Legacy @@ -16,6 +17,9 @@ namespace osu.Game.Scoring.Legacy public static long GetDisplayScore(this ScoreInfo scoreInfo, ScoringMode mode) => getDisplayScore(scoreInfo.Ruleset.OnlineID, scoreInfo.TotalScore, mode, scoreInfo.MaximumStatistics); + public static long GetDisplayScore(this SoloScoreInfo soloScoreInfo, ScoringMode mode) + => getDisplayScore(soloScoreInfo.RulesetID, soloScoreInfo.TotalScore, mode, soloScoreInfo.MaximumStatistics); + private static long getDisplayScore(int rulesetId, long score, ScoringMode mode, IReadOnlyDictionary maximumStatistics) { if (mode == ScoringMode.Standardised) @@ -27,44 +31,37 @@ namespace osu.Game.Scoring.Legacy .DefaultIfEmpty(0) .Sum(); - // 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 scaledRawScore = score / ScoreProcessor.MAX_SCORE; - - return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, maxBasicJudgements), 2) * getStandardisedToClassicMultiplier(rulesetId)); + return convertStandardisedToClassic(rulesetId, score, maxBasicJudgements); } /// - /// Returns a ballpark multiplier which gives a similar "feel" for how large scores should get when displayed in "classic" mode. + /// Returns a ballpark "classic" score which gives a similar "feel" to stable. /// This is different per ruleset to match the different algorithms used in the scoring implementation. /// - private static double getStandardisedToClassicMultiplier(int rulesetId) + /// + /// The coefficients chosen here were determined by a least-squares fit performed over all beatmaps + /// with the goal of minimising the relative error of maximum possible base score (without bonus). + /// The constant coefficients (100000, 1 / 10d) - while being detrimental to the least-squares fit - are forced, + /// so that every 10 points in standardised mode converts to at least 1 point in classic mode. + /// This is done to account for bonus judgements in a way that does not reorder scores. + /// + private static long convertStandardisedToClassic(int rulesetId, long standardisedTotalScore, int objectCount) { - double multiplier; - switch (rulesetId) { - // For non-legacy rulesets, just go with the same as the osu! ruleset. - // This is arbitrary, but at least allows the setting to do something to the score. - default: case 0: - multiplier = 36; - break; + return (long)Math.Round((objectCount * objectCount * 32.57 + 100000) * standardisedTotalScore / ScoreProcessor.MAX_SCORE); case 1: - multiplier = 22; - break; + return (long)Math.Round((objectCount * 1109 + 100000) * standardisedTotalScore / ScoreProcessor.MAX_SCORE); case 2: - multiplier = 28; - break; + return (long)Math.Round(Math.Pow(standardisedTotalScore / ScoreProcessor.MAX_SCORE * objectCount, 2) * 21.62 + standardisedTotalScore / 10d); case 3: - multiplier = 16; - break; + default: + return standardisedTotalScore; } - - return multiplier; } public static int? GetCountGeki(this ScoreInfo scoreInfo) diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index b85b6a066e..886fb1379c 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -42,7 +42,7 @@ namespace osu.Game.Scoring this.api = api; } - protected override ScoreInfo? CreateModel(ArchiveReader archive) + protected override ScoreInfo? CreateModel(ArchiveReader archive, ImportParameters parameters) { string name = archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); @@ -52,14 +52,23 @@ namespace osu.Game.Scoring { return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; } - catch (LegacyScoreDecoder.BeatmapNotFoundException e) + catch (LegacyScoreDecoder.BeatmapNotFoundException notFound) { - Logger.Log($@"Score '{archive.Name}' failed to import: no corresponding beatmap with the hash '{e.Hash}' could be found.", LoggingTarget.Database); + Logger.Log($@"Score '{archive.Name}' failed to import: no corresponding beatmap with the hash '{notFound.Hash}' could be found.", LoggingTarget.Database); - // In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap. - var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = e.Hash }); - req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, e.Hash)); - api.Queue(req); + if (!parameters.Batch) + { + // In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap. + var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); + req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, notFound.Hash)); + api.Queue(req); + } + + return null; + } + catch (Exception e) + { + Logger.Log($@"Failed to parse headers of score '{archive.Name}': {e}.", LoggingTarget.Database); return null; } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 1cdca5754d..1b2e4785db 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -714,8 +714,11 @@ namespace osu.Game.Screens.Edit } // if the dialog is already displayed, block exiting until the user explicitly makes a decision. - if (dialogOverlay.CurrentDialog is PromptForSaveDialog) + if (dialogOverlay.CurrentDialog is PromptForSaveDialog saveDialog) + { + saveDialog.Flash(); return true; + } if (isNewBeatmap || HasUnsavedChanges) { diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 8d08de4168..2cd8e45d28 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -371,8 +371,11 @@ namespace osu.Game.Screens.OnlinePlay.Match return true; // if the dialog is already displayed, block exiting until the user explicitly makes a decision. - if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog) + if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) + { + discardChangesDialog.Flash(); return false; + } dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => { @@ -452,7 +455,7 @@ namespace osu.Game.Screens.OnlinePlay.Match // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info var localBeatmap = beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID); - Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + UserModsSelectOverlay.Beatmap = Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); } protected virtual void UpdateMods() diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index 68685d7eb5..f7059c5853 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -25,211 +25,43 @@ namespace osu.Game.Screens.Play.HUD { public partial class ArgonHealthDisplay : HealthDisplay, ISerialisableDrawable { - private const float curve_start = 280; - private const float curve_end = 310; - private const float curve_smoothness = 10; + public bool UsesFixedAnchor { get; set; } - private const float bar_length = 350; - private const float bar_height = 32.5f; + private BarPath mainBar = null!; - private BarPath healthBar = null!; - private BarPath missBar = null!; + /// + /// Used to show a glow at the end of the main bar, or red "damage" area when missing. + /// + private BarPath glowBar = null!; + + private BackgroundPath background = null!; private SliderPath barPath = null!; - private static readonly Colour4 health_bar_colour = Colour4.White; - - // the opacity isn't part of the design, it's only here to control glow intensity. - private static readonly Colour4 health_bar_glow_colour = Color4Extensions.FromHex("#7ED7FD").Opacity(0.5f); - private static readonly Colour4 health_bar_flash_colour = Color4Extensions.FromHex("#7ED7FD").Opacity(0.8f); - - private static readonly Colour4 miss_bar_colour = Color4Extensions.FromHex("#FF9393"); - private static readonly Colour4 miss_bar_glow_colour = Color4Extensions.FromHex("#FD0000"); - - // the "flashed" glow colour is just a lightened version of the original one, not part of the design. - private static readonly Colour4 miss_bar_flash_colour = Color4Extensions.FromHex("#FF5D5D"); - - public bool UsesFixedAnchor { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - AutoSizeAxes = Axes.Both; - - Vector2 diagonalDir = (new Vector2(curve_end, bar_height) - new Vector2(curve_start, 0)).Normalized(); - - // todo: SliderPath or parts of it should be moved away to a utility class as they're useful for making curved paths in general, as done here. - barPath = new SliderPath(new[] - { - new PathControlPoint(new Vector2(0, 0), PathType.Linear), - new PathControlPoint(new Vector2(curve_start - curve_smoothness, 0), PathType.Bezier), - new PathControlPoint(new Vector2(curve_start, 0)), - new PathControlPoint(new Vector2(curve_start, 0) + diagonalDir * curve_smoothness, PathType.Linear), - new PathControlPoint(new Vector2(curve_end, bar_height) - diagonalDir * curve_smoothness, PathType.Bezier), - new PathControlPoint(new Vector2(curve_end, bar_height)), - new PathControlPoint(new Vector2(curve_end + curve_smoothness, bar_height), PathType.Linear), - new PathControlPoint(new Vector2(bar_length, bar_height)), - }); - - var vertices = new List(); - barPath.GetPathToProgress(vertices, 0.0, 1.0); - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4f, 0f), - Children = new Drawable[] - { - new Circle - { - Margin = new MarginPadding { Top = 10f - 3f / 2f, Left = -2f }, - Size = new Vector2(50f, 3f), - }, - new Container - { - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new BackgroundPath - { - PathRadius = 10f, - Vertices = vertices, - }, - missBar = new BarPath - { - BarColour = Color4.White, - GlowColour = OsuColour.Gray(0.5f), - Blending = BlendingParameters.Additive, - Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.8f), Color4.White), - PathRadius = 40f, - GlowPortion = 0.9f, - Margin = new MarginPadding(-30f), - Vertices = vertices - }, - healthBar = new BarPath - { - AutoSizeAxes = Axes.None, - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - BarColour = health_bar_colour, - GlowColour = health_bar_glow_colour, - PathRadius = 10f, - GlowPortion = 0.6f, - Vertices = vertices - }, - } - } - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Current.BindValueChanged(v => - { - if (v.NewValue >= MissBarValue) - finishMissBarUsage(); - - this.TransformTo(nameof(HealthBarValue), v.NewValue, 300, Easing.OutQuint); - if (resetMissBarDelegate == null) - this.TransformTo(nameof(MissBarValue), v.NewValue, 300, Easing.OutQuint); - }, true); - - updatePathVertices(); - } - - protected override void Update() - { - base.Update(); - - healthBar.Alpha = (float)Interpolation.DampContinuously(healthBar.Alpha, (float)(Current.Value > 0 ? 1 : 0), 40, Time.Elapsed); - missBar.Alpha = (float)Interpolation.DampContinuously(missBar.Alpha, (float)(MissBarValue > 0 ? 1 : 0), 40, Time.Elapsed); - } + private static readonly Colour4 main_bar_colour = Colour4.White; + private static readonly Colour4 main_bar_glow_colour = Color4Extensions.FromHex("#7ED7FD").Opacity(0.5f); private ScheduledDelegate? resetMissBarDelegate; - protected override void Miss(JudgementResult result) - { - base.Miss(result); - - if (result.HealthAtJudgement == 0.0) - // health is already empty, nothing should be displayed here. - return; - - if (resetMissBarDelegate != null) - { - resetMissBarDelegate.Cancel(); - resetMissBarDelegate = null; - } - else - this.TransformTo(nameof(MissBarValue), HealthBarValue); - - this.Delay(500).Schedule(() => - { - this.TransformTo(nameof(MissBarValue), Current.Value, 300, Easing.OutQuint); - - finishMissBarUsage(); - }, out resetMissBarDelegate); - - missBar.TransformTo(nameof(BarPath.BarColour), miss_bar_colour, 100, Easing.OutQuint) - .Then() - .TransformTo(nameof(BarPath.BarColour), miss_bar_flash_colour, 800, Easing.OutQuint); - - missBar.TransformTo(nameof(BarPath.GlowColour), miss_bar_glow_colour.Lighten(0.2f)) - .TransformTo(nameof(BarPath.GlowColour), miss_bar_glow_colour, 800, Easing.OutQuint); - } - - private void finishMissBarUsage() - { - if (Current.Value > 0) - { - missBar.TransformTo(nameof(BarPath.BarColour), health_bar_colour, 300, Easing.In); - missBar.TransformTo(nameof(BarPath.GlowColour), health_bar_glow_colour, 300, Easing.In); - } - - resetMissBarDelegate?.Cancel(); - resetMissBarDelegate = null; - } - - protected override void Flash(JudgementResult result) - { - base.Flash(result); - - healthBar.TransformTo(nameof(BarPath.GlowColour), health_bar_flash_colour) - .TransformTo(nameof(BarPath.GlowColour), health_bar_glow_colour, 300, Easing.OutQuint); - - if (resetMissBarDelegate == null) - { - missBar.TransformTo(nameof(BarPath.BarColour), Colour4.White, 100, Easing.OutQuint) - .Then() - .TransformTo(nameof(BarPath.BarColour), health_bar_colour, 800, Easing.OutQuint); - - missBar.TransformTo(nameof(BarPath.GlowColour), Colour4.White) - .TransformTo(nameof(BarPath.GlowColour), health_bar_glow_colour, 800, Easing.OutQuint); - } - } - - private double missBarValue; private readonly List missBarVertices = new List(); + private readonly List healthBarVertices = new List(); - public double MissBarValue + private double glowBarValue; + + public double GlowBarValue { - get => missBarValue; + get => glowBarValue; set { - if (missBarValue == value) + if (glowBarValue == value) return; - missBarValue = value; + glowBarValue = value; updatePathVertices(); } } private double healthBarValue; - private readonly List healthBarVertices = new List(); public double HealthBarValue { @@ -244,10 +76,179 @@ namespace osu.Game.Screens.Play.HUD } } + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4f, 0f), + Children = new Drawable[] + { + new Circle + { + Margin = new MarginPadding { Top = 8.5f, Left = -2 }, + Size = new Vector2(50f, 3f), + }, + new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new BackgroundPath + { + PathRadius = 10f, + }, + glowBar = new BarPath + { + BarColour = Color4.White, + GlowColour = OsuColour.Gray(0.5f), + Blending = BlendingParameters.Additive, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.8f), Color4.White), + PathRadius = 40f, + // Kinda hacky, but results in correct positioning with increased path radius. + Margin = new MarginPadding(-30f), + GlowPortion = 0.9f, + }, + mainBar = new BarPath + { + AutoSizeAxes = Axes.None, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + BarColour = main_bar_colour, + GlowColour = main_bar_glow_colour, + PathRadius = 10f, + GlowPortion = 0.6f, + }, + } + } + }, + }; + + updatePath(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(v => + { + if (v.NewValue >= GlowBarValue) + finishMissDisplay(); + + this.TransformTo(nameof(HealthBarValue), v.NewValue, 300, Easing.OutQuint); + if (resetMissBarDelegate == null) + this.TransformTo(nameof(GlowBarValue), v.NewValue, 300, Easing.OutQuint); + }, true); + } + + protected override void Update() + { + base.Update(); + + mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); + glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, GlowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + } + + protected override void Flash(JudgementResult result) + { + base.Flash(result); + + mainBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour.Opacity(0.8f)) + .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); + + if (resetMissBarDelegate == null) + { + glowBar.TransformTo(nameof(BarPath.BarColour), Colour4.White, 100, Easing.OutQuint) + .Then() + .TransformTo(nameof(BarPath.BarColour), main_bar_colour, 800, Easing.OutQuint); + + glowBar.TransformTo(nameof(BarPath.GlowColour), Colour4.White) + .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 800, Easing.OutQuint); + } + } + + protected override void Miss(JudgementResult result) + { + base.Miss(result); + + if (resetMissBarDelegate != null) + { + resetMissBarDelegate.Cancel(); + resetMissBarDelegate = null; + } + else + { + // Reset any ongoing animation immediately, else things get weird. + this.TransformTo(nameof(GlowBarValue), HealthBarValue); + } + + this.Delay(500).Schedule(() => + { + this.TransformTo(nameof(GlowBarValue), Current.Value, 300, Easing.OutQuint); + finishMissDisplay(); + }, out resetMissBarDelegate); + + glowBar.TransformTo(nameof(BarPath.BarColour), new Colour4(255, 147, 147, 255), 100, Easing.OutQuint).Then() + .TransformTo(nameof(BarPath.BarColour), new Colour4(255, 93, 93, 255), 800, Easing.OutQuint); + + glowBar.TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255).Lighten(0.2f)) + .TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255), 800, Easing.OutQuint); + } + + private void finishMissDisplay() + { + if (Current.Value > 0) + { + glowBar.TransformTo(nameof(BarPath.BarColour), main_bar_colour, 300, Easing.In); + glowBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.In); + } + + resetMissBarDelegate?.Cancel(); + resetMissBarDelegate = null; + } + + private void updatePath() + { + const float curve_start = 280; + const float curve_end = 310; + const float curve_smoothness = 10; + + const float bar_length = 350; + const float bar_verticality = 32.5f; + + Vector2 diagonalDir = (new Vector2(curve_end, bar_verticality) - new Vector2(curve_start, 0)).Normalized(); + + barPath = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0), PathType.Linear), + new PathControlPoint(new Vector2(curve_start - curve_smoothness, 0), PathType.Bezier), + new PathControlPoint(new Vector2(curve_start, 0)), + new PathControlPoint(new Vector2(curve_start, 0) + diagonalDir * curve_smoothness, PathType.Linear), + new PathControlPoint(new Vector2(curve_end, bar_verticality) - diagonalDir * curve_smoothness, PathType.Bezier), + new PathControlPoint(new Vector2(curve_end, bar_verticality)), + new PathControlPoint(new Vector2(curve_end + curve_smoothness, bar_verticality), PathType.Linear), + new PathControlPoint(new Vector2(bar_length, bar_verticality)), + }); + + List vertices = new List(); + barPath.GetPathToProgress(vertices, 0.0, 1.0); + + background.Vertices = vertices; + mainBar.Vertices = vertices; + glowBar.Vertices = vertices; + + updatePathVertices(); + } + private void updatePathVertices() { barPath.GetPathToProgress(healthBarVertices, 0.0, healthBarValue); - barPath.GetPathToProgress(missBarVertices, healthBarValue, Math.Max(missBarValue, healthBarValue)); + barPath.GetPathToProgress(missBarVertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); if (healthBarVertices.Count == 0) healthBarVertices.Add(Vector2.Zero); @@ -255,11 +256,11 @@ namespace osu.Game.Screens.Play.HUD if (missBarVertices.Count == 0) missBarVertices.Add(Vector2.Zero); - missBar.Vertices = missBarVertices.Select(v => v - missBarVertices[0]).ToList(); - missBar.Position = missBarVertices[0]; + glowBar.Vertices = missBarVertices.Select(v => v - missBarVertices[0]).ToList(); + glowBar.Position = missBarVertices[0]; - healthBar.Vertices = healthBarVertices.Select(v => v - healthBarVertices[0]).ToList(); - healthBar.Position = healthBarVertices[0]; + mainBar.Vertices = healthBarVertices.Select(v => v - healthBarVertices[0]).ToList(); + mainBar.Position = healthBarVertices[0]; } private partial class BackgroundPath : SmoothPath @@ -267,10 +268,12 @@ namespace osu.Game.Screens.Play.HUD protected override Color4 ColourAt(float position) { if (position <= 0.128f) - return Color4.White.Opacity(0.5f); + return Color4.White.Opacity(0.8f); - position -= 0.128f; - return Interpolation.ValueAt(Math.Clamp(position, 0f, 1f), Color4.White.Opacity(0.5f), Color4.Black.Opacity(0.5f), -0.75f, 1f, Easing.OutQuart); + return Interpolation.ValueAt(position, + Color4.White.Opacity(0.8f), + Color4.Black.Opacity(0.2f), + -0.5f, 1f, Easing.OutQuint); } } diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index c064cdb040..ba948b516e 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -63,7 +63,7 @@ namespace osu.Game.Screens.Play.HUD { iconsContainer.Clear(); - foreach (Mod mod in mods.NewValue) + foreach (Mod mod in mods.NewValue.AsOrdered()) iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); appearTransform(); diff --git a/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs index 0b2ce10ac3..f9cf025b47 100644 --- a/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs @@ -68,7 +68,7 @@ namespace osu.Game.Screens.Play.HUD Spacing = new Vector2(0, -12 * iconScale); - foreach (Mod mod in current.Value) + foreach (Mod mod in current.Value.AsOrdered()) { Add(new ModIcon(mod) { diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 872425e3fd..4054e456b9 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -247,7 +247,7 @@ namespace osu.Game.Screens.Play contentIn(); - MetadataInfo.Delay(750).FadeIn(500); + MetadataInfo.Delay(750).FadeIn(500, Easing.OutQuint); // after an initial delay, start the debounced load check. // this will continue to execute even after resuming back on restart. @@ -420,7 +420,7 @@ namespace osu.Game.Screens.Play { MetadataInfo.Loading = true; - content.FadeInFromZero(400); + content.FadeInFromZero(500, Easing.OutQuint); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); settingsScroll.FadeInFromZero(500, Easing.Out) diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 34ee0ae4e8..9c2b1cfe37 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -525,7 +525,11 @@ namespace osu.Game.Screens.Select if (beatmapInfoNoDebounce == null) run(); else - selectionChangedDebounce = Scheduler.AddDelayed(run, 200); + { + // Intentionally slightly higher than repeat_tick_rate to avoid loading songs when holding left / right arrows. + // See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/InputManager.cs#L44 + selectionChangedDebounce = Scheduler.AddDelayed(run, 80); + } if (beatmap?.Equals(beatmapInfoPrevious) != true) { diff --git a/osu.Game/Skinning/IAnimationTimeReference.cs b/osu.Game/Skinning/IAnimationTimeReference.cs index b6a944ddf8..91f1171a72 100644 --- a/osu.Game/Skinning/IAnimationTimeReference.cs +++ b/osu.Game/Skinning/IAnimationTimeReference.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Textures; using osu.Framework.Timing; +using osuTK; namespace osu.Game.Skinning { @@ -13,7 +14,7 @@ namespace osu.Game.Skinning /// /// /// This should not be used to start an animation immediately at the current time. - /// To do so, use with startAtCurrentTime = true instead. + /// To do so, use with startAtCurrentTime = true instead. /// [Cached] public interface IAnimationTimeReference diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index 0d2461567f..dde6c1fa29 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osuTK; using static osu.Game.Skinning.SkinConfiguration; namespace osu.Game.Skinning @@ -18,16 +19,16 @@ namespace osu.Game.Skinning public static partial class LegacySkinExtensions { public static Drawable? GetAnimation(this ISkin? source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", - bool startAtCurrentTime = true, double? frameLength = null) - => source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength); + bool startAtCurrentTime = true, double? frameLength = null, Vector2? maxSize = null) + => source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength, maxSize); public static Drawable? GetAnimation(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false, - string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null) + string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null, Vector2? maxSize = null) { if (source == null) return null; - var textures = GetTextures(source, componentName, wrapModeS, wrapModeT, animatable, animationSeparator, out var retrievalSource); + var textures = GetTextures(source, componentName, wrapModeS, wrapModeT, animatable, animationSeparator, maxSize, out var retrievalSource); switch (textures.Length) { @@ -53,7 +54,7 @@ namespace osu.Game.Skinning } } - public static Texture[] GetTextures(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, string animationSeparator, out ISkin? retrievalSource) + public static Texture[] GetTextures(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, string animationSeparator, Vector2? maxSize, out ISkin? retrievalSource) { retrievalSource = null; @@ -80,6 +81,9 @@ namespace osu.Game.Skinning // if an animation was not allowed or not found, fall back to a sprite retrieval. var singleTexture = retrievalSource.GetTexture(componentName, wrapModeS, wrapModeT); + if (singleTexture != null && maxSize != null) + singleTexture = singleTexture.WithMaximumSize(maxSize.Value); + return singleTexture != null ? new[] { singleTexture } : Array.Empty(); @@ -88,11 +92,14 @@ namespace osu.Game.Skinning { for (int i = 0; true; i++) { - Texture? texture; + var texture = skin.GetTexture(getFrameName(i), wrapModeS, wrapModeT); - if ((texture = skin.GetTexture(getFrameName(i), wrapModeS, wrapModeT)) == null) + if (texture == null) break; + if (maxSize != null) + texture = texture.WithMaximumSize(maxSize.Value); + yield return texture; } } @@ -100,6 +107,16 @@ namespace osu.Game.Skinning string getFrameName(int frameIndex) => $"{componentName}{animationSeparator}{frameIndex}"; } + public static Texture WithMaximumSize(this Texture texture, Vector2 maxSize) + { + if (texture.DisplayWidth <= maxSize.X && texture.DisplayHeight <= maxSize.Y) + return texture; + + // use scale adjust property for downscaling the texture in order to meet the specified maximum dimensions. + texture.ScaleAdjust *= Math.Max(texture.DisplayWidth / maxSize.X, texture.DisplayHeight / maxSize.Y); + return texture; + } + public static bool HasFont(this ISkin source, LegacyFont font) { return source.GetTexture($"{source.GetFontPrefix(font)}-0") != null; diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index d6af52855b..7eb92126fa 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -13,6 +13,7 @@ namespace osu.Game.Skinning public sealed partial class LegacySpriteText : OsuSpriteText { private readonly LegacyFont font; + private readonly Vector2? maxSizePerGlyph; private LegacyGlyphStore glyphStore = null!; @@ -20,9 +21,11 @@ namespace osu.Game.Skinning protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x' }; - public LegacySpriteText(LegacyFont font) + public LegacySpriteText(LegacyFont font, Vector2? maxSizePerGlyph = null) { this.font = font; + this.maxSizePerGlyph = maxSizePerGlyph; + Shadow = false; UseFullGlyphHeight = false; } @@ -33,7 +36,7 @@ namespace osu.Game.Skinning Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: true); Spacing = new Vector2(-skin.GetFontOverlap(font), 0); - glyphStore = new LegacyGlyphStore(skin); + glyphStore = new LegacyGlyphStore(skin, maxSizePerGlyph); } protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); @@ -41,10 +44,12 @@ namespace osu.Game.Skinning private class LegacyGlyphStore : ITexturedGlyphLookupStore { private readonly ISkin skin; + private readonly Vector2? maxSize; - public LegacyGlyphStore(ISkin skin) + public LegacyGlyphStore(ISkin skin, Vector2? maxSize) { this.skin = skin; + this.maxSize = maxSize; } public ITexturedCharacterGlyph? Get(string fontName, char character) @@ -56,6 +61,9 @@ namespace osu.Game.Skinning if (texture == null) return null; + if (maxSize != null) + texture = texture.WithMaximumSize(maxSize.Value); + return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); } diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 659edc6f5f..ccf49d722f 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -105,7 +105,14 @@ namespace osu.Game.Skinning Debug.Assert(Configuration != null); } else - Configuration = new SkinConfiguration(); + { + Configuration = new SkinConfiguration + { + // generally won't be hit as we always write a `skin.ini` on import, but best be safe than sorry. + // see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298 + LegacyVersion = SkinConfiguration.LATEST_VERSION, + }; + } // skininfo files may be null for default skin. foreach (SkinComponentsContainerLookup.TargetArea skinnableTarget in Enum.GetValues()) @@ -197,7 +204,7 @@ namespace osu.Game.Skinning { // This fallback is important for user skins which use SkinnableSprites. case SkinnableSprite.SpriteComponentLookup sprite: - return this.GetAnimation(sprite.LookupName, false, false); + return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize); case SkinComponentsContainerLookup containerLookup: diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index f2103a45c4..3e948a8afb 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -39,7 +39,7 @@ namespace osu.Game.Skinning protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == @".osk"; - protected override SkinInfo CreateModel(ArchiveReader archive) => new SkinInfo { Name = archive.Name ?? @"No name" }; + protected override SkinInfo CreateModel(ArchiveReader archive, ImportParameters parameters) => new SkinInfo { Name = archive.Name ?? @"No name" }; private const string unknown_creator_string = @"Unknown"; @@ -118,7 +118,7 @@ namespace osu.Game.Skinning string nameLine = @$"Name: {item.Name}"; string authorLine = @$"Author: {item.Creator}"; - string[] newLines = + List newLines = new List { @"// The following content was automatically added by osu! during import, based on filename / folder metadata.", @"[General]", @@ -130,6 +130,10 @@ namespace osu.Game.Skinning if (existingFile == null) { + // skins without a skin.ini are supposed to import using the "latest version" spec. + // see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298 + newLines.Add($"Version: {SkinConfiguration.LATEST_VERSION}"); + // In the case a skin doesn't have a skin.ini yet, let's create one. writeNewSkinIni(); } diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 1d97566470..9effb483c4 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -34,8 +34,8 @@ namespace osu.Game.Skinning [Resolved] private ISkinSource source { get; set; } = null!; - public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling) - : base(new SpriteComponentLookup(textureName), confineMode) + public SkinnableSprite(string textureName, Vector2? maxSize = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(new SpriteComponentLookup(textureName, maxSize), confineMode) { SpriteName.Value = textureName; } @@ -56,10 +56,14 @@ namespace osu.Game.Skinning protected override Drawable CreateDefault(ISkinComponentLookup lookup) { - var texture = textures.Get(((SpriteComponentLookup)lookup).LookupName); + var spriteLookup = (SpriteComponentLookup)lookup; + var texture = textures.Get(spriteLookup.LookupName); if (texture == null) - return new SpriteNotFound(((SpriteComponentLookup)lookup).LookupName); + return new SpriteNotFound(spriteLookup.LookupName); + + if (spriteLookup.MaxSize != null) + texture = texture.WithMaximumSize(spriteLookup.MaxSize.Value); return new Sprite { Texture = texture }; } @@ -69,10 +73,12 @@ namespace osu.Game.Skinning internal class SpriteComponentLookup : ISkinComponentLookup { public string LookupName { get; set; } + public Vector2? MaxSize { get; set; } - public SpriteComponentLookup(string textureName) + public SpriteComponentLookup(string textureName, Vector2? maxSize = null) { LookupName = textureName; + MaxSize = maxSize; } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index c2a58d46ef..6c14ec243b 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -23,7 +23,7 @@ namespace osu.Game.Storyboards.Drawables { public partial class DrawableStoryboard : Container { - [Cached] + [Cached(typeof(Storyboard))] public Storyboard Storyboard { get; } /// diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index 054a50456b..cefd51b2aa 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -94,25 +94,19 @@ namespace osu.Game.Storyboards.Drawables [Resolved] private IBeatSyncProvider beatSyncProvider { get; set; } + [Resolved] + private TextureStore textureStore { get; set; } + [BackgroundDependencyLoader] - private void load(TextureStore textureStore, Storyboard storyboard) + private void load(Storyboard storyboard) { - int frameIndex = 0; - - Texture frameTexture = textureStore.Get(getFramePath(frameIndex)); - - if (frameTexture != null) + if (storyboard.UseSkinSprites) { - // sourcing from storyboard. - for (frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++) - AddFrame(textureStore.Get(getFramePath(frameIndex)), Animation.FrameDelay); - } - else if (storyboard.UseSkinSprites) - { - // fallback to skin if required. skin.SourceChanged += skinSourceChanged; skinSourceChanged(); } + else + addFramesFromStoryboardSource(); Animation.ApplyTransforms(this); } @@ -135,11 +129,28 @@ namespace osu.Game.Storyboards.Drawables // When reading from a skin, we match stables weird behaviour where `FrameCount` is ignored // and resources are retrieved until the end of the animation. - foreach (var texture in skin.GetTextures(Path.GetFileNameWithoutExtension(Animation.Path)!, default, default, true, string.Empty, out _)) - AddFrame(texture, Animation.FrameDelay); + var skinTextures = skin.GetTextures(Path.GetFileNameWithoutExtension(Animation.Path)!, default, default, true, string.Empty, null, out _); + + if (skinTextures.Length > 0) + { + foreach (var texture in skinTextures) + AddFrame(texture, Animation.FrameDelay); + } + else + { + addFramesFromStoryboardSource(); + } } - private string getFramePath(int i) => Animation.Path.Replace(".", $"{i}."); + private void addFramesFromStoryboardSource() + { + int frameIndex; + // sourcing from storyboard. + for (frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++) + AddFrame(textureStore.Get(getFramePath(frameIndex)), Animation.FrameDelay); + + string getFramePath(int i) => Animation.Path.Replace(".", $"{i}."); + } protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs index 38e7ff1c70..40842fe7ed 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -30,10 +31,12 @@ namespace osu.Game.Storyboards.Drawables InternalChild = ElementContainer = new LayerElementContainer(layer); } - protected partial class LayerElementContainer : LifetimeManagementContainer + public partial class LayerElementContainer : LifetimeManagementContainer { private readonly StoryboardLayer storyboardLayer; + public IEnumerable Elements => InternalChildren; + public LayerElementContainer(StoryboardLayer layer) { storyboardLayer = layer; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index 379de1a497..ec875219b6 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -74,6 +74,12 @@ namespace osu.Game.Storyboards.Drawables public override bool IsPresent => !float.IsNaN(DrawPosition.X) && !float.IsNaN(DrawPosition.Y) && base.IsPresent; + [Resolved] + private ISkinSource skin { get; set; } = null!; + + [Resolved] + private TextureStore textureStore { get; set; } = null!; + public DrawableStoryboardSprite(StoryboardSprite sprite) { Sprite = sprite; @@ -84,24 +90,28 @@ namespace osu.Game.Storyboards.Drawables LifetimeEnd = sprite.EndTimeForDisplay; } - [Resolved] - private ISkinSource skin { get; set; } = null!; - [BackgroundDependencyLoader] - private void load(TextureStore textureStore, Storyboard storyboard) + private void load(Storyboard storyboard) { - Texture = textureStore.Get(Sprite.Path); - - if (Texture == null && storyboard.UseSkinSprites) + if (storyboard.UseSkinSprites) { skin.SourceChanged += skinSourceChanged; skinSourceChanged(); } + else + Texture = textureStore.Get(Sprite.Path); Sprite.ApplyTransforms(this); } - private void skinSourceChanged() => Texture = skin.GetTexture(Sprite.Path); + private void skinSourceChanged() + { + Texture = skin.GetTexture(Sprite.Path) ?? textureStore.Get(Sprite.Path); + + // Setting texture will only update the size if it's zero. + // So let's force an explicit update. + Size = new Vector2(Texture?.DisplayWidth ?? 0, Texture?.DisplayHeight ?? 0); + } protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 1892855d3d..21342831b0 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -18,7 +18,7 @@ namespace osu.Game.Storyboards public BeatmapInfo BeatmapInfo = new BeatmapInfo(); /// - /// Whether the storyboard can fall back to skin sprites in case no matching storyboard sprites are found. + /// Whether the storyboard should prefer textures from the current skin before using local storyboard textures. /// public bool UseSkinSprites { get; set; } @@ -86,7 +86,7 @@ namespace osu.Game.Storyboards } } - public DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) => + public virtual DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) => new DrawableStoryboard(this, mods); private static readonly string[] image_extensions = { @".png", @".jpg" }; diff --git a/osu.Game/Storyboards/StoryboardVideo.cs b/osu.Game/Storyboards/StoryboardVideo.cs index 4652e45852..8c11e19a06 100644 --- a/osu.Game/Storyboards/StoryboardVideo.cs +++ b/osu.Game/Storyboards/StoryboardVideo.cs @@ -14,7 +14,7 @@ namespace osu.Game.Storyboards public double StartTime { get; } - public StoryboardVideo(string path, int offset) + public StoryboardVideo(string path, double offset) { Path = path; StartTime = offset; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 049d4275a4..0ee922e53a 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -37,7 +37,7 @@ - +