diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 65ac05261a..cbd0231fdb 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2022.417.0", + "version": "2022.607.0", "commands": [ "localisation" ] diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml index 8ca9f38234..442b97c473 100644 --- a/.github/workflows/sentry-release.yml +++ b/.github/workflows/sentry-release.yml @@ -23,4 +23,4 @@ jobs: SENTRY_URL: https://sentry.ppy.sh/ with: environment: production - version: ${{ github.ref }} + version: osu@${{ github.ref_name }} diff --git a/osu.Android.props b/osu.Android.props index c28085557e..aad8cf10d0 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,10 +52,10 @@ - + - + diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index 715614a201..a5bd126782 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -14,11 +14,11 @@ namespace osu.Game.Rulesets.Mania.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; - [TestCase(2.3449735700206298d, 151, "diffcalc-test")] + [TestCase(2.3449735700206298d, 242, "diffcalc-test")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(2.7879104989252959d, 151, "diffcalc-test")] + [TestCase(2.7879104989252959d, 242, "diffcalc-test")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new ManiaModDoubleTime()); diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index 5b7a460079..c35a3dcdc2 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty foreach (var v in base.ToDatabaseAttributes()) yield return v; - // Todo: osu!mania doesn't output MaxCombo attribute for some reason. + yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); yield return (ATTRIB_ID_SCORE_MULTIPLIER, ScoreMultiplier); @@ -39,6 +39,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty { base.FromDatabaseAttributes(values); + MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; ScoreMultiplier = values[ATTRIB_ID_SCORE_MULTIPLIER]; diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index b17aa7fc4d..88f51bf961 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -52,10 +52,18 @@ namespace osu.Game.Rulesets.Mania.Difficulty // This is done the way it is to introduce fractional differences in order to match osu-stable for the time being. GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate), ScoreMultiplier = getScoreMultiplier(mods), - MaxCombo = beatmap.HitObjects.Sum(h => h is HoldNote ? 2 : 1), + MaxCombo = beatmap.HitObjects.Sum(maxComboForObject) }; } + private static int maxComboForObject(HitObject hitObject) + { + if (hitObject is HoldNote hold) + return 1 + (int)((hold.EndTime - hold.StartTime) / 100); + + return 1; + } + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { var sortedObjects = beatmap.HitObjects.ToArray(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 23500f5da6..79ff222a89 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Mods if (positionInfo == positionInfos.First()) { - positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); + positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); } else diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index a904658a4c..fa095edafa 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -79,7 +79,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { Result = { BindTarget = SpinsPerMinute }, }, - ticks = new Container(), + ticks = new Container + { + RelativeSizeAxes = Axes.Both, + }, new AspectContainer { Anchor = Anchor.Centre, diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs index 726fbd3ea6..39239c8233 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs @@ -1,6 +1,8 @@ // 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.Graphics; + namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSpinnerTick : DrawableOsuHitObject @@ -10,13 +12,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected DrawableSpinner DrawableSpinner => (DrawableSpinner)ParentHitObject; public DrawableSpinnerTick() - : base(null) + : this(null) { } public DrawableSpinnerTick(SpinnerTick spinnerTick) : base(spinnerTick) { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; } protected override double MaximumJudgementOffset => DrawableSpinner.HitObject.Duration; diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index ddee4d3ebd..1a130e96b3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -69,8 +69,8 @@ namespace osu.Game.Rulesets.Osu.Objects double startTime = StartTime + (float)(i + 1) / totalSpins * Duration; AddNested(i < SpinsRequired - ? new SpinnerTick { StartTime = startTime, Position = Position } - : new SpinnerBonusTick { StartTime = startTime, Position = Position }); + ? new SpinnerTick { StartTime = startTime } + : new SpinnerBonusTick { StartTime = startTime }); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index 1e170036e4..a58f62736b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -78,7 +78,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } }); - if (!(source.FindProvider(s => s.GetTexture("spinner-top") != null) is DefaultLegacySkin)) + var topProvider = source.FindProvider(s => s.GetTexture("spinner-top") != null); + + if (topProvider is LegacySkinTransformer transformer && !(transformer.Skin is DefaultLegacySkin)) { AddInternal(ApproachCircle = new Sprite { diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index da73c2addb..266f7d1251 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -116,6 +116,7 @@ namespace osu.Game.Rulesets.Osu.Utils if (!(osuObject is Slider slider)) return; + // No need to update the head and tail circles, since slider handles that when the new slider path is set slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); @@ -137,6 +138,7 @@ namespace osu.Game.Rulesets.Osu.Utils if (!(osuObject is Slider slider)) return; + // No need to update the head and tail circles, since slider handles that when the new slider path is set slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); @@ -146,5 +148,41 @@ namespace osu.Game.Rulesets.Osu.Utils slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); } + + /// + /// Rotate a slider about its start position by the specified angle. + /// + /// The slider to be rotated. + /// The angle, measured in radians, to rotate the slider by. + public static void RotateSlider(Slider slider, float rotation) + { + void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position; + + // No need to update the head and tail circles, since slider handles that when the new slider path is set + slider.NestedHitObjects.OfType().ForEach(rotateNestedObject); + slider.NestedHitObjects.OfType().ForEach(rotateNestedObject); + + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); + foreach (var point in controlPoints) + point.Position = rotateVector(point.Position, rotation); + + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + } + + /// + /// Rotate a vector by the specified angle. + /// + /// The vector to be rotated. + /// The angle, measured in radians, to rotate the vector by. + /// The rotated vector. + private static Vector2 rotateVector(Vector2 vector, float rotation) + { + float angle = MathF.Atan2(vector.Y, vector.X) + rotation; + float length = vector.Length; + return new Vector2( + length * MathF.Cos(angle), + length * MathF.Sin(angle) + ); + } } } diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs index d1bc3b45df..a77d1f8b0f 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osuTK; @@ -37,15 +38,23 @@ namespace osu.Game.Rulesets.Osu.Utils foreach (OsuHitObject hitObject in hitObjects) { Vector2 relativePosition = hitObject.Position - previousPosition; - float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); + float absoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X); float relativeAngle = absoluteAngle - previousAngle; - positionInfos.Add(new ObjectPositionInfo(hitObject) + ObjectPositionInfo positionInfo; + positionInfos.Add(positionInfo = new ObjectPositionInfo(hitObject) { RelativeAngle = relativeAngle, DistanceFromPrevious = relativePosition.Length }); + if (hitObject is Slider slider) + { + float absoluteRotation = getSliderRotation(slider); + positionInfo.Rotation = absoluteRotation - absoluteAngle; + absoluteAngle = absoluteRotation; + } + previousPosition = hitObject.EndPosition; previousAngle = absoluteAngle; } @@ -70,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Utils if (hitObject is Spinner) { - previous = null; + previous = current; continue; } @@ -124,16 +133,23 @@ namespace osu.Game.Rulesets.Osu.Utils if (previous != null) { - Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre; - Vector2 relativePosition = previous.HitObject.Position - earliestPosition; - previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); + if (previous.HitObject is Slider s) + { + previousAbsoluteAngle = getSliderRotation(s); + } + else + { + Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre; + Vector2 relativePosition = previous.HitObject.Position - earliestPosition; + previousAbsoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X); + } } float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle; var posRelativeToPrev = new Vector2( - current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle), - current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle) + current.PositionInfo.DistanceFromPrevious * MathF.Cos(absoluteAngle), + current.PositionInfo.DistanceFromPrevious * MathF.Sin(absoluteAngle) ); Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre; @@ -141,6 +157,19 @@ namespace osu.Game.Rulesets.Osu.Utils posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev); current.PositionModified = lastEndPosition + posRelativeToPrev; + + if (!(current.HitObject is Slider slider)) + return; + + absoluteAngle = MathF.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X); + + Vector2 centreOfMassOriginal = calculateCentreOfMass(slider); + Vector2 centreOfMassModified = rotateVector(centreOfMassOriginal, current.PositionInfo.Rotation + absoluteAngle - getSliderRotation(slider)); + centreOfMassModified = RotateAwayFromEdge(current.PositionModified, centreOfMassModified); + + float relativeRotation = MathF.Atan2(centreOfMassModified.Y, centreOfMassModified.X) - MathF.Atan2(centreOfMassOriginal.Y, centreOfMassOriginal.X); + if (!Precision.AlmostEquals(relativeRotation, 0)) + RotateSlider(slider, relativeRotation); } /// @@ -172,13 +201,13 @@ namespace osu.Game.Rulesets.Osu.Utils var previousPosition = workingObject.PositionModified; // Clamp slider position to the placement area - // If the slider is larger than the playfield, force it to stay at the original position + // If the slider is larger than the playfield, at least make sure that the head circle is inside the playfield float newX = possibleMovementBounds.Width < 0 - ? workingObject.PositionOriginal.X + ? Math.Clamp(possibleMovementBounds.Left, 0, OsuPlayfield.BASE_SIZE.X) : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); float newY = possibleMovementBounds.Height < 0 - ? workingObject.PositionOriginal.Y + ? Math.Clamp(possibleMovementBounds.Top, 0, OsuPlayfield.BASE_SIZE.Y) : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); slider.Position = workingObject.PositionModified = new Vector2(newX, newY); @@ -287,6 +316,45 @@ namespace osu.Game.Rulesets.Osu.Utils ); } + /// + /// Estimate the centre of mass of a slider relative to its start position. + /// + /// The slider to process. + /// The centre of mass of the slider. + private static Vector2 calculateCentreOfMass(Slider slider) + { + const double sample_step = 50; + + // just sample the start and end positions if the slider is too short + if (slider.Distance <= sample_step) + { + return Vector2.Divide(slider.Path.PositionAt(1), 2); + } + + int count = 0; + Vector2 sum = Vector2.Zero; + double pathDistance = slider.Distance; + + for (double i = 0; i < pathDistance; i += sample_step) + { + sum += slider.Path.PositionAt(i / pathDistance); + count++; + } + + return sum / count; + } + + /// + /// Get the absolute rotation of a slider, defined as the angle from its start position to the end of its path. + /// + /// The slider to process. + /// The angle in radians. + private static float getSliderRotation(Slider slider) + { + var endPositionVector = slider.Path.PositionAt(1); + return MathF.Atan2(endPositionVector.Y, endPositionVector.X); + } + public class ObjectPositionInfo { /// @@ -309,6 +377,13 @@ namespace osu.Game.Rulesets.Osu.Utils /// public float DistanceFromPrevious { get; set; } + /// + /// The rotation of the hit object, relative to its jump angle. + /// For sliders, this is defined as the angle from the slider's start position to the end of its path, relative to its jump angle. + /// For hit circles and spinners, this property is ignored. + /// + public float Rotation { get; set; } + /// /// The hit object associated with this . /// diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 504b10e9bc..2dd332fc13 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Primitives; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Judgements; @@ -321,12 +322,14 @@ namespace osu.Game.Rulesets.Taiko.UI private class ProxyContainer : LifetimeManagementContainer { - public new MarginPadding Padding - { - set => base.Padding = value; - } - public void Add(Drawable proxy) => AddInternal(proxy); + + public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) + { + // DrawableHitObject disables masking. + // Hitobject content is proxied and unproxied based on hit status and the IsMaskedAway value could get stuck because of this. + return false; + } } } } diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index eaacc623c9..2bb6459f20 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Chat [SetUp] public void Setup() => Schedule(() => { - var container = new ChannelManagerContainer(); + var container = new ChannelManagerContainer(API); Child = container; channelManager = container.ChannelManager; }); @@ -145,11 +145,11 @@ namespace osu.Game.Tests.Chat private class ChannelManagerContainer : CompositeDrawable { [Cached] - public ChannelManager ChannelManager { get; } = new ChannelManager(); + public ChannelManager ChannelManager { get; } - public ChannelManagerContainer() + public ChannelManagerContainer(IAPIProvider apiProvider) { - InternalChild = ChannelManager; + InternalChild = ChannelManager = new ChannelManager(apiProvider); } } } diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 00276955aa..d4956e97e0 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -710,7 +710,7 @@ namespace osu.Game.Tests.Database var imported = await LoadOszIntoStore(importer, realm.Realm); - realm.Realm.Write(() => + await realm.Realm.WriteAsync(() => { foreach (var b in imported.Beatmaps) b.OnlineID = -1; diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index 9c307341bd..97be1dcfaa 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -59,30 +59,34 @@ namespace osu.Game.Tests.Gameplay scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new TestJudgement(HitResult.Great)) { Type = HitResult.Great }); Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(1_000_000)); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(1)); // No header shouldn't cause any change - scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame()); + scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame()); Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(1_000_000)); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(1)); // Reset with a miss instead. - scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame + scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { Header = new FrameHeader(0, 0, 0, new Dictionary { { HitResult.Miss, 1 } }, DateTimeOffset.Now) }); Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); // Reset with no judged hit. - scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame + scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { Header = new FrameHeader(0, 0, 0, new Dictionary(), DateTimeOffset.Now) }); Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); Assert.That(scoreProcessor.JudgedHits, Is.Zero); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); } private class TestJudgement : Judgement diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index d3cacaa88c..d68398236a 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -59,11 +59,13 @@ namespace osu.Game.Tests.Skins AddAssert("Check float parse lookup", () => requester.GetConfig("FloatTest")?.Value == 1.1f); } - [Test] - public void TestBoolLookup() + [TestCase("0", false)] + [TestCase("1", true)] + [TestCase("2", true)] // https://github.com/ppy/osu/issues/18579 + public void TestBoolLookup(string originalValue, bool expectedParsedValue) { - AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["BoolTest"] = "1"); - AddAssert("Check bool parse lookup", () => requester.GetConfig("BoolTest")?.Value == true); + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["BoolTest"] = originalValue); + AddAssert("Check bool parse lookup", () => requester.GetConfig("BoolTest")?.Value == expectedParsedValue); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTapButton.cs b/osu.Game.Tests/Visual/Editing/TestSceneTapButton.cs new file mode 100644 index 0000000000..d8141619ab --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTapButton.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Timing; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneTapButton : OsuManualInputManagerTestScene + { + private TapButton tapButton; + + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Test] + public void TestBasic() + { + AddStep("create button", () => + { + Child = tapButton = new TapButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(4), + }; + }); + + bool pressed = false; + + AddRepeatStep("Press button", () => + { + InputManager.MoveMouseTo(tapButton); + if (!pressed) + InputManager.PressButton(MouseButton.Left); + else + InputManager.ReleaseButton(MouseButton.Left); + + pressed = !pressed; + }, 100); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs index 46b45979ea..a1218aa3e7 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs @@ -11,7 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Timing; @@ -77,28 +77,6 @@ namespace osu.Game.Tests.Visual.Editing timingInfo.Text = $"offset: {selectedGroup.Value.Time:N2} bpm: {selectedGroup.Value.ControlPoints.OfType().First().BPM:N2}"; } - [Test] - public void TestTapThenReset() - { - AddStep("click tap button", () => - { - control.ChildrenOfType() - .Last() - .TriggerClick(); - }); - - AddUntilStep("wait for track playing", () => Clock.IsRunning); - - AddStep("click reset button", () => - { - control.ChildrenOfType() - .First() - .TriggerClick(); - }); - - AddUntilStep("wait for track stopped", () => !Clock.IsRunning); - } - [Test] public void TestBasic() { @@ -109,7 +87,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("click tap button", () => { - control.ChildrenOfType() + control.ChildrenOfType() .Last() .TriggerClick(); }); @@ -123,6 +101,28 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestTapThenReset() + { + AddStep("click tap button", () => + { + control.ChildrenOfType() + .Last() + .TriggerClick(); + }); + + AddUntilStep("wait for track playing", () => Clock.IsRunning); + + AddStep("click reset button", () => + { + control.ChildrenOfType() + .First() + .TriggerClick(); + }); + + AddUntilStep("wait for track stopped", () => !Clock.IsRunning); + } + protected override void Dispose(bool isDisposing) { Beatmap.Disabled = false; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs new file mode 100644 index 0000000000..d726bd004e --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Utils; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneTimelineZoom : TimelineTestScene + { + public override Drawable CreateTestComponent() => Empty(); + + [Test] + public void TestVisibleRangeUpdatesOnZoomChange() + { + double initialVisibleRange = 0; + + AddStep("reset zoom", () => TimelineArea.Timeline.Zoom = 100); + AddStep("get initial range", () => initialVisibleRange = TimelineArea.Timeline.VisibleRange); + + AddStep("scale zoom", () => TimelineArea.Timeline.Zoom = 200); + AddAssert("range halved", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange / 2, 1)); + AddStep("descale zoom", () => TimelineArea.Timeline.Zoom = 50); + AddAssert("range doubled", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange * 2, 1)); + + AddStep("restore zoom", () => TimelineArea.Timeline.Zoom = 100); + AddAssert("range restored", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange, 1)); + } + + [Test] + public void TestVisibleRangeConstantOnSizeChange() + { + double initialVisibleRange = 0; + + AddStep("reset timeline size", () => TimelineArea.Timeline.Width = 1); + AddStep("get initial range", () => initialVisibleRange = TimelineArea.Timeline.VisibleRange); + + AddStep("scale timeline size", () => TimelineArea.Timeline.Width = 2); + AddAssert("same range", () => TimelineArea.Timeline.VisibleRange == initialVisibleRange); + AddStep("descale timeline size", () => TimelineArea.Timeline.Width = 0.5f); + AddAssert("same range", () => TimelineArea.Timeline.VisibleRange == initialVisibleRange); + + AddStep("restore timeline size", () => TimelineArea.Timeline.Width = 1); + AddAssert("same range", () => TimelineArea.Timeline.VisibleRange == initialVisibleRange); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index 17b8189fc7..a358166477 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -1,14 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Timing; +using osu.Game.Screens.Edit.Timing.RowAttributes; +using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { @@ -22,6 +26,8 @@ namespace osu.Game.Tests.Visual.Editing [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + private TimingScreen timingScreen; + protected override bool ScrollUsingMouseWheel => false; public TestSceneTimingScreen() @@ -36,12 +42,54 @@ namespace osu.Game.Tests.Visual.Editing Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); Beatmap.Disabled = true; - Child = new TimingScreen + Child = timingScreen = new TimingScreen { State = { Value = Visibility.Visible }, }; } + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Stop clock", () => Clock.Stop()); + + AddUntilStep("wait for rows to load", () => Child.ChildrenOfType().Any()); + } + + [Test] + public void TestTrackingCurrentTimeWhileRunning() + { + AddStep("Select first effect point", () => + { + InputManager.MoveMouseTo(Child.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); + AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + + AddStep("Seek to just before next point", () => Clock.Seek(69000)); + AddStep("Start clock", () => Clock.Start()); + + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); + } + + [Test] + public void TestTrackingCurrentTimeWhilePaused() + { + AddStep("Select first effect point", () => + { + InputManager.MoveMouseTo(Child.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); + AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + + AddStep("Seek to later", () => Clock.Seek(80000)); + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); + } + protected override void Dispose(bool isDisposing) { Beatmap.Disabled = false; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index 95d11d6909..2d056bafdd 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -44,7 +44,12 @@ namespace osu.Game.Tests.Visual.Editing RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(30) }, - scrollContainer = new ZoomableScrollContainer { RelativeSizeAxes = Axes.Both } + scrollContainer = new ZoomableScrollContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + } } }, new MenuCursor() @@ -62,7 +67,15 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestWidthInitialization() { - AddAssert("Inner container width was initialized", () => innerBox.DrawWidth > 0); + AddAssert("Inner container width was initialized", () => innerBox.DrawWidth == scrollContainer.DrawWidth); + } + + [Test] + public void TestWidthUpdatesOnDrawSizeChanges() + { + AddStep("Shrink scroll container", () => scrollContainer.Width = 0.5f); + AddAssert("Scroll container width shrunk", () => scrollContainer.DrawWidth == scrollContainer.Parent.DrawWidth / 2); + AddAssert("Inner container width matches scroll container", () => innerBox.DrawWidth == scrollContainer.DrawWidth); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 4e6342868a..a738debecc 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -18,7 +18,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; -using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; @@ -27,7 +26,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public abstract class MultiplayerGameplayLeaderboardTestScene : OsuTestScene { - private const int total_users = 16; + protected const int TOTAL_USERS = 16; protected readonly BindableList MultiplayerUsers = new BindableList(); @@ -35,9 +34,10 @@ namespace osu.Game.Tests.Visual.Multiplayer protected virtual MultiplayerRoomUser CreateUser(int userId) => new MultiplayerRoomUser(userId); - protected abstract MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor); + protected abstract MultiplayerGameplayLeaderboard CreateLeaderboard(); private readonly BindableList multiplayerUserIds = new BindableList(); + private readonly BindableDictionary watchedUserStates = new BindableDictionary(); private OsuConfigManager config; @@ -81,6 +81,9 @@ namespace osu.Game.Tests.Visual.Multiplayer multiplayerClient.SetupGet(c => c.CurrentMatchPlayingUserIds) .Returns(() => multiplayerUserIds); + + spectatorClient.SetupGet(c => c.WatchedUserStates) + .Returns(() => watchedUserStates); } [SetUpSteps] @@ -100,8 +103,26 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("populate users", () => { MultiplayerUsers.Clear(); - for (int i = 0; i < total_users; i++) - MultiplayerUsers.Add(CreateUser(i)); + + for (int i = 0; i < TOTAL_USERS; i++) + { + var user = CreateUser(i); + + MultiplayerUsers.Add(user); + + watchedUserStates[i] = new SpectatorState + { + BeatmapID = 0, + RulesetID = 0, + Mods = user.Mods, + MaximumScoringValues = new ScoringValues + { + BaseScore = 10000, + MaxCombo = 1000, + CountBasicHitObjects = 1000 + } + }; + } }); AddStep("create leaderboard", () => @@ -109,13 +130,8 @@ namespace osu.Game.Tests.Visual.Multiplayer Leaderboard?.Expire(); Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - OsuScoreProcessor scoreProcessor = new OsuScoreProcessor(); - scoreProcessor.ApplyBeatmap(playableBeatmap); - Child = scoreProcessor; - - LoadComponentAsync(Leaderboard = CreateLeaderboard(scoreProcessor), Add); + LoadComponentAsync(Leaderboard = CreateLeaderboard(), Add); }); AddUntilStep("wait for load", () => Leaderboard.IsLoaded); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index f57a54d84c..60215dc8b3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -4,11 +4,11 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play.HUD; @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("reset", () => { - Clear(); + leaderboard?.RemoveAndDisposeImmediately(); clocks = new Dictionary { @@ -32,21 +32,18 @@ namespace osu.Game.Tests.Visual.Multiplayer { PLAYER_2_ID, new ManualClock() } }; - foreach ((int userId, var _) in clocks) + foreach ((int userId, _) in clocks) { SpectatorClient.SendStartPlay(userId, 0); - OnlinePlayDependencies.MultiplayerClient.AddUser(new APIUser { Id = userId }); + OnlinePlayDependencies.MultiplayerClient.AddUser(new APIUser { Id = userId }, true); } }); AddStep("create leaderboard", () => { Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - var scoreProcessor = new OsuScoreProcessor(); - scoreProcessor.ApplyBeatmap(playable); - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(Ruleset.Value, scoreProcessor, clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) { Expanded = { Value = true } }, Add); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index 6e4aa48b0e..cbbd535cee 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -1,22 +1,58 @@ // 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.Game.Rulesets.Osu.Scoring; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiplayerGameplayLeaderboard : MultiplayerGameplayLeaderboardTestScene { - protected override MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor) + protected override MultiplayerRoomUser CreateUser(int userId) { - return new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, MultiplayerUsers.ToArray()) + var user = base.CreateUser(userId); + + if (userId == TOTAL_USERS - 1) + user.Mods = new[] { new APIMod(new OsuModNoFail()) }; + + return user; + } + + protected override MultiplayerGameplayLeaderboard CreateLeaderboard() + { + return new TestLeaderboard(MultiplayerUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, }; } + + [Test] + public void TestPerUserMods() + { + AddStep("first user has no mods", () => Assert.That(((TestLeaderboard)Leaderboard).UserMods[0], Is.Empty)); + AddStep("last user has NF mod", () => + { + Assert.That(((TestLeaderboard)Leaderboard).UserMods[TOTAL_USERS - 1], Has.One.Items); + Assert.That(((TestLeaderboard)Leaderboard).UserMods[TOTAL_USERS - 1].Single(), Is.TypeOf()); + }); + } + + private class TestLeaderboard : MultiplayerGameplayLeaderboard + { + public Dictionary> UserMods => UserScores.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ScoreProcessor.Mods); + + public TestLeaderboard(MultiplayerRoomUser[] users) + : base(users) + { + } + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index 5caab9487e..c25884039f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -5,7 +5,6 @@ using System.Linq; using osu.Framework.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; -using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play.HUD; @@ -25,8 +24,8 @@ namespace osu.Game.Tests.Visual.Multiplayer return user; } - protected override MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor) => - new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, MultiplayerUsers.ToArray()) + protected override MultiplayerGameplayLeaderboard CreateLeaderboard() => + new MultiplayerGameplayLeaderboard(MultiplayerUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index c1f5f110d1..51bb27f93e 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -609,8 +609,6 @@ namespace osu.Game.Tests.Visual.Navigation public ModSelectOverlay ModSelectOverlay => ModSelect; public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; - - protected override bool DisplayStableImportPrompt => false; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index a28de3be1e..4d227af2cb 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.Online { linkColour = colours.Blue; - var chatManager = new ChannelManager(); + var chatManager = new ChannelManager(API); BindableList availableChannels = (BindableList)chatManager.AvailableChannels; availableChannels.Add(new Channel { Name = "#english" }); availableChannels.Add(new Channel { Name = "#japanese" }); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 4edbb9f215..e3792c0780 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Online RelativeSizeAxes = Axes.Both, CachedDependencies = new (Type, object)[] { - (typeof(ChannelManager), channelManager = new ChannelManager()), + (typeof(ChannelManager), channelManager = new ChannelManager(API)), }, Children = new Drawable[] { @@ -469,6 +469,8 @@ namespace osu.Game.Tests.Visual.Online chatOverlay.Show(); }); + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + waitForChannel1Visible(); AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext)); waitForChannel2Visible(); @@ -570,15 +572,15 @@ namespace osu.Game.Tests.Visual.Online public SlowLoadingDrawableChannel GetSlowLoadingChannel(Channel channel) => DrawableChannels.OfType().Single(c => c.Channel == channel); - protected override ChatOverlayDrawableChannel CreateDrawableChannel(Channel newChannel) + protected override DrawableChannel CreateDrawableChannel(Channel newChannel) { return SlowLoading ? new SlowLoadingDrawableChannel(newChannel) - : new ChatOverlayDrawableChannel(newChannel); + : new DrawableChannel(newChannel); } } - private class SlowLoadingDrawableChannel : ChatOverlayDrawableChannel + private class SlowLoadingDrawableChannel : DrawableChannel { public readonly ManualResetEventSlim LoadEvent = new ManualResetEventSlim(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs index 79f62a16e3..c7ca3b4457 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -43,7 +44,7 @@ namespace osu.Game.Tests.Visual.Online Schedule(() => { - Child = testContainer = new TestContainer(new[] { publicChannel, privateMessageChannel }) + Child = testContainer = new TestContainer(API, new[] { publicChannel, privateMessageChannel }) { RelativeSizeAxes = Axes.Both, }; @@ -178,6 +179,36 @@ namespace osu.Game.Tests.Visual.Online AddAssert("1 notification fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 1); } + /// + /// Ensures that handles channels which have not been or could not be resolved (i.e. = 0). + /// + [Test] + public void TestSendInUnresolvedChannel() + { + int i = 1; + Channel unresolved = null; + + AddRepeatStep("join unresolved channels", () => testContainer.ChannelManager.JoinChannel(unresolved = new Channel(new APIUser + { + Id = 100 + i, + Username = $"Foreign #{i++}", + })), 5); + + AddStep("send message in unresolved channel", () => + { + Debug.Assert(unresolved.Id == 0); + + unresolved.AddLocalEcho(new LocalEchoMessage + { + Sender = API.LocalUser.Value, + ChannelId = unresolved.Id, + Content = "Some message", + }); + }); + + AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0); + } + private void receiveMessage(APIUser sender, Channel channel, string content) => channel.AddNewMessages(createMessage(sender, channel, content)); private Message createMessage(APIUser sender, Channel channel, string content) => new Message(messageIdCounter++) @@ -198,7 +229,7 @@ namespace osu.Game.Tests.Visual.Online private class TestContainer : Container { [Cached] - public ChannelManager ChannelManager { get; } = new ChannelManager(); + public ChannelManager ChannelManager { get; } [Cached(typeof(INotificationOverlay))] public NotificationOverlay NotificationOverlay { get; } = new NotificationOverlay @@ -214,9 +245,10 @@ namespace osu.Game.Tests.Visual.Online private readonly Channel[] channels; - public TestContainer(Channel[] channels) + public TestContainer(IAPIProvider api, Channel[] channels) { this.channels = channels; + ChannelManager = new ChannelManager(api); } [BackgroundDependencyLoader] diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 860ef5d565..8d5eebd31f 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -11,6 +11,7 @@ using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Chat; using osuTK.Input; @@ -44,17 +45,22 @@ namespace osu.Game.Tests.Visual.Online Id = 5, }; - [Cached] - private ChannelManager channelManager = new ChannelManager(); + private ChannelManager channelManager; private TestStandAloneChatDisplay chatDisplay; private int messageIdSequence; private Channel testChannel; - public TestSceneStandAloneChatDisplay() + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - Add(channelManager); + Add(channelManager = new ChannelManager(parent.Get())); + + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.Cache(channelManager); + + return dependencies; } [SetUp] @@ -128,11 +134,11 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Ensure no adjacent day separators", () => { - var indices = chatDisplay.FillFlow.OfType().Select(ds => chatDisplay.FillFlow.IndexOf(ds)); + var indices = chatDisplay.FillFlow.OfType().Select(ds => chatDisplay.FillFlow.IndexOf(ds)); foreach (int i in indices) { - if (i < chatDisplay.FillFlow.Count && chatDisplay.FillFlow[i + 1] is DrawableChannel.DaySeparator) + if (i < chatDisplay.FillFlow.Count && chatDisplay.FillFlow[i + 1] is DaySeparator) return false; } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index f5fe00458a..c532e8bc05 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -173,6 +173,8 @@ namespace osu.Game.Tests.Visual.Playlists { AddUntilStep("wait for scores loaded", () => requestComplete + // request handler may need to fire more than once to get scores. + && totalCount > 0 && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount && resultsScreen.ScorePanelList.AllPanelsVisible); AddWaitStep("wait for display", 5); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 8b646df362..2a31728f87 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -96,6 +96,7 @@ namespace osu.Game.Tests.Visual.Ranking beatmap.Metadata.Author = author; beatmap.Metadata.Title = "Verrrrrrrrrrrrrrrrrrry looooooooooooooooooooooooong beatmap title"; beatmap.Metadata.Artist = "Verrrrrrrrrrrrrrrrrrry looooooooooooooooooooooooong beatmap artist"; + beatmap.DifficultyName = "Verrrrrrrrrrrrrrrrrrry looooooooooooooooooooooooong difficulty name"; return beatmap; } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs index 83265e13ad..3e679a7905 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; @@ -83,7 +84,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep("clear label", () => textBox.LabelText = default); AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); - AddStep("set warning text", () => textBox.WarningText = "This is some very important warning text! Hopefully it doesn't break the alignment of the default value indicator..."); + AddStep("set warning text", () => textBox.SetNoticeText("This is some very important warning text! Hopefully it doesn't break the alignment of the default value indicator...", true)); AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); } @@ -129,16 +130,18 @@ namespace osu.Game.Tests.Visual.Settings SettingsNumberBox numberBox = null; AddStep("create settings item", () => Child = numberBox = new SettingsNumberBox()); - AddAssert("warning text not created", () => !numberBox.ChildrenOfType().Any()); + AddAssert("warning text not created", () => !numberBox.ChildrenOfType().Any()); - AddStep("set warning text", () => numberBox.WarningText = "this is a warning!"); - AddAssert("warning text created", () => numberBox.ChildrenOfType().Single().Alpha == 1); + AddStep("set warning text", () => numberBox.SetNoticeText("this is a warning!", true)); + AddAssert("warning text created", () => numberBox.ChildrenOfType().Single().Alpha == 1); - AddStep("unset warning text", () => numberBox.WarningText = default); - AddAssert("warning text hidden", () => numberBox.ChildrenOfType().Single().Alpha == 0); + AddStep("unset warning text", () => numberBox.ClearNoticeText()); + AddAssert("warning text hidden", () => !numberBox.ChildrenOfType().Any()); - AddStep("set warning text again", () => numberBox.WarningText = "another warning!"); - AddAssert("warning text shown again", () => numberBox.ChildrenOfType().Single().Alpha == 1); + AddStep("set warning text again", () => numberBox.SetNoticeText("another warning!", true)); + AddAssert("warning text shown again", () => numberBox.ChildrenOfType().Single().Alpha == 1); + + AddStep("set non warning text", () => numberBox.SetNoticeText("you did good!")); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index 940d001c5b..a78a8aa028 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -5,12 +5,11 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Extensions; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; @@ -23,38 +22,28 @@ namespace osu.Game.Tests.Visual.SongSelect { public class TestSceneBeatmapRecommendations : OsuGameTestScene { + [Resolved] + private IRulesetStore rulesetStore { get; set; } + [SetUpSteps] public override void SetUpSteps() { - AddStep("register request handling", () => - { - ((DummyAPIAccess)API).HandleRequest = req => - { - switch (req) - { - case GetUserRequest userRequest: - userRequest.TriggerSuccess(getUser(userRequest.Ruleset.OnlineID)); - return true; - } - - return false; - }; - }); - base.SetUpSteps(); - APIUser getUser(int? rulesetID) + AddStep("populate ruleset statistics", () => { - return new APIUser + Dictionary rulesetStatistics = new Dictionary(); + + rulesetStore.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo => { - Username = @"Dummy", - Id = 1001, - Statistics = new UserStatistics + rulesetStatistics[rulesetInfo.ShortName] = new UserStatistics { - PP = getNecessaryPP(rulesetID) - } - }; - } + PP = getNecessaryPP(rulesetInfo.OnlineID) + }; + }); + + API.LocalUser.Value.RulesetsStatistics = rulesetStatistics; + }); decimal getNecessaryPP(int? rulesetID) { diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index aad7f6b301..77f5bd83d6 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -18,6 +18,7 @@ using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; @@ -80,6 +81,37 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("delete all beatmaps", () => manager?.Delete()); } + [Test] + public void TestPlaceholderBeatmapPresence() + { + createSongSelect(); + + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + + addRulesetImportStep(0); + AddUntilStep("wait for placeholder hidden", () => getPlaceholder()?.State.Value == Visibility.Hidden); + + AddStep("delete all beatmaps", () => manager?.Delete()); + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + } + + [Test] + public void TestPlaceholderConvertSetting() + { + changeRuleset(2); + addRulesetImportStep(0); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + createSongSelect(); + + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + + AddStep("click link in placeholder", () => getPlaceholder().ChildrenOfType().First().TriggerClick()); + + AddUntilStep("convert setting changed", () => config.Get(OsuSetting.ShowConvertedBeatmaps)); + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Hidden); + } + [Test] public void TestSingleFilterOnEnter() { @@ -941,6 +973,8 @@ namespace osu.Game.Tests.Visual.SongSelect private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.IndexOf(info); + private NoResultsPlaceholder getPlaceholder() => songSelect.ChildrenOfType().FirstOrDefault(); + private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmapInfo); private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon) diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index a5ead6c2f0..c30250c86a 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -5,6 +5,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; using osu.Game.Tournament.IPC; @@ -29,7 +30,7 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader(true)] - private void load(MatchIPCInfo ipc) + private void load(MatchIPCInfo ipc, IAPIProvider api) { if (ipc != null) { @@ -45,7 +46,7 @@ namespace osu.Game.Tournament.Components if (manager == null) { - AddInternal(manager = new ChannelManager { HighPollRate = { Value = true } }); + AddInternal(manager = new ChannelManager(api) { HighPollRate = { Value = true } }); Channel.BindTo(manager.CurrentChannel); } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 5f7de0d762..dba457c81c 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -319,6 +319,15 @@ namespace osu.Game.Beatmaps }); } + public void DeleteAllVideos() + { + realm.Write(r => + { + var items = r.All().Where(s => !s.DeletePending && !s.Protected); + beatmapModelManager.DeleteVideos(items.ToList()); + }); + } + public void UndeleteAll() { realm.Run(r => beatmapModelManager.Undelete(r.All().Where(s => s.DeletePending).ToList())); diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs index 4c680bbcc9..277047348e 100644 --- a/osu.Game/Beatmaps/BeatmapModelManager.cs +++ b/osu.Game/Beatmaps/BeatmapModelManager.cs @@ -16,6 +16,7 @@ using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Skinning; using osu.Game.Stores; +using osu.Game.Overlays.Notifications; #nullable enable @@ -33,6 +34,8 @@ namespace osu.Game.Beatmaps protected override string[] HashableFileTypes => new[] { ".osu" }; + public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv" }; + public BeatmapModelManager(RealmAccess realm, Storage storage, BeatmapOnlineLookupQueue? onlineLookupQueue = null) : base(realm, storage, onlineLookupQueue) { @@ -114,5 +117,50 @@ namespace osu.Game.Beatmaps item.CopyChangesToRealm(existing); }); } + + /// + /// Delete videos from a list of beatmaps. + /// This will post notifications tracking progress. + /// + public void DeleteVideos(List items, bool silent = false) + { + if (items.Count == 0) return; + + var notification = new ProgressNotification + { + Progress = 0, + Text = $"Preparing to delete all {HumanisedModelName} videos...", + CompletionText = "No videos found to delete!", + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + int deleted = 0; + + foreach (var b in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + var video = b.Files.FirstOrDefault(f => VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.Ordinal))); + + if (video != null) + { + DeleteFile(b, video); + deleted++; + notification.CompletionText = $"Deleted {deleted} {HumanisedModelName} video(s)!"; + } + + notification.Text = $"Deleting videos from {HumanisedModelName}s ({deleted} deleted)"; + + notification.Progress = (float)++i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } } } diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index 93c2fccbc7..4629b20569 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -7,11 +7,8 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Game.Extensions; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Rulesets; namespace osu.Game.Beatmaps @@ -25,26 +22,15 @@ namespace osu.Game.Beatmaps [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private IRulesetStore rulesets { get; set; } - [Resolved] private Bindable ruleset { get; set; } - /// - /// The user for which the last requests were run. - /// - private int? requestedUserId; - - private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); - - private readonly IBindable apiState = new Bindable(); + private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); [BackgroundDependencyLoader] private void load() { - apiState.BindTo(api.State); - apiState.BindValueChanged(onlineStateChanged, true); + api.LocalUser.BindValueChanged(_ => populateValues(), true); } /// @@ -58,12 +44,12 @@ namespace osu.Game.Beatmaps [CanBeNull] public BeatmapInfo GetRecommendedBeatmap(IEnumerable beatmaps) { - foreach (var r in orderedRulesets) + foreach (string r in orderedRulesets) { if (!recommendedDifficultyMapping.TryGetValue(r, out double recommendation)) continue; - BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.Equals(r)).OrderBy(b => + BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r)).OrderBy(b => { double difference = b.StarRating - recommendation; return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder @@ -76,55 +62,35 @@ namespace osu.Game.Beatmaps return null; } - private void fetchRecommendedValues() + private void populateValues() { - if (recommendedDifficultyMapping.Count > 0 && api.LocalUser.Value.Id == requestedUserId) + if (api.LocalUser.Value.RulesetsStatistics == null) return; - requestedUserId = api.LocalUser.Value.Id; - - // only query API for built-in rulesets - rulesets.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo => + foreach (var kvp in api.LocalUser.Value.RulesetsStatistics) { - var req = new GetUserRequest(api.LocalUser.Value.Id, rulesetInfo); - - req.Success += result => - { - // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 - recommendedDifficultyMapping[rulesetInfo] = Math.Pow((double)(result.Statistics.PP ?? 0), 0.4) * 0.195; - }; - - api.Queue(req); - }); + // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 + recommendedDifficultyMapping[kvp.Key] = Math.Pow((double)(kvp.Value.PP ?? 0), 0.4) * 0.195; + } } /// /// Rulesets ordered descending by their respective recommended difficulties. /// The currently selected ruleset will always be first. /// - private IEnumerable orderedRulesets + private IEnumerable orderedRulesets { get { if (LoadState < LoadState.Ready || ruleset.Value == null) - return Enumerable.Empty(); + return Enumerable.Empty(); return recommendedDifficultyMapping .OrderByDescending(pair => pair.Value) .Select(pair => pair.Key) - .Where(r => !r.Equals(ruleset.Value)) - .Prepend(ruleset.Value); + .Where(r => !r.Equals(ruleset.Value.ShortName)) + .Prepend(ruleset.Value.ShortName); } } - - private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => - { - switch (state.NewValue) - { - case APIState.Online: - fetchRecommendedValues(); - break; - } - }); } } diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index 84903d381a..5ebdee0b09 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -85,6 +85,8 @@ namespace osu.Game.Beatmaps.Drawables downloadTrackers.Add(beatmapDownloadTracker); AddInternal(beatmapDownloadTracker); + // Note that this is downloading the beatmaps even if they are already downloaded. + // We could rely more on `BeatmapDownloadTracker`'s exposed state to avoid this. beatmapDownloader.Download(beatmapSet); } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs index 9a17d6dc9b..08aaa8da42 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs @@ -35,7 +35,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards protected readonly BeatmapDownloadTracker DownloadTracker; protected BeatmapCard(APIBeatmapSet beatmapSet, bool allowExpansion = true) - : base(HoverSampleSet.Submit) + : base(HoverSampleSet.Button) { Expanded = new BindableBool { Disabled = !allowExpansion }; diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs index 58c1ebee0f..7826d64296 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs @@ -6,7 +6,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps.Drawables.Cards.Statistics; using osu.Game.Graphics; @@ -245,10 +244,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards }); if (BeatmapSet.HasVideo) - leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) }); + leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(20) }); if (BeatmapSet.HasStoryboard) - leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) }); + leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(20) }); if (BeatmapSet.FeaturedInSpotlight) { diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs index 3d7e81de21..c1ba521925 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps.Drawables.Cards.Statistics; using osu.Game.Graphics; @@ -226,10 +225,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards }); if (BeatmapSet.HasVideo) - leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) }); + leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(20) }); if (BeatmapSet.HasStoryboard) - leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) }); + leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(20) }); if (BeatmapSet.FeaturedInSpotlight) { diff --git a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs index c55e9c0a28..1b2c5d3ffc 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs @@ -3,14 +3,16 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osuTK; using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables.Cards { - public class IconPill : CircularContainer + public abstract class IconPill : CircularContainer, IHasTooltip { public Vector2 IconSize { @@ -20,7 +22,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards private readonly Container iconContainer; - public IconPill(IconUsage icon) + protected IconPill(IconUsage icon) { AutoSizeAxes = Axes.Both; Masking = true; @@ -47,5 +49,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards }, }; } + + public abstract LocalisableString TooltipText { get; } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/StoryboardIconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/StoryboardIconPill.cs new file mode 100644 index 0000000000..2ebf9107f5 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/StoryboardIconPill.cs @@ -0,0 +1,19 @@ +// 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.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class StoryboardIconPill : IconPill + { + public StoryboardIconPill() + : base(FontAwesome.Solid.Image) + { + } + + public override LocalisableString TooltipText => BeatmapsetsStrings.ShowInfoStoryboard; + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/VideoIconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/VideoIconPill.cs new file mode 100644 index 0000000000..b81e18b0dd --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/VideoIconPill.cs @@ -0,0 +1,19 @@ +// 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.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class VideoIconPill : IconPill + { + public VideoIconPill() + : base(FontAwesome.Solid.Film) + { + } + + public override LocalisableString TooltipText => BeatmapsetsStrings.ShowInfoVideo; + } +} diff --git a/osu.Game/Database/OnlineLookupCache.cs b/osu.Game/Database/OnlineLookupCache.cs index 2f98aef58a..506103a2c0 100644 --- a/osu.Game/Database/OnlineLookupCache.cs +++ b/osu.Game/Database/OnlineLookupCache.cs @@ -91,7 +91,7 @@ namespace osu.Game.Database } } - private void performLookup() + private async Task performLookup() { // contains at most 50 unique IDs from tasks, which is used to perform the lookup. var nextTaskBatch = new Dictionary>>(); @@ -127,7 +127,7 @@ namespace osu.Game.Database // rather than queueing, we maintain our own single-threaded request stream. // todo: we probably want retry logic here. - api.Perform(request); + await api.PerformAsync(request).ConfigureAwait(false); finishPendingTask(); diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index dbd3b96763..086ec52d80 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -392,7 +392,7 @@ namespace osu.Game.Database { total_writes_async.Value++; using (var realm = getRealmInstance()) - await realm.WriteAsync(action); + await realm.WriteAsync(() => action(realm)); } /// diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 11bfd80ec1..5c6e315225 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -21,7 +21,7 @@ namespace osu.Game.Graphics.Containers /// public class ScalingContainer : Container { - private const float duration = 500; + internal const float TRANSITION_DURATION = 500; private Bindable sizeX; private Bindable sizeY; @@ -99,7 +99,7 @@ namespace osu.Game.Graphics.Containers if (applyUIScale) { uiScale = osuConfig.GetBindable(OsuSetting.UIScale); - uiScale.BindValueChanged(args => this.TransformTo(nameof(CurrentScale), args.NewValue, duration, Easing.OutQuart), true); + uiScale.BindValueChanged(args => this.TransformTo(nameof(CurrentScale), args.NewValue, TRANSITION_DURATION, Easing.OutQuart), true); } } @@ -163,10 +163,10 @@ namespace osu.Game.Graphics.Containers backgroundStack.Push(new ScalingBackgroundScreen()); } - backgroundStack.FadeIn(duration); + backgroundStack.FadeIn(TRANSITION_DURATION); } else - backgroundStack?.FadeOut(duration); + backgroundStack?.FadeOut(TRANSITION_DURATION); } RectangleF targetRect = new RectangleF(Vector2.Zero, Vector2.One); @@ -195,13 +195,13 @@ namespace osu.Game.Graphics.Containers if (requiresMasking) sizableContainer.Masking = true; - sizableContainer.MoveTo(targetRect.Location, duration, Easing.OutQuart); - sizableContainer.ResizeTo(targetRect.Size, duration, Easing.OutQuart); + sizableContainer.MoveTo(targetRect.Location, TRANSITION_DURATION, Easing.OutQuart); + sizableContainer.ResizeTo(targetRect.Size, TRANSITION_DURATION, Easing.OutQuart); // Of note, this will not work great in the case of nested ScalingContainers where multiple are applying corner radius. // Masking and corner radius should likely only be applied at one point in the full game stack to fix this. // An example of how this can occur is when the skin editor is visible and the game screen scaling is set to "Everything". - sizableContainer.TransformTo(nameof(CornerRadius), requiresMasking ? corner_radius : 0, duration, requiresMasking ? Easing.OutQuart : Easing.None) + sizableContainer.TransformTo(nameof(CornerRadius), requiresMasking ? corner_radius : 0, TRANSITION_DURATION, requiresMasking ? Easing.OutQuart : Easing.None) .OnComplete(_ => { sizableContainer.Masking = requiresMasking; }); } diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index 1b564ef1b4..2b59ee0282 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -21,7 +21,7 @@ namespace osu.Game.Graphics.UserInterface { Size = TwoLayerButton.SIZE_EXTENDED; - Child = button = new TwoLayerButton(HoverSampleSet.Submit) + Child = button = new TwoLayerButton { Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index ad69ec4078..69fbd744c9 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -56,8 +56,8 @@ namespace osu.Game.Graphics.UserInterface private readonly SpriteText spriteText; private Vector2 hoverSpacing => new Vector2(3f, 0f); - public DialogButton() - : base(HoverSampleSet.Submit) + public DialogButton(HoverSampleSet sampleSet = HoverSampleSet.Button) + : base(sampleSet) { RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 0df69a5b54..1730e1478f 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -36,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface Icon = FontAwesome.Solid.ExternalLinkAlt, RelativeSizeAxes = Axes.Both }, - new HoverClickSounds(HoverSampleSet.Submit) + new HoverClickSounds() }; } diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index 12819840e5..ba253a7c71 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -7,6 +7,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Extensions; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osuTK.Input; namespace osu.Game.Graphics.UserInterface @@ -37,7 +38,10 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnClick(ClickEvent e) { if (buttons.Contains(e.Button) && Contains(e.ScreenSpaceMousePosition)) - sampleClick?.Play(); + { + sampleClick.Frequency.Value = 0.99 + RNG.NextDouble(0.02); + sampleClick.Play(); + } return base.OnClick(e); } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index a5ea6fcfbf..b88f81a143 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -10,9 +10,6 @@ namespace osu.Game.Graphics.UserInterface [Description("default")] Default, - [Description("submit")] - Submit, - [Description("button")] Button, diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs index 4780270f66..ee59da7279 100644 --- a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs @@ -13,6 +13,7 @@ namespace osu.Game.Graphics.UserInterface { public class ShearedToggleButton : ShearedButton { + private Sample? sampleClick; private Sample? sampleOff; private Sample? sampleOn; @@ -39,8 +40,9 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(AudioManager audio) { - sampleOn = audio.Samples.Get(@"UI/check-on"); - sampleOff = audio.Samples.Get(@"UI/check-off"); + sampleClick = audio.Samples.Get(@"UI/default-select"); + sampleOn = audio.Samples.Get(@"UI/dropdown-open"); + sampleOff = audio.Samples.Get(@"UI/dropdown-close"); } protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); @@ -67,6 +69,8 @@ namespace osu.Game.Graphics.UserInterface private void playSample() { + sampleClick?.Play(); + if (Active.Value) sampleOn?.Play(); else diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index 3d09d09833..d9dbf4974b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.IO; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -65,6 +67,9 @@ namespace osu.Game.Graphics.UserInterfaceV2 { get { + if (BeatmapModelManager.VIDEO_EXTENSIONS.Contains(File.Extension)) + return FontAwesome.Regular.FileVideo; + switch (File.Extension) { case @".ogg": @@ -77,12 +82,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 case @".png": return FontAwesome.Regular.FileImage; - case @".mp4": - case @".avi": - case @".mov": - case @".flv": - return FontAwesome.Regular.FileVideo; - default: return FontAwesome.Regular.File; } diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index 80dfa104f3..ae2b85da51 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -1,11 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.Toolkit.HighPerformance; +using osu.Framework.Extensions; using osu.Framework.IO.Stores; using SharpCompress.Archives.Zip; +using SixLabors.ImageSharp.Memory; namespace osu.Game.IO.Archives { @@ -27,15 +31,12 @@ namespace osu.Game.IO.Archives if (entry == null) throw new FileNotFoundException(); - // allow seeking - MemoryStream copy = new MemoryStream(); + var owner = MemoryAllocator.Default.Allocate((int)entry.Size); using (Stream s = entry.OpenEntryStream()) - s.CopyTo(copy); + s.ReadToFill(owner.Memory.Span); - copy.Position = 0; - - return copy; + return new MemoryOwnerMemoryStream(owner); } public override void Dispose() @@ -45,5 +46,48 @@ namespace osu.Game.IO.Archives } public override IEnumerable Filenames => archive.Entries.Select(e => e.Key).ExcludeSystemFileNames(); + + private class MemoryOwnerMemoryStream : Stream + { + private readonly IMemoryOwner owner; + private readonly Stream stream; + + public MemoryOwnerMemoryStream(IMemoryOwner owner) + { + this.owner = owner; + + stream = owner.Memory.AsStream(); + } + + protected override void Dispose(bool disposing) + { + owner?.Dispose(); + base.Dispose(disposing); + } + + public override void Flush() => stream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => stream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin); + + public override void SetLength(long value) => stream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => stream.Write(buffer, offset, count); + + public override bool CanRead => stream.CanRead; + + public override bool CanSeek => stream.CanSeek; + + public override bool CanWrite => stream.CanWrite; + + public override long Length => stream.Length; + + public override long Position + { + get => stream.Position; + set => stream.Position = value; + } + } } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 69ea6b00ca..3da5f3212e 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -80,6 +80,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay), + new KeyBinding(new[] { InputKey.T }, GlobalAction.EditorTapForBPM), new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally), new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.EditorDecreaseDistanceSpacing), @@ -322,5 +323,8 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.DeselectAllMods))] DeselectAllMods, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTapForBPM))] + EditorTapForBPM, } } diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs index f298717c99..0f0f560df9 100644 --- a/osu.Game/Localisation/AudioSettingsStrings.cs +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -30,12 +30,12 @@ namespace osu.Game.Localisation public static LocalisableString OutputDevice => new TranslatableString(getKey(@"output_device"), @"Output device"); /// - /// "Master" + /// "Hitsound stereo separation" /// public static LocalisableString PositionalLevel => new TranslatableString(getKey(@"positional_hitsound_audio_level"), @"Hitsound stereo separation"); /// - /// "Level" + /// "Master" /// public static LocalisableString MasterVolume => new TranslatableString(getKey(@"master_volume"), @"Master"); @@ -69,6 +69,6 @@ namespace osu.Game.Localisation /// public static LocalisableString OffsetWizard => new TranslatableString(getKey(@"offset_wizard"), @"Offset wizard"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index e392ae619f..82d03dbb5b 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -174,6 +174,11 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorTimingMode => new TranslatableString(getKey(@"editor_timing_mode"), @"Timing mode"); + /// + /// "Tap for BPM" + /// + public static LocalisableString EditorTapForBPM => new TranslatableString(getKey(@"editor_tap_for_bpm"), @"Tap for BPM"); + /// /// "Cycle grid display mode" /// @@ -205,7 +210,7 @@ namespace osu.Game.Localisation public static LocalisableString ToggleInGameInterface => new TranslatableString(getKey(@"toggle_in_game_interface"), @"Toggle in-game interface"); /// - /// "Toggle Mod Select" + /// "Toggle mod select" /// public static LocalisableString ToggleModSelection => new TranslatableString(getKey(@"toggle_mod_selection"), @"Toggle mod select"); @@ -294,6 +299,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ToggleChatFocus => new TranslatableString(getKey(@"toggle_chat_focus"), @"Toggle chat focus"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/JoystickSettingsStrings.cs b/osu.Game/Localisation/JoystickSettingsStrings.cs index 410cd0a6f5..976ec1adde 100644 --- a/osu.Game/Localisation/JoystickSettingsStrings.cs +++ b/osu.Game/Localisation/JoystickSettingsStrings.cs @@ -15,10 +15,10 @@ namespace osu.Game.Localisation public static LocalisableString JoystickGamepad => new TranslatableString(getKey(@"joystick_gamepad"), @"Joystick / Gamepad"); /// - /// "Deadzone Threshold" + /// "Deadzone" /// public static LocalisableString DeadzoneThreshold => new TranslatableString(getKey(@"deadzone_threshold"), @"Deadzone"); private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/LayoutSettingsStrings.cs b/osu.Game/Localisation/LayoutSettingsStrings.cs new file mode 100644 index 0000000000..b4326b8e39 --- /dev/null +++ b/osu.Game/Localisation/LayoutSettingsStrings.cs @@ -0,0 +1,34 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class LayoutSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.LayoutSettings"; + + /// + /// "Checking for fullscreen capabilities..." + /// + public static LocalisableString CheckingForFullscreenCapabilities => new TranslatableString(getKey(@"checking_for_fullscreen_capabilities"), @"Checking for fullscreen capabilities..."); + + /// + /// "osu! is running exclusive fullscreen, guaranteeing low latency!" + /// + public static LocalisableString OsuIsRunningExclusiveFullscreen => new TranslatableString(getKey(@"osu_is_running_exclusive_fullscreen"), @"osu! is running exclusive fullscreen, guaranteeing low latency!"); + + /// + /// "Unable to run exclusive fullscreen. You'll still experience some input latency." + /// + public static LocalisableString UnableToRunExclusiveFullscreen => new TranslatableString(getKey(@"unable_to_run_exclusive_fullscreen"), @"Unable to run exclusive fullscreen. You'll still experience some input latency."); + + /// + /// "Using fullscreen on macOS makes interacting with the menu bar and spaces no longer work, and may lead to freezes if a system dialog is presented. Using borderless is recommended." + /// + public static LocalisableString FullscreenMacOSNote => new TranslatableString(getKey(@"fullscreen_macos_note"), @"Using fullscreen on macOS makes interacting with the menu bar and spaces no longer work, and may lead to freezes if a system dialog is presented. Using borderless is recommended."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/MaintenanceSettingsStrings.cs b/osu.Game/Localisation/MaintenanceSettingsStrings.cs index a0e1a9ddab..7a04bcd1ca 100644 --- a/osu.Game/Localisation/MaintenanceSettingsStrings.cs +++ b/osu.Game/Localisation/MaintenanceSettingsStrings.cs @@ -29,6 +29,11 @@ namespace osu.Game.Localisation /// public static LocalisableString DeleteAllBeatmaps => new TranslatableString(getKey(@"delete_all_beatmaps"), @"Delete ALL beatmaps"); + /// + /// "Delete ALL beatmap videos" + /// + public static LocalisableString DeleteAllBeatmapVideos => new TranslatableString(getKey(@"delete_all_beatmap_videos"), @"Delete ALL beatmap videos"); + /// /// "Import scores from stable" /// diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index 8b74b94d59..81035c5a5e 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -54,6 +54,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ExportSkinButton => new TranslatableString(getKey(@"export_skin_button"), @"Export selected skin"); + /// + /// "Delete selected skin" + /// + public static LocalisableString DeleteSkinButton => new TranslatableString(getKey(@"delete_skin_button"), @"Delete selected skin"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 776ff5fd8f..451ea117d5 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -121,8 +121,16 @@ namespace osu.Game.Online.API if (isFailing) return; - Logger.Log($@"Performing request {this}", LoggingTarget.Network); - WebRequest.Perform(); + try + { + Logger.Log($@"Performing request {this}", LoggingTarget.Network); + WebRequest.Perform(); + } + catch (OperationCanceledException) + { + // ignore this. internally Perform is running async and the fail state may have changed since + // the last check of `isFailing` above. + } if (isFailing) return; diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index f292e95bd1..d3707d977c 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -63,12 +63,13 @@ namespace osu.Game.Online.API public virtual void Queue(APIRequest request) { - if (HandleRequest?.Invoke(request) != true) + Schedule(() => { - // this will fail due to not receiving an APIAccess, and trigger a failure on the request. - // this is intended - any request in testing that needs non-failures should use HandleRequest. - request.Perform(this); - } + if (HandleRequest?.Invoke(request) != true) + { + request.Fail(new InvalidOperationException($@"{nameof(DummyAPIAccess)} cannot process this request.")); + } + }); } public void Perform(APIRequest request) => HandleRequest?.Invoke(request); diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 31f67bcecc..c96e7c4cd3 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -61,8 +61,7 @@ namespace osu.Game.Online.Chat /// public IBindableList AvailableChannels => availableChannels; - [Resolved] - private IAPIProvider api { get; set; } + private readonly IAPIProvider api; [Resolved] private UserLookupCache users { get; set; } @@ -71,8 +70,9 @@ namespace osu.Game.Online.Chat private readonly IBindable isIdle = new BindableBool(); - public ChannelManager() + public ChannelManager(IAPIProvider api) { + this.api = api; CurrentChannel.ValueChanged += currentChannelChanged; } diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index 8356b36667..3b0d049528 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -38,7 +38,6 @@ namespace osu.Game.Online.Chat } public DrawableLinkCompiler(IEnumerable parts) - : base(HoverSampleSet.Submit) { Parts = parts.ToList(); } diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index ca6082e19b..fbc5ef79ef 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -77,7 +77,7 @@ namespace osu.Game.Online.Chat if (!messages.Any()) return; - var channel = channelManager.JoinedChannels.SingleOrDefault(c => c.Id == messages.First().ChannelId); + var channel = channelManager.JoinedChannels.SingleOrDefault(c => c.Id > 0 && c.Id == messages.First().ChannelId); if (channel == null) return; diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index b7e1bc999b..bbfffea6fd 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -155,39 +155,42 @@ namespace osu.Game.Online.Chat { public Func CreateChatLineAction; - [Resolved] - private OsuColour colours { get; set; } - public StandAloneDrawableChannel(Channel channel) : base(channel) { } - [BackgroundDependencyLoader] - private void load() - { - ChatLineFlow.Padding = new MarginPadding { Horizontal = 0 }; - } - protected override ChatLine CreateChatLine(Message m) => CreateChatLineAction(m); - protected override Drawable CreateDaySeparator(DateTimeOffset time) => new DaySeparator(time) + protected override DaySeparator CreateDaySeparator(DateTimeOffset time) => new StandAloneDaySeparator(time); + } + + protected class StandAloneDaySeparator : DaySeparator + { + protected override float TextSize => 14; + protected override float LineHeight => 1; + protected override float Spacing => 5; + protected override float DateAlign => 125; + + public StandAloneDaySeparator(DateTimeOffset time) + : base(time) { - TextSize = 14, - Colour = colours.Yellow, - LineHeight = 1, - Padding = new MarginPadding { Horizontal = 10 }, - Margin = new MarginPadding { Vertical = 5 }, - }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Height = 25; + Colour = colours.Yellow; + } } protected class StandAloneMessage : ChatLine { protected override float TextSize => 15; - - protected override float HorizontalPadding => 10; - protected override float MessagePadding => 120; - protected override float TimestampPadding => 50; + protected override float Spacing => 5; + protected override float TimestampWidth => 45; + protected override float UsernameWidth => 75; public StandAloneMessage(Message message) : base(message) diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index cae675b406..c95f3fa579 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -197,6 +197,7 @@ namespace osu.Game.Online.Multiplayer APIRoom.Playlist.Clear(); APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); + APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); Debug.Assert(LocalUser != null); addUserToAPIRoom(LocalUser); @@ -737,6 +738,7 @@ namespace osu.Game.Online.Multiplayer APIRoom.Type.Value = Room.Settings.MatchType; APIRoom.QueueMode.Value = Room.Settings.QueueMode; APIRoom.AutoStartDuration.Value = Room.Settings.AutoStartDuration; + APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); RoomUpdated?.Invoke(); } diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 6ec884d79c..12091bae88 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -61,7 +61,13 @@ namespace osu.Game.Online.Rooms /// Used for serialising to the API. /// [JsonProperty("beatmap_id")] - private int onlineBeatmapId => Beatmap.OnlineID; + private int onlineBeatmapId + { + get => Beatmap.OnlineID; + // This setter is only required for client-side serialise-then-deserialise operations. + // Serialisation is supposed to emit only a `beatmap_id`, but a (non-null) `beatmap` is required on deserialise. + set => Beatmap = new APIBeatmap { OnlineID = value }; + } /// /// A beatmap representing this playlist item. diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 60c0503ddd..1933267efd 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -162,6 +162,13 @@ namespace osu.Game.Online.Rooms Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); } + /// + /// Copies values from another into this one. + /// + /// + /// **Beware**: This will store references between s. + /// + /// The to copy values from. public void CopyFrom(Room other) { RoomID.Value = other.RoomID.Value; diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 78beda6298..9bf49364f3 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -39,7 +39,7 @@ namespace osu.Game.Online.Spectator /// /// The states of all users currently being watched. /// - public IBindableDictionary WatchedUserStates => watchedUserStates; + public virtual IBindableDictionary WatchedUserStates => watchedUserStates; /// /// A global list of all players currently playing. @@ -172,6 +172,7 @@ namespace osu.Game.Online.Spectator currentState.RulesetID = score.ScoreInfo.RulesetID; currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); currentState.State = SpectatedUserState.Playing; + currentState.MaximumScoringValues = state.ScoreProcessor.MaximumScoringValues; currentBeatmap = state.Beatmap; currentScore = score; diff --git a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs new file mode 100644 index 0000000000..a1e8715c8f --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs @@ -0,0 +1,192 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Timing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Online.Spectator +{ + /// + /// A wrapper over a for spectated users. + /// This should be used when a local "playable" beatmap is unavailable or expensive to generate for the spectated user. + /// + public class SpectatorScoreProcessor : Component + { + /// + /// The current total score. + /// + public readonly BindableDouble TotalScore = new BindableDouble { MinValue = 0 }; + + /// + /// The current accuracy. + /// + public readonly BindableDouble Accuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + + /// + /// The current combo. + /// + public readonly BindableInt Combo = new BindableInt(); + + /// + /// The used to calculate scores. + /// + public readonly Bindable Mode = new Bindable(); + + /// + /// The applied s. + /// + public IReadOnlyList Mods => scoreProcessor?.Mods.Value ?? Array.Empty(); + + private IClock? referenceClock; + + /// + /// The clock used to determine the current score. + /// + public IClock ReferenceClock + { + get => referenceClock ?? Clock; + set => referenceClock = value; + } + + [Resolved] + private SpectatorClient spectatorClient { get; set; } = null!; + + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + private readonly IBindableDictionary spectatorStates = new BindableDictionary(); + private readonly List replayFrames = new List(); + private readonly int userId; + + private SpectatorState? spectatorState; + private ScoreProcessor? scoreProcessor; + private ScoreInfo? scoreInfo; + + public SpectatorScoreProcessor(int userId) + { + this.userId = userId; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Mode.BindValueChanged(_ => UpdateScore()); + + spectatorStates.BindTo(spectatorClient.WatchedUserStates); + spectatorStates.BindCollectionChanged(onSpectatorStatesChanged, true); + + spectatorClient.OnNewFrames += onNewFrames; + } + + private void onSpectatorStatesChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + if (!spectatorStates.TryGetValue(userId, out var userState) || userState.BeatmapID == null || userState.RulesetID == null) + { + scoreProcessor?.RemoveAndDisposeImmediately(); + scoreProcessor = null; + scoreInfo = null; + spectatorState = null; + replayFrames.Clear(); + return; + } + + if (scoreProcessor != null) + return; + + Debug.Assert(scoreInfo == null); + + RulesetInfo? rulesetInfo = rulesetStore.GetRuleset(userState.RulesetID.Value); + if (rulesetInfo == null) + return; + + Ruleset ruleset = rulesetInfo.CreateInstance(); + + spectatorState = userState; + scoreInfo = new ScoreInfo { Ruleset = rulesetInfo }; + scoreProcessor = ruleset.CreateScoreProcessor(); + scoreProcessor.Mods.Value = userState.Mods.Select(m => m.ToMod(ruleset)).ToArray(); + } + + private void onNewFrames(int incomingUserId, FrameDataBundle bundle) + { + if (incomingUserId != userId) + return; + + Schedule(() => + { + if (scoreProcessor == null) + return; + + replayFrames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header)); + UpdateScore(); + }); + } + + public void UpdateScore() + { + if (scoreInfo == null || replayFrames.Count == 0) + return; + + Debug.Assert(spectatorState != null); + Debug.Assert(scoreProcessor != null); + + int frameIndex = replayFrames.BinarySearch(new TimedFrame(ReferenceClock.CurrentTime)); + if (frameIndex < 0) + frameIndex = ~frameIndex; + frameIndex = Math.Clamp(frameIndex - 1, 0, replayFrames.Count - 1); + + TimedFrame frame = replayFrames[frameIndex]; + Debug.Assert(frame.Header != null); + + scoreInfo.MaxCombo = frame.Header.MaxCombo; + scoreInfo.Statistics = frame.Header.Statistics; + + Accuracy.Value = frame.Header.Accuracy; + Combo.Value = frame.Header.Combo; + + scoreProcessor.ExtractScoringValues(frame.Header, out var currentScoringValues, out _); + TotalScore.Value = scoreProcessor.ComputeScore(Mode.Value, currentScoringValues, spectatorState.MaximumScoringValues); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (spectatorClient.IsNotNull()) + spectatorClient.OnNewFrames -= onNewFrames; + } + + private class TimedFrame : IComparable + { + public readonly double Time; + public readonly FrameHeader? Header; + + public TimedFrame(double time) + { + Time = time; + } + + public TimedFrame(double time, FrameHeader header) + { + Time = time; + Header = header; + } + + public int CompareTo(TimedFrame other) => Time.CompareTo(other.Time); + } + } +} diff --git a/osu.Game/Online/Spectator/SpectatorState.cs b/osu.Game/Online/Spectator/SpectatorState.cs index 77686d12da..64e5f8b3a1 100644 --- a/osu.Game/Online/Spectator/SpectatorState.cs +++ b/osu.Game/Online/Spectator/SpectatorState.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using MessagePack; using osu.Game.Online.API; +using osu.Game.Scoring; namespace osu.Game.Online.Spectator { @@ -27,6 +28,9 @@ namespace osu.Game.Online.Spectator [Key(3)] public SpectatedUserState State { get; set; } + [Key(4)] + public ScoringValues MaximumScoringValues { get; set; } + public bool Equals(SpectatorState other) { if (ReferenceEquals(null, other)) return false; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 785881d97a..1f9a1ce938 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Humanizer; using JetBrains.Annotations; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -658,11 +659,14 @@ namespace osu.Game } protected override IDictionary GetFrameworkConfigDefaults() - => new Dictionary + { + return new Dictionary { - // General expectation that osu! starts in fullscreen by default (also gives the most predictable performance) - { FrameworkSetting.WindowMode, WindowMode.Fullscreen } + // General expectation that osu! starts in fullscreen by default (also gives the most predictable performance). + // However, macOS is bound to have issues when using exclusive fullscreen as it takes full control away from OS, therefore borderless is default there. + { FrameworkSetting.WindowMode, RuntimeInfo.OS == RuntimeInfo.Platform.macOS ? WindowMode.Borderless : WindowMode.Fullscreen } }; + } protected override void LoadComplete() { @@ -847,7 +851,7 @@ namespace osu.Game loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true); var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); - loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); + loadComponentSingleFile(channelManager = new ChannelManager(API), AddInternal, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); loadComponentSingleFile(new MessageNotifier(), AddInternal, true); loadComponentSingleFile(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 666413004a..5dbdf6f602 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -80,6 +80,9 @@ namespace osu.Game public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild; + internal EndpointConfiguration CreateEndpoints() => + UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); + public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); /// @@ -268,7 +271,7 @@ namespace osu.Game dependencies.Cache(SkinManager = new SkinManager(Storage, realm, Host, Resources, Audio, Scheduler)); dependencies.CacheAs(SkinManager); - EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); + EndpointConfiguration endpoints = CreateEndpoints(); MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index 1be1321d85..f4f958e4a4 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; @@ -69,7 +70,7 @@ namespace osu.Game.Overlays.AccountCreation }, usernameTextBox = new OsuTextBox { - PlaceholderText = UsersStrings.LoginUsername, + PlaceholderText = UsersStrings.LoginUsername.ToLower(), RelativeSizeAxes = Axes.X, TabbableContentContainer = this }, @@ -91,7 +92,7 @@ namespace osu.Game.Overlays.AccountCreation }, passwordTextBox = new OsuPasswordTextBox { - PlaceholderText = "password", + PlaceholderText = UsersStrings.LoginPassword.ToLower(), RelativeSizeAxes = Axes.X, TabbableContentContainer = this, }, diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs index 52dfcad2cc..ff43170207 100644 --- a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -40,7 +40,7 @@ namespace osu.Game.Overlays.BeatmapListing Font = OsuFont.GetFont(size: 13, weight: FontWeight.Regular), Text = LabelFor(Value) }, - new HoverClickSounds() + new HoverClickSounds(HoverSampleSet.TabSelect) }); Enabled.Value = true; diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index a1d8cd5d38..56e39f212d 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Linq; using System.Collections.Generic; @@ -26,42 +28,6 @@ namespace osu.Game.Overlays.Chat { public class ChatLine : CompositeDrawable { - public const float LEFT_PADDING = default_message_padding + default_horizontal_padding * 2; - - private const float default_message_padding = 200; - - protected virtual float MessagePadding => default_message_padding; - - private const float default_timestamp_padding = 65; - - protected virtual float TimestampPadding => default_timestamp_padding; - - private const float default_horizontal_padding = 15; - - protected virtual float HorizontalPadding => default_horizontal_padding; - - protected virtual float TextSize => 20; - - private Color4 usernameColour; - - private OsuSpriteText timestamp; - - public ChatLine(Message message) - { - Message = message; - Padding = new MarginPadding { Left = HorizontalPadding, Right = HorizontalPadding }; - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - } - - [Resolved(CanBeNull = true)] - private ChannelManager chatManager { get; set; } - - private Message message; - private OsuSpriteText username; - - public LinkFlowContainer ContentFlow { get; private set; } - public Message Message { get => message; @@ -78,119 +44,101 @@ namespace osu.Game.Overlays.Chat } } + public LinkFlowContainer ContentFlow { get; private set; } = null!; + + protected virtual float TextSize => 20; + + protected virtual float Spacing => 15; + + protected virtual float TimestampWidth => 60; + + protected virtual float UsernameWidth => 130; + + private Color4 usernameColour; + + private OsuSpriteText timestamp = null!; + + private Message message = null!; + + private OsuSpriteText username = null!; + + private Container? highlight; + private bool senderHasColour => !string.IsNullOrEmpty(message.Sender.Colour); + private bool messageHasColour => Message.IsAction && senderHasColour; + [Resolved] - private OsuColour colours { get; set; } + private ChannelManager? chatManager { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ChatLine(Message message) + { + Message = message; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider? colourProvider) { usernameColour = senderHasColour ? Color4Extensions.FromHex(message.Sender.Colour) : username_colours[message.Sender.Id % username_colours.Length]; - Drawable effectedUsername = username = new OsuSpriteText + InternalChild = new GridContainer { - Shadow = false, - Colour = senderHasColour ? colours.ChatBlue : usernameColour, - Truncate = true, - EllipsisString = "… :", - Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - MaxWidth = MessagePadding - TimestampPadding - }; - - if (senderHasColour) - { - // Background effect - effectedUsername = new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] { - AutoSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 4, - EdgeEffect = new EdgeEffectParameters - { - Roundness = 1, - Radius = 1, - Colour = Color4.Black.Opacity(0.3f), - Offset = new Vector2(0, 1), - Type = EdgeEffectType.Shadow, - }, - Child = new Container - { - AutoSizeAxes = Axes.Both, - Y = 0, - Masking = true, - CornerRadius = 4, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = usernameColour, - }, - new Container - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 4, Right = 4, Bottom = 1, Top = -2 }, - Child = username - } - } - } - }; - } - - InternalChildren = new Drawable[] - { - new Container - { - Size = new Vector2(MessagePadding, TextSize), - Children = new Drawable[] - { - timestamp = new OsuSpriteText - { - Shadow = false, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: TextSize * 0.75f, weight: FontWeight.SemiBold, fixedWidth: true) - }, - new MessageSender(message.Sender) - { - AutoSizeAxes = Axes.Both, - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Child = effectedUsername, - }, - } + new Dimension(GridSizeMode.Absolute, TimestampWidth + Spacing + UsernameWidth + Spacing), + new Dimension(), }, - new Container + Content = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = MessagePadding + HorizontalPadding }, - Children = new Drawable[] + new Drawable[] { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + timestamp = new OsuSpriteText + { + Shadow = false, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TextSize * 0.75f, weight: FontWeight.SemiBold, fixedWidth: true), + MaxWidth = TimestampWidth, + Colour = colourProvider?.Background1 ?? Colour4.White, + }, + new MessageSender(message.Sender) + { + Width = UsernameWidth, + AutoSizeAxes = Axes.Y, + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + Child = createUsername(), + Margin = new MarginPadding { Horizontal = Spacing }, + }, + }, + }, ContentFlow = new LinkFlowContainer(t => { t.Shadow = false; - - if (Message.IsAction) - { - t.Font = OsuFont.GetFont(italics: true); - - if (senderHasColour) - t.Colour = Color4Extensions.FromHex(message.Sender.Colour); - } - - t.Font = t.Font.With(size: TextSize); + t.Font = t.Font.With(size: TextSize, italics: Message.IsAction); + t.Colour = messageHasColour ? Color4Extensions.FromHex(message.Sender.Colour) : colourProvider?.Content1 ?? Colour4.White; }) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, } - } + }, } }; } @@ -203,8 +151,6 @@ namespace osu.Game.Overlays.Chat FinishTransforms(true); } - private Container highlight; - /// /// Performs a highlight animation on this . /// @@ -233,7 +179,7 @@ namespace osu.Game.Overlays.Chat timestamp.FadeTo(message is LocalEchoMessage ? 0 : 1, 500, Easing.OutQuint); timestamp.Text = $@"{message.Timestamp.LocalDateTime:HH:mm:ss}"; - username.Text = $@"{message.Sender.Username}" + (senderHasColour || message.IsAction ? "" : ":"); + username.Text = $@"{message.Sender.Username}"; // remove non-existent channels from the link list message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chatManager?.AvailableChannels.Any(c => c.Name == link.Argument.ToString()) != true); @@ -242,22 +188,78 @@ namespace osu.Game.Overlays.Chat ContentFlow.AddLinks(message.DisplayContent, message.Links); } + private Drawable createUsername() + { + username = new OsuSpriteText + { + Shadow = false, + Colour = senderHasColour ? colours.ChatBlue : usernameColour, + Truncate = true, + EllipsisString = "…", + Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + MaxWidth = UsernameWidth, + }; + + if (!senderHasColour) + return username; + + // Background effect + return new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 4, + EdgeEffect = new EdgeEffectParameters + { + Roundness = 1, + Radius = 1, + Colour = Color4.Black.Opacity(0.3f), + Offset = new Vector2(0, 1), + Type = EdgeEffectType.Shadow, + }, + Child = new Container + { + AutoSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 4, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = usernameColour, + }, + new Container + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 4, Right = 4, Bottom = 1, Top = -2 }, + Child = username + } + } + } + }; + } + private class MessageSender : OsuClickableContainer, IHasContextMenu { private readonly APIUser sender; - private Action startChatAction; + private Action startChatAction = null!; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; public MessageSender(APIUser sender) { this.sender = sender; } - [BackgroundDependencyLoader(true)] - private void load(UserProfileOverlay profile, ChannelManager chatManager) + [BackgroundDependencyLoader] + private void load(UserProfileOverlay? profile, ChannelManager? chatManager) { Action = () => profile?.ShowUser(sender); startChatAction = () => chatManager?.OpenPrivateChannel(sender); diff --git a/osu.Game/Overlays/Chat/ChatOverlayDrawableChannel.cs b/osu.Game/Overlays/Chat/ChatOverlayDrawableChannel.cs deleted file mode 100644 index 3b47adc4b7..0000000000 --- a/osu.Game/Overlays/Chat/ChatOverlayDrawableChannel.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable enable - -using System; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.Chat; - -namespace osu.Game.Overlays.Chat -{ - public class ChatOverlayDrawableChannel : DrawableChannel - { - public ChatOverlayDrawableChannel(Channel channel) - : base(channel) - { - } - - [BackgroundDependencyLoader] - private void load() - { - ChatLineFlow.Padding = new MarginPadding(0); - } - - protected override Drawable CreateDaySeparator(DateTimeOffset time) => new ChatOverlayDaySeparator(time); - - private class ChatOverlayDaySeparator : Container - { - private readonly DateTimeOffset time; - - public ChatOverlayDaySeparator(DateTimeOffset time) - { - this.time = time; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Horizontal = 15, Vertical = 20 }; - Child = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Absolute, 200), - new Dimension(GridSizeMode.Absolute, 15), - new Dimension(), - }, - Content = new[] - { - new[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 15), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new[] - { - new Circle - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Colour = colourProvider.Background5, - RelativeSizeAxes = Axes.X, - Height = 2, - }, - Drawable.Empty(), - new OsuSpriteText - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Text = time.ToLocalTime().ToString("dd MMMM yyyy").ToUpper(), - Font = OsuFont.Torus.With(size: 15, weight: FontWeight.SemiBold), - Colour = colourProvider.Content1, - }, - }, - }, - }, - Drawable.Empty(), - new Circle - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = colourProvider.Background5, - RelativeSizeAxes = Axes.X, - Height = 2, - }, - }, - }, - }; - } - } - } -} diff --git a/osu.Game/Overlays/Chat/DaySeparator.cs b/osu.Game/Overlays/Chat/DaySeparator.cs new file mode 100644 index 0000000000..9ae35b0c38 --- /dev/null +++ b/osu.Game/Overlays/Chat/DaySeparator.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.Chat +{ + public class DaySeparator : Container + { + protected virtual float TextSize => 15; + + protected virtual float LineHeight => 2; + + protected virtual float DateAlign => 205; + + protected virtual float Spacing => 15; + + private readonly DateTimeOffset time; + + [Resolved(CanBeNull = true)] + private OverlayColourProvider? colourProvider { get; set; } + + public DaySeparator(DateTimeOffset time) + { + this.time = time; + Height = 40; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RowDimensions = new[] { new Dimension() }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, DateAlign), + new Dimension(GridSizeMode.Absolute, Spacing), + new Dimension(), + }, + Content = new[] + { + new[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension() }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, Spacing), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = LineHeight, + Colour = colourProvider?.Background5 ?? Colour4.White, + }, + Drawable.Empty(), + new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Text = time.ToLocalTime().ToString("dd MMMM yyyy").ToUpper(), + Font = OsuFont.Torus.With(size: TextSize, weight: FontWeight.SemiBold), + Colour = colourProvider?.Content1 ?? Colour4.White, + }, + } + }, + }, + Drawable.Empty(), + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = LineHeight, + Colour = colourProvider?.Background5 ?? Colour4.White, + }, + } + } + }; + } + } +} diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index f2d4a3e301..c3a341bca4 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -7,14 +7,9 @@ using System.Diagnostics; using System.Linq; 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.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osu.Game.Graphics.Cursor; -using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osuTK.Graphics; @@ -40,9 +35,6 @@ namespace osu.Game.Overlays.Chat } } - [Resolved] - private OsuColour colours { get; set; } - public DrawableChannel(Channel channel) { Channel = channel; @@ -67,7 +59,7 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Bottom = 5 }, Child = ChatLineFlow = new FillFlowContainer { - Padding = new MarginPadding { Left = 20, Right = 20 }, + Padding = new MarginPadding { Horizontal = 10 }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, @@ -121,11 +113,7 @@ namespace osu.Game.Overlays.Chat protected virtual ChatLine CreateChatLine(Message m) => new ChatLine(m); - protected virtual Drawable CreateDaySeparator(DateTimeOffset time) => new DaySeparator(time) - { - Colour = colours.ChatBlue.Lighten(0.7f), - Margin = new MarginPadding { Vertical = 10 }, - }; + protected virtual DaySeparator CreateDaySeparator(DateTimeOffset time) => new DaySeparator(time); private void newMessagesArrived(IEnumerable newMessages) => Schedule(() => { @@ -203,69 +191,5 @@ namespace osu.Game.Overlays.Chat }); private IEnumerable chatLines => ChatLineFlow.Children.OfType(); - - public class DaySeparator : Container - { - public float TextSize - { - get => text.Font.Size; - set => text.Font = text.Font.With(size: value); - } - - private float lineHeight = 2; - - public float LineHeight - { - get => lineHeight; - set => lineHeight = leftBox.Height = rightBox.Height = value; - } - - private readonly SpriteText text; - private readonly Box leftBox; - private readonly Box rightBox; - - public DaySeparator(DateTimeOffset time) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Child = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - }, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize), }, - Content = new[] - { - new Drawable[] - { - leftBox = new Box - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = lineHeight, - }, - text = new OsuSpriteText - { - Margin = new MarginPadding { Horizontal = 10 }, - Text = time.ToLocalTime().ToString("dd MMM yyyy"), - }, - rightBox = new Box - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = lineHeight, - }, - } - } - }; - } - } } } diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs index 86c81d5d79..2a21d30a4a 100644 --- a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs +++ b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,6 +18,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; using osuTK; @@ -32,6 +35,8 @@ namespace osu.Game.Overlays.Chat.Listing public IEnumerable FilterTerms => new LocalisableString[] { Channel.Name, Channel.Topic ?? string.Empty }; public bool MatchingFilter { set => this.FadeTo(value ? 1f : 0f, 100); } + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); + private Box hoverBox = null!; private SpriteIcon checkbox = null!; private OsuSpriteText channelText = null!; @@ -46,14 +51,20 @@ namespace osu.Game.Overlays.Chat.Listing private const float vertical_margin = 1.5f; + private Sample? sampleJoin; + private Sample? sampleLeave; + public ChannelListingItem(Channel channel) { Channel = channel; } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { + sampleJoin = audio.Samples.Get(@"UI/check-on"); + sampleLeave = audio.Samples.Get(@"UI/check-off"); + Masking = true; CornerRadius = 5; RelativeSizeAxes = Axes.X; @@ -156,7 +167,19 @@ namespace osu.Game.Overlays.Chat.Listing } }, true); - Action = () => (channelJoined.Value ? OnRequestLeave : OnRequestJoin)?.Invoke(Channel); + Action = () => + { + if (channelJoined.Value) + { + OnRequestLeave?.Invoke(Channel); + sampleLeave?.Play(); + } + else + { + OnRequestJoin?.Invoke(Channel); + sampleJoin?.Play(); + } + }; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 02769b5d68..f04bf76c18 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -38,9 +38,9 @@ namespace osu.Game.Overlays private LoadingLayer loading = null!; private ChannelListing channelListing = null!; private ChatTextBar textBar = null!; - private Container currentChannelContainer = null!; + private Container currentChannelContainer = null!; - private readonly Dictionary loadedChannels = new Dictionary(); + private readonly Dictionary loadedChannels = new Dictionary(); protected IEnumerable DrawableChannels => loadedChannels.Values; @@ -126,7 +126,7 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background4, }, - currentChannelContainer = new Container + currentChannelContainer = new Container { RelativeSizeAxes = Axes.Both, }, @@ -313,7 +313,7 @@ namespace osu.Game.Overlays loading.Show(); // Ensure the drawable channel is stored before async load to prevent double loading - ChatOverlayDrawableChannel drawableChannel = CreateDrawableChannel(newChannel); + DrawableChannel drawableChannel = CreateDrawableChannel(newChannel); loadedChannels.Add(newChannel, drawableChannel); LoadComponentAsync(drawableChannel, loadedDrawable => @@ -338,7 +338,7 @@ namespace osu.Game.Overlays channelManager.MarkChannelAsRead(newChannel); } - protected virtual ChatOverlayDrawableChannel CreateDrawableChannel(Channel newChannel) => new ChatOverlayDrawableChannel(newChannel); + protected virtual DrawableChannel CreateDrawableChannel(Channel newChannel) => new DrawableChannel(newChannel); private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) { @@ -361,7 +361,7 @@ namespace osu.Game.Overlays if (loadedChannels.ContainsKey(channel)) { - ChatOverlayDrawableChannel loaded = loadedChannels[channel]; + DrawableChannel loaded = loadedChannels[channel]; loadedChannels.Remove(channel); // DrawableChannel removed from cache must be manually disposed loaded.Dispose(); diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index 17e04c0c99..ddcee7c040 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -154,12 +154,15 @@ namespace osu.Game.Overlays.FirstRunSetup var downloadTracker = tutorialDownloader.DownloadTrackers.First(); + downloadTracker.State.BindValueChanged(state => + { + if (state.NewValue == DownloadState.LocallyAvailable) + downloadTutorialButton.Complete(); + }, true); + downloadTracker.Progress.BindValueChanged(progress => { downloadTutorialButton.SetProgress(progress.NewValue, false); - - if (progress.NewValue == 1) - downloadTutorialButton.Complete(); }, true); } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index 8452691bb5..f09a26a527 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -126,6 +126,7 @@ namespace osu.Game.Overlays.FirstRunSetup private class SampleScreenContainer : CompositeDrawable { private readonly OsuScreen screen; + // Minimal isolation from main game. [Cached] @@ -151,6 +152,9 @@ namespace osu.Game.Overlays.FirstRunSetup RelativeSizeAxes = Axes.Both; } + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => + new DependencyContainer(new DependencyIsolationContainer(base.CreateChildDependencies(parent))); + [BackgroundDependencyLoader] private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) { @@ -197,5 +201,41 @@ namespace osu.Game.Overlays.FirstRunSetup stack.PushSynchronously(screen); } } + + private class DependencyIsolationContainer : IReadOnlyDependencyContainer + { + private readonly IReadOnlyDependencyContainer parentDependencies; + + private readonly Type[] isolatedTypes = + { + typeof(OsuGame) + }; + + public DependencyIsolationContainer(IReadOnlyDependencyContainer parentDependencies) + { + this.parentDependencies = parentDependencies; + } + + public object Get(Type type) + { + if (isolatedTypes.Contains(type)) + return null; + + return parentDependencies.Get(type); + } + + public object Get(Type type, CacheInfo info) + { + if (isolatedTypes.Contains(type)) + return null; + + return parentDependencies.Get(type, info); + } + + public void Inject(T instance) where T : class + { + parentDependencies.Inject(instance); + } + } } } diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index 68d0704825..5ce0b9df9c 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -14,7 +14,6 @@ using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.News @@ -29,7 +28,6 @@ namespace osu.Game.Overlays.News private TextFlowContainer main; public NewsCard(APINewsPost post) - : base(HoverSampleSet.Submit) { this.post = post; diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs index aa83f89689..e2ce25660e 100644 --- a/osu.Game/Overlays/News/Sidebar/MonthSection.cs +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -18,7 +18,6 @@ using System.Diagnostics; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Platform; -using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.News.Sidebar { @@ -129,7 +128,6 @@ namespace osu.Game.Overlays.News.Sidebar private readonly APINewsPost post; public PostButton(APINewsPost post) - : base(HoverSampleSet.Submit) { this.post = post; diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs index b4a5d5e31b..4cfdf5cc86 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private Sample sampleOpen; private Sample sampleClose; - protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(); public ExpandDetailsButton() { diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs index 94ef5e5d86..13465f3bf8 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Profile.Sections { @@ -18,7 +17,6 @@ namespace osu.Game.Overlays.Profile.Sections private readonly IBeatmapInfo beatmapInfo; protected BeatmapMetadataContainer(IBeatmapInfo beatmapInfo) - : base(HoverSampleSet.Submit) { this.beatmapInfo = beatmapInfo; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 602ace6dea..05890ad882 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -4,6 +4,7 @@ using System; using System.Drawing; using System.Linq; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; @@ -13,6 +14,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Framework.Platform.Windows; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -34,10 +36,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private Bindable sizeFullscreen; private readonly BindableList resolutions = new BindableList(new[] { new Size(9999, 9999) }); + private readonly IBindable fullscreenCapability = new Bindable(FullscreenCapability.Capable); [Resolved] private OsuGameBase game { get; set; } + [Resolved] + private GameHost host { get; set; } + private SettingsDropdown resolutionDropdown; private SettingsDropdown displayDropdown; private SettingsDropdown windowModeDropdown; @@ -65,6 +71,9 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics windowModes.BindTo(host.Window.SupportedWindowModes); } + if (host.Window is WindowsWindow windowsWindow) + fullscreenCapability.BindTo(windowsWindow.FullscreenCapability); + Children = new Drawable[] { windowModeDropdown = new SettingsDropdown @@ -139,6 +148,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics } }, }; + + fullscreenCapability.BindValueChanged(_ => Schedule(updateScreenModeWarning), true); } protected override void LoadComplete() @@ -150,8 +161,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics windowModeDropdown.Current.BindValueChanged(mode => { updateDisplayModeDropdowns(); - - windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? GraphicsSettingsStrings.NotFullscreenNote : default; + updateScreenModeWarning(); }, true); windowModes.BindCollectionChanged((sender, args) => @@ -213,6 +223,48 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics } } + private void updateScreenModeWarning() + { + if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS) + { + if (windowModeDropdown.Current.Value == WindowMode.Fullscreen) + windowModeDropdown.SetNoticeText(LayoutSettingsStrings.FullscreenMacOSNote, true); + else + windowModeDropdown.ClearNoticeText(); + + return; + } + + if (windowModeDropdown.Current.Value != WindowMode.Fullscreen) + { + windowModeDropdown.SetNoticeText(GraphicsSettingsStrings.NotFullscreenNote, true); + return; + } + + if (host.Window is WindowsWindow) + { + switch (fullscreenCapability.Value) + { + case FullscreenCapability.Unknown: + windowModeDropdown.SetNoticeText(LayoutSettingsStrings.CheckingForFullscreenCapabilities, true); + break; + + case FullscreenCapability.Capable: + windowModeDropdown.SetNoticeText(LayoutSettingsStrings.OsuIsRunningExclusiveFullscreen); + break; + + case FullscreenCapability.Incapable: + windowModeDropdown.SetNoticeText(LayoutSettingsStrings.UnableToRunExclusiveFullscreen, true); + break; + } + } + else + { + // We can only detect exclusive fullscreen status on windows currently. + windowModeDropdown.ClearNoticeText(); + } + } + private void bindPreviewEvent(Bindable bindable) { bindable.ValueChanged += _ => diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 653f30a018..8c3e45cd62 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -48,7 +48,16 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics frameLimiterDropdown.Current.BindValueChanged(limit => { - frameLimiterDropdown.WarningText = limit.NewValue == FrameSync.Unlimited ? GraphicsSettingsStrings.UnlimitedFramesNote : default; + switch (limit.NewValue) + { + case FrameSync.Unlimited: + frameLimiterDropdown.SetNoticeText(GraphicsSettingsStrings.UnlimitedFramesNote, true); + break; + + default: + frameLimiterDropdown.ClearNoticeText(); + break; + } }, true); } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 4235dc0a05..1511d53b6b 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -117,9 +117,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) { if (highPrecision.NewValue) - highPrecisionMouse.WarningText = MouseSettingsStrings.HighPrecisionPlatformWarning; + highPrecisionMouse.SetNoticeText(MouseSettingsStrings.HighPrecisionPlatformWarning, true); else - highPrecisionMouse.WarningText = null; + highPrecisionMouse.ClearNoticeText(); } }, true); } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 802d442ced..5d31c38ae7 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -11,6 +11,7 @@ using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; using osu.Game.Localisation; @@ -95,11 +96,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input Origin = Anchor.TopCentre, Text = TabletSettingsStrings.NoTabletDetected, }, - new SettingsNoticeText(colours) + new LinkFlowContainer(cp => cp.Colour = colours.Yellow) { TextAnchor = Anchor.TopCentre, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, }.With(t => { if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index be4b0decd9..054de8dbd7 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -28,6 +28,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance private SettingsButton deleteSkinsButton; private SettingsButton restoreButton; private SettingsButton undeleteButton; + private SettingsButton deleteBeatmapVideosButton; [BackgroundDependencyLoader(permitNulls: true)] private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] LegacyImportManager legacyImportManager, IDialogOverlay dialogOverlay) @@ -58,6 +59,19 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance } }); + Add(deleteBeatmapVideosButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllBeatmapVideos, + Action = () => + { + dialogOverlay?.Push(new MassVideoDeleteConfirmationDialog(() => + { + deleteBeatmapVideosButton.Enabled.Value = false; + Task.Run(beatmaps.DeleteAllVideos).ContinueWith(t => Schedule(() => deleteBeatmapVideosButton.Enabled.Value = true)); + })); + } + }); + if (legacyImportManager?.SupportsImportFromStable == true) { Add(importScoresButton = new SettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs new file mode 100644 index 0000000000..fc8c9d497b --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs @@ -0,0 +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; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class MassVideoDeleteConfirmationDialog : MassDeleteConfirmationDialog + { + public MassVideoDeleteConfirmationDialog(Action deleteAction) + : base(deleteAction) + { + BodyText = "All beatmap videos? This cannot be undone!"; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index a87e65b735..b83600a16d 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -16,6 +16,7 @@ using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Game.Screens.Select; using osu.Game.Skinning; using osu.Game.Skinning.Editor; using Realms; @@ -67,6 +68,7 @@ namespace osu.Game.Overlays.Settings.Sections Action = () => skinEditor?.ToggleVisibility(), }, new ExportSkinButton(), + new DeleteSkinButton(), }; config.BindWith(OsuSetting.Skin, configBindable); @@ -202,5 +204,36 @@ namespace osu.Game.Overlays.Settings.Sections } } } + + public class DeleteSkinButton : DangerousSettingsButton + { + [Resolved] + private SkinManager skins { get; set; } + + [Resolved(CanBeNull = true)] + private IDialogOverlay dialogOverlay { get; set; } + + private Bindable currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = SkinSettingsStrings.DeleteSkinButton; + Action = delete; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + } + + private void delete() + { + dialogOverlay?.Push(new SkinDeleteDialog(currentSkin.Value)); + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 284e9cb2de..fceffa09c5 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -61,7 +61,10 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface user.BindValueChanged(u => { - backgroundSourceDropdown.WarningText = u.NewValue?.IsSupporter != true ? UserInterfaceStrings.NotSupporterNote : default; + if (u.NewValue?.IsSupporter != true) + backgroundSourceDropdown.SetNoticeText(UserInterfaceStrings.NotSupporterNote, true); + else + backgroundSourceDropdown.ClearNoticeText(); }, true); } } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index ee9daa1c0d..ea076b77ac 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -41,7 +41,7 @@ namespace osu.Game.Overlays.Settings private SpriteText labelText; - private OsuTextFlowContainer warningText; + private OsuTextFlowContainer noticeText; public bool ShowsDefaultIndicator = true; private readonly Container defaultValueIndicatorContainer; @@ -70,27 +70,32 @@ namespace osu.Game.Overlays.Settings } /// - /// Text to be displayed at the bottom of this . - /// Generally used to recommend the user change their setting as the current one is considered sub-optimal. + /// Clear any warning text. /// - public LocalisableString? WarningText + public void ClearNoticeText() { - set + noticeText?.Expire(); + noticeText = null; + } + + /// + /// Set the text to be displayed at the bottom of this . + /// Generally used to provide feedback to a user about a sub-optimal setting. + /// + /// The text to display. + /// Whether the text is in a warning state. Will decide how this is visually represented. + public void SetNoticeText(LocalisableString text, bool isWarning = false) + { + ClearNoticeText(); + + // construct lazily for cases where the label is not needed (may be provided by the Control). + FlowContent.Add(noticeText = new LinkFlowContainer(cp => cp.Colour = isWarning ? colours.Yellow : colours.Green) { - bool hasValue = value != default; - - if (warningText == null) - { - if (!hasValue) - return; - - // construct lazily for cases where the label is not needed (may be provided by the Control). - FlowContent.Add(warningText = new SettingsNoticeText(colours) { Margin = new MarginPadding { Bottom = 5 } }); - } - - warningText.Alpha = hasValue ? 1 : 0; - warningText.Text = value ?? default; - } + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = 5 }, + Text = text, + }); } public virtual Bindable Current diff --git a/osu.Game/Overlays/Settings/SettingsNoticeText.cs b/osu.Game/Overlays/Settings/SettingsNoticeText.cs deleted file mode 100644 index 76ecf7edd4..0000000000 --- a/osu.Game/Overlays/Settings/SettingsNoticeText.cs +++ /dev/null @@ -1,19 +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.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; - -namespace osu.Game.Overlays.Settings -{ - public class SettingsNoticeText : LinkFlowContainer - { - public SettingsNoticeText(OsuColour colours) - : base(s => s.Colour = colours.Yellow) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - } - } -} diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 4a839b048c..b686f11c13 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -20,7 +20,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; @@ -83,7 +82,6 @@ namespace osu.Game.Overlays.Toolbar private RealmAccess realm { get; set; } protected ToolbarButton() - : base(HoverSampleSet.Toolbar) { Width = Toolbar.HEIGHT; RelativeSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs index 308359570f..12529da07f 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs @@ -11,7 +11,6 @@ using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -29,7 +28,6 @@ namespace osu.Game.Overlays.Toolbar private AnalogClockDisplay analog; public ToolbarClock() - : base(HoverSampleSet.Toolbar) { RelativeSizeAxes = Axes.Y; AutoSizeAxes = Axes.X; diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index 929c362bd8..24d9f785f2 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -15,7 +15,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Framework.Utils; @@ -28,7 +27,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Volume { - public class VolumeMeter : Container, IKeyBindingHandler, IStateful + public class VolumeMeter : Container, IStateful { private CircularProgress volumeCircle; private CircularProgress volumeCircleGlow; @@ -80,7 +79,7 @@ namespace osu.Game.Overlays.Volume [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audio) { - hoverSample = audio.Samples.Get($"UI/{HoverSampleSet.Button.GetDescription()}-hover"); + hoverSample = audio.Samples.Get($@"UI/{HoverSampleSet.Button.GetDescription()}-hover"); notchSample = audio.Samples.Get(@"UI/notch-tick"); sampleLastPlaybackTime = Time.Current; @@ -132,7 +131,7 @@ namespace osu.Game.Overlays.Volume { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Name = "Progress under covers for smoothing", + Name = @"Progress under covers for smoothing", RelativeSizeAxes = Axes.Both, Rotation = 180, Child = volumeCircle = new CircularProgress @@ -144,7 +143,7 @@ namespace osu.Game.Overlays.Volume }, new Circle { - Name = "Inner Cover", + Name = @"Inner Cover", Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, @@ -153,7 +152,7 @@ namespace osu.Game.Overlays.Volume }, new Container { - Name = "Progress overlay for glow", + Name = @"Progress overlay for glow", Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, @@ -365,27 +364,6 @@ namespace osu.Game.Overlays.Volume { } - public bool OnPressed(KeyBindingPressEvent e) - { - if (!IsHovered) - return false; - - switch (e.Action) - { - case GlobalAction.SelectPreviousGroup: - State = SelectionState.Selected; - adjust(1, false); - return true; - - case GlobalAction.SelectNextGroup: - State = SelectionState.Selected; - adjust(-1, false); - return true; - } - - return false; - } - public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index a96949e96f..9d2ed3f837 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -17,6 +17,7 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays.Volume; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Overlays { @@ -171,6 +172,30 @@ namespace osu.Game.Overlays return base.OnMouseMove(e); } + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Left: + Adjust(GlobalAction.PreviousVolumeMeter); + return true; + + case Key.Right: + Adjust(GlobalAction.NextVolumeMeter); + return true; + + case Key.Down: + Adjust(GlobalAction.DecreaseVolume); + return true; + + case Key.Up: + Adjust(GlobalAction.IncreaseVolume); + return true; + } + + return base.OnKeyDown(e); + } + protected override bool OnHover(HoverEvent e) { schedulePopOut(); diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index 94ddc32bb7..bfa67b8c45 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -117,9 +117,8 @@ namespace osu.Game.Rulesets.Scoring /// /// If the provided replay frame does not have any header information, this will be a noop. /// - /// The ruleset to be used for retrieving statistics. /// The replay frame to read header statistics from. - public virtual void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame) + public virtual void ResetFromReplayFrame(ReplayFrame frame) { if (frame.Header == null) return; diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 1dd1d1aeb6..df094ddb7c 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.Contracts; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.Online.Spectator; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -88,17 +90,34 @@ namespace osu.Game.Rulesets.Scoring private readonly double accuracyPortion; private readonly double comboPortion; - private int maxAchievableCombo; + /// + /// Scoring values for a perfect play. + /// + public ScoringValues MaximumScoringValues + { + get + { + if (!beatmapApplied) + throw new InvalidOperationException($"Cannot access maximum scoring values before calling {nameof(ApplyBeatmap)}."); + + return maximumScoringValues; + } + } + + private ScoringValues maximumScoringValues; /// - /// The maximum achievable base score. + /// Scoring values for the current play assuming all perfect hits. /// - private double maxBaseScore; + /// + /// This is only used to determine the accuracy with respect to the current point in time for an ongoing play session. + /// + private ScoringValues currentMaximumScoringValues; /// - /// The maximum number of basic (non-tick and non-bonus) hitobjects. + /// Scoring values for the current play. /// - private int maxBasicHitObjects; + private ScoringValues currentScoringValues; /// /// The maximum of a basic (non-tick and non-bonus) hitobject. @@ -106,9 +125,6 @@ namespace osu.Game.Rulesets.Scoring /// private HitResult? maxBasicResult; - private double rollingMaxBaseScore; - private double baseScore; - private int basicHitObjects; private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); @@ -163,6 +179,10 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; + // Always update the maximum scoring values. + applyResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); + currentMaximumScoringValues.MaxCombo += result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; + if (!result.Type.IsScorable()) return; @@ -171,16 +191,8 @@ namespace osu.Game.Rulesets.Scoring else if (result.Type.BreaksCombo()) Combo.Value = 0; - double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; - - if (!result.Type.IsBonus()) - { - baseScore += scoreIncrease; - rollingMaxBaseScore += result.Judgement.MaxNumericResult; - } - - if (result.Type.IsBasic()) - basicHitObjects++; + applyResult(result.Type, ref currentScoringValues); + currentScoringValues.MaxCombo = HighestCombo.Value; hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; @@ -188,6 +200,20 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } + private static void applyResult(HitResult result, ref ScoringValues scoringValues) + { + if (!result.IsScorable()) + return; + + if (result.IsBonus()) + scoringValues.BonusScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; + else + scoringValues.BaseScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; + + if (result.IsBasic()) + scoringValues.CountBasicHitObjects++; + } + /// /// Creates the that describes a . /// @@ -206,19 +232,15 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; + // Always update the maximum scoring values. + revertResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); + currentMaximumScoringValues.MaxCombo -= result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; + if (!result.Type.IsScorable()) return; - double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; - - if (!result.Type.IsBonus()) - { - baseScore -= scoreIncrease; - rollingMaxBaseScore -= result.Judgement.MaxNumericResult; - } - - if (result.Type.IsBasic()) - basicHitObjects--; + revertResult(result.Type, ref currentScoringValues); + currentScoringValues.MaxCombo = HighestCombo.Value; Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; @@ -227,14 +249,24 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } + private static void revertResult(HitResult result, ref ScoringValues scoringValues) + { + if (!result.IsScorable()) + return; + + if (result.IsBonus()) + scoringValues.BonusScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; + else + scoringValues.BaseScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; + + if (result.IsBasic()) + scoringValues.CountBasicHitObjects--; + } + private void updateScore() { - double rollingAccuracyRatio = rollingMaxBaseScore > 0 ? baseScore / rollingMaxBaseScore : 1; - double accuracyRatio = maxBaseScore > 0 ? baseScore / maxBaseScore : 1; - double comboRatio = maxAchievableCombo > 0 ? (double)HighestCombo.Value / maxAchievableCombo : 1; - - Accuracy.Value = rollingAccuracyRatio; - TotalScore.Value = ComputeScore(Mode.Value, accuracyRatio, comboRatio, getBonusScore(scoreResultCounts), maxBasicHitObjects); + Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; + TotalScore.Value = ComputeScore(Mode.Value, currentScoringValues, maximumScoringValues); } /// @@ -246,22 +278,15 @@ namespace osu.Game.Rulesets.Scoring /// The to represent the score as. /// The to compute the total score of. /// The total score in the given . + [Pure] public double ComputeFinalScore(ScoringMode mode, ScoreInfo scoreInfo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - extractFromStatistics(ruleset, - scoreInfo.Statistics, - out double extractedBaseScore, - out double extractedMaxBaseScore, - out int extractedMaxCombo, - out int extractedBasicHitObjects); + ExtractScoringValues(scoreInfo, out var current, out var maximum); - double accuracyRatio = extractedMaxBaseScore > 0 ? extractedBaseScore / extractedMaxBaseScore : 1; - double comboRatio = extractedMaxCombo > 0 ? (double)scoreInfo.MaxCombo / extractedMaxCombo : 1; - - return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), extractedBasicHitObjects); + return ComputeScore(mode, current, maximum); } /// @@ -273,6 +298,7 @@ namespace osu.Game.Rulesets.Scoring /// The to represent the score as. /// The to compute the total score of. /// The total score in the given . + [Pure] public double ComputePartialScore(ScoringMode mode, ScoreInfo scoreInfo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) @@ -281,17 +307,9 @@ namespace osu.Game.Rulesets.Scoring if (!beatmapApplied) throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}."); - extractFromStatistics(ruleset, - scoreInfo.Statistics, - out double extractedBaseScore, - out _, - out _, - out _); + ExtractScoringValues(scoreInfo, out var current, out _); - double accuracyRatio = maxBaseScore > 0 ? extractedBaseScore / maxBaseScore : 1; - double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; - - return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), maxBasicHitObjects); + return ComputeScore(mode, current, MaximumScoringValues); } /// @@ -305,6 +323,7 @@ namespace osu.Game.Rulesets.Scoring /// The to compute the total score of. /// The maximum achievable combo for the provided beatmap. /// The total score in the given . + [Pure] public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) @@ -313,26 +332,30 @@ namespace osu.Game.Rulesets.Scoring double accuracyRatio = scoreInfo.Accuracy; double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; + ExtractScoringValues(scoreInfo, out var current, out var maximum); + // For legacy osu!mania scores, a full-GREAT score has 100% accuracy. If combined with a full-combo, the score becomes indistinguishable from a full-PERFECT score. // To get around this, the accuracy ratio is always recalculated based on the hit statistics rather than trusting the score. // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. - if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3) - { - extractFromStatistics( - ruleset, - scoreInfo.Statistics, - out double computedBaseScore, - out double computedMaxBaseScore, - out _, - out _); + if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3 && maximum.BaseScore > 0) + accuracyRatio = current.BaseScore / maximum.BaseScore; - if (computedMaxBaseScore > 0) - accuracyRatio = computedBaseScore / computedMaxBaseScore; - } + return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); + } - int computedBasicHitObjects = scoreInfo.Statistics.Where(kvp => kvp.Key.IsBasic()).Select(kvp => kvp.Value).Sum(); - - return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), computedBasicHitObjects); + /// + /// Computes the total score from scoring values. + /// + /// The to represent the score as. + /// The current scoring values. + /// The maximum scoring values. + /// The total score computed from the given scoring values. + [Pure] + public double ComputeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) + { + double accuracyRatio = maximum.BaseScore > 0 ? current.BaseScore / maximum.BaseScore : 1; + double comboRatio = maximum.MaxCombo > 0 ? (double)current.MaxCombo / maximum.MaxCombo : 1; + return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); } /// @@ -344,6 +367,7 @@ namespace osu.Game.Rulesets.Scoring /// The total bonus score. /// The total number of basic (non-tick and non-bonus) hitobjects in the beatmap. /// The total score computed from the given scoring component ratios. + [Pure] public double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects) { switch (mode) @@ -362,15 +386,6 @@ namespace osu.Game.Rulesets.Scoring } } - /// - /// Calculates the total bonus score from score statistics. - /// - /// The score statistics. - /// The total bonus score. - private double getBonusScore(IReadOnlyDictionary statistics) - => statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE - + statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; - private ScoreRank rankFrom(double acc) { if (acc == 1) @@ -402,15 +417,10 @@ namespace osu.Game.Rulesets.Scoring lastHitObject = null; if (storeResults) - { - maxAchievableCombo = HighestCombo.Value; - maxBaseScore = baseScore; - maxBasicHitObjects = basicHitObjects; - } + maximumScoringValues = currentScoringValues; - baseScore = 0; - rollingMaxBaseScore = 0; - basicHitObjects = 0; + currentScoringValues = default; + currentMaximumScoringValues = default; TotalScore.Value = 0; Accuracy.Value = 1; @@ -437,14 +447,20 @@ namespace osu.Game.Rulesets.Scoring score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score)); } - public override void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame) + public override void ResetFromReplayFrame(ReplayFrame frame) { - base.ResetFromReplayFrame(ruleset, frame); + base.ResetFromReplayFrame(frame); if (frame.Header == null) return; - extractFromStatistics(ruleset, frame.Header.Statistics, out baseScore, out rollingMaxBaseScore, out _, out _); + extractScoringValues(frame.Header.Statistics, out var current, out var maximum); + currentScoringValues.BaseScore = current.BaseScore; + currentScoringValues.MaxCombo = frame.Header.MaxCombo; + currentMaximumScoringValues.BaseScore = maximum.BaseScore; + currentMaximumScoringValues.MaxCombo = maximum.MaxCombo; + + Combo.Value = frame.Header.Combo; HighestCombo.Value = frame.Header.MaxCombo; scoreResultCounts.Clear(); @@ -455,52 +471,126 @@ namespace osu.Game.Rulesets.Scoring OnResetFromReplayFrame?.Invoke(); } - private void extractFromStatistics(Ruleset ruleset, IReadOnlyDictionary statistics, out double baseScore, out double maxBaseScore, out int maxCombo, - out int basicHitObjects) + #region ScoringValue extraction + + /// + /// Applies a best-effort extraction of hit statistics into . + /// + /// + /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: + /// + /// The maximum will always be 0. + /// The current and maximum will always be the same value. + /// + /// Consumers are expected to more accurately fill in the above values through external means. + /// + /// Ensure to fill in the maximum for use in + /// . + /// + /// + /// The score to extract scoring values from. + /// The "current" scoring values, representing the hit statistics as they appear. + /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. + [Pure] + internal void ExtractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum) { - baseScore = 0; - maxBaseScore = 0; - maxCombo = 0; - basicHitObjects = 0; + extractScoringValues(scoreInfo.Statistics, out current, out maximum); + current.MaxCombo = scoreInfo.MaxCombo; + } + + /// + /// Applies a best-effort extraction of hit statistics into . + /// + /// + /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: + /// + /// The maximum will always be 0. + /// The current and maximum will always be the same value. + /// + /// Consumers are expected to more accurately fill in the above values through external means. + /// + /// Ensure to fill in the maximum for use in + /// . + /// + /// + /// The replay frame header to extract scoring values from. + /// The "current" scoring values, representing the hit statistics as they appear. + /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. + [Pure] + internal void ExtractScoringValues(FrameHeader header, out ScoringValues current, out ScoringValues maximum) + { + extractScoringValues(header.Statistics, out current, out maximum); + current.MaxCombo = header.MaxCombo; + } + + /// + /// Applies a best-effort extraction of hit statistics into . + /// + /// + /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: + /// + /// The current will always be 0. + /// The maximum will always be 0. + /// The current and maximum will always be the same value. + /// + /// Consumers are expected to more accurately fill in the above values (especially the current ) via external means (e.g. ). + /// + /// The hit statistics to extract scoring values from. + /// The "current" scoring values, representing the hit statistics as they appear. + /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. + [Pure] + private void extractScoringValues(IReadOnlyDictionary statistics, out ScoringValues current, out ScoringValues maximum) + { + current = default; + maximum = default; foreach ((HitResult result, int count) in statistics) { - // Bonus scores are counted separately directly from the statistics dictionary later on. - if (!result.IsScorable() || result.IsBonus()) + if (!result.IsScorable()) continue; - // The maximum result of this judgement if it wasn't a miss. - // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). - HitResult maxResult; - - switch (result) + if (result.IsBonus()) + current.BonusScore += count * Judgement.ToNumericResult(result); + else { - case HitResult.LargeTickHit: - case HitResult.LargeTickMiss: - maxResult = HitResult.LargeTickHit; - break; + // The maximum result of this judgement if it wasn't a miss. + // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). + HitResult maxResult; - case HitResult.SmallTickHit: - case HitResult.SmallTickMiss: - maxResult = HitResult.SmallTickHit; - break; + switch (result) + { + case HitResult.LargeTickHit: + case HitResult.LargeTickMiss: + maxResult = HitResult.LargeTickHit; + break; - default: - maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; - break; + case HitResult.SmallTickHit: + case HitResult.SmallTickMiss: + maxResult = HitResult.SmallTickHit; + break; + + default: + maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; + break; + } + + current.BaseScore += count * Judgement.ToNumericResult(result); + maximum.BaseScore += count * Judgement.ToNumericResult(maxResult); } - baseScore += count * Judgement.ToNumericResult(result); - maxBaseScore += count * Judgement.ToNumericResult(maxResult); - if (result.AffectsCombo()) - maxCombo += count; + maximum.MaxCombo += count; if (result.IsBasic()) - basicHitObjects += count; + { + current.CountBasicHitObjects += count; + maximum.CountBasicHitObjects += count; + } } } + #endregion + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 7d1b23f48b..b5390eb6e2 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -27,8 +27,6 @@ namespace osu.Game.Rulesets.UI { public readonly KeyBindingContainer KeyBindingContainer; - private readonly Ruleset ruleset; - [Resolved(CanBeNull = true)] private ScoreProcessor scoreProcessor { get; set; } @@ -57,8 +55,6 @@ namespace osu.Game.Rulesets.UI protected RulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) { - this.ruleset = ruleset.CreateInstance(); - InternalChild = KeyBindingContainer = CreateKeyBindingContainer(ruleset, variant, unique) .WithChild(content = new Container { RelativeSizeAxes = Axes.Both }); @@ -85,7 +81,7 @@ namespace osu.Game.Rulesets.UI break; case ReplayStatisticsFrameEvent statisticsStateChangeEvent: - scoreProcessor?.ResetFromReplayFrame(ruleset, statisticsStateChangeEvent.Frame); + scoreProcessor?.ResetFromReplayFrame(statisticsStateChangeEvent.Frame); break; default: diff --git a/osu.Game/Scoring/ScoringValues.cs b/osu.Game/Scoring/ScoringValues.cs new file mode 100644 index 0000000000..d31cd7c68b --- /dev/null +++ b/osu.Game/Scoring/ScoringValues.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Scoring +{ + /// + /// Stores the required scoring data that fulfils the minimum requirements for a to calculate score. + /// + [MessagePackObject] + public struct ScoringValues + { + /// + /// The sum of all "basic" scoring values. See: and . + /// + [Key(0)] + public double BaseScore; + + /// + /// The sum of all "bonus" scoring values. See: and . + /// + [Key(1)] + public double BonusScore; + + /// + /// The highest achieved combo. + /// + [Key(2)] + public int MaxCombo; + + /// + /// The count of "basic" s. See: . + /// + [Key(3)] + public int CountBasicHitObjects; + } +} diff --git a/osu.Game/Screens/Edit/BottomBar.cs b/osu.Game/Screens/Edit/BottomBar.cs index 5994184c11..62caaced89 100644 --- a/osu.Game/Screens/Edit/BottomBar.cs +++ b/osu.Game/Screens/Edit/BottomBar.cs @@ -2,13 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Overlays; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Edit { @@ -26,6 +29,14 @@ namespace osu.Game.Screens.Edit Height = 60; + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.2f), + Type = EdgeEffectType.Shadow, + Radius = 10f, + }; + InternalChildren = new Drawable[] { new Box diff --git a/osu.Game/Screens/Edit/Components/EditorSidebar.cs b/osu.Game/Screens/Edit/Components/EditorSidebar.cs index 4edcef41b1..4e9b1d5222 100644 --- a/osu.Game/Screens/Edit/Components/EditorSidebar.cs +++ b/osu.Game/Screens/Edit/Components/EditorSidebar.cs @@ -16,13 +16,15 @@ namespace osu.Game.Screens.Edit.Components /// internal class EditorSidebar : Container { + public const float WIDTH = 250; + private readonly Box background; protected override Container Content { get; } public EditorSidebar() { - Width = 250; + Width = WIDTH; RelativeSizeAxes = Axes.Y; InternalChildren = new Drawable[] diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs index 5550c6a748..e0b21b2e22 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs @@ -1,99 +1,29 @@ // 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 osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Framework.Threading; -using osu.Game.Graphics; +using osu.Framework.Allocation; using osu.Game.Graphics.UserInterface; -using osuTK; -using osuTK.Graphics; -using osuTK.Input; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Timing; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineButton : CompositeDrawable + public class TimelineButton : IconButton { - public Action Action; - public readonly BindableBool Enabled = new BindableBool(true); - - public IconUsage Icon + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - get => button.Icon; - set => button.Icon = value; + // These are using colourProvider but don't match the design. + // Just something to fit until someone implements the updated design. + IconColour = colourProvider.Background1; + IconHoverColour = colourProvider.Content2; + + HoverColour = colourProvider.Background1; + FlashColour = colourProvider.Content2; + + Add(new RepeatingButtonBehaviour(this)); } - private readonly IconButton button; - - public TimelineButton() - { - InternalChild = button = new TimelineIconButton { Action = () => Action?.Invoke() }; - - button.Enabled.BindTo(Enabled); - Width = button.Width; - } - - protected override void Update() - { - base.Update(); - - button.Size = new Vector2(button.Width, DrawHeight); - } - - private class TimelineIconButton : IconButton - { - public TimelineIconButton() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - IconColour = OsuColour.Gray(0.35f); - IconHoverColour = Color4.White; - HoverColour = OsuColour.Gray(0.25f); - FlashColour = OsuColour.Gray(0.5f); - } - - private ScheduledDelegate repeatSchedule; - - /// - /// The initial delay before mouse down repeat begins. - /// - private const int repeat_initial_delay = 250; - - /// - /// The delay between mouse down repeats after the initial repeat. - /// - private const int repeat_tick_rate = 70; - - protected override bool OnClick(ClickEvent e) - { - // don't actuate a click since we are manually handling repeats. - return true; - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (e.Button == MouseButton.Left) - { - Action clickAction = () => base.OnClick(new ClickEvent(e.CurrentState, e.Button)); - - // run once for initial down - clickAction(); - - Scheduler.Add(repeatSchedule = new ScheduledDelegate(clickAction, Clock.CurrentTime + repeat_initial_delay, repeat_tick_rate)); - } - - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - repeatSchedule?.Cancel(); - base.OnMouseUp(e); - } - } + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 35d103ddf1..d008368b69 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; using osu.Framework.Input.Events; +using osu.Framework.Layout; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Graphics.Containers; @@ -40,10 +41,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved(canBeNull: true)] private IFrameBasedClock editorClock { get; set; } + private readonly LayoutValue zoomedContentWidthCache = new LayoutValue(Invalidation.DrawSize); + public ZoomableScrollContainer() : base(Direction.Horizontal) { base.Content.Add(zoomedContent = new Container { RelativeSizeAxes = Axes.Y }); + + AddLayout(zoomedContentWidthCache); } private float minZoom = 1; @@ -103,12 +108,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected override void LoadComplete() + protected override void Update() { - base.LoadComplete(); + base.Update(); - // This width only gets updated on the application of a transform, so this needs to be initialized here. - updateZoomedContentWidth(); + if (!zoomedContentWidthCache.IsValid) + updateZoomedContentWidth(); } protected override bool OnScroll(ScrollEvent e) @@ -128,7 +133,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return base.OnScroll(e); } - private void updateZoomedContentWidth() => zoomedContent.Width = DrawWidth * currentZoom; + private void updateZoomedContentWidth() + { + zoomedContent.Width = DrawWidth * currentZoom; + zoomedContentWidthCache.Validate(); + } private float zoomTarget = 1; @@ -199,8 +208,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline float targetOffset = expectedWidth * (focusPoint / contentSize) - focusOffset; d.currentZoom = newZoom; - d.updateZoomedContentWidth(); + // Temporarily here to make sure ScrollTo gets the correct DrawSize for scrollable area. // TODO: Make sure draw size gets invalidated properly on the framework side, and remove this once it is. d.Invalidate(Invalidation.DrawSize); diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index a67a060134..26819dcfe7 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -99,6 +99,15 @@ namespace osu.Game.Screens.Edit colourSelected = colours.Colour3; } + protected override void LoadComplete() + { + base.LoadComplete(); + + // Reduce flicker of rows when offset is being changed rapidly. + // Probably need to reconsider this. + FinishTransforms(true); + } + private bool selected; public bool Selected diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index aae19396db..fd916894ea 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; using System.IO; @@ -9,6 +11,7 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -27,10 +30,10 @@ namespace osu.Game.Screens.Edit.Setup public IEnumerable HandledExtensions => handledExtensions; - private readonly Bindable currentFile = new Bindable(); + private readonly Bindable currentFile = new Bindable(); [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; public FileChooserLabelledTextBox(params string[] handledExtensions) { @@ -45,7 +48,7 @@ namespace osu.Game.Screens.Edit.Setup currentFile.BindValueChanged(onFileSelected); } - private void onFileSelected(ValueChangedEvent file) + private void onFileSelected(ValueChangedEvent file) { if (file.NewValue == null) return; @@ -65,14 +68,16 @@ namespace osu.Game.Screens.Edit.Setup protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - game.UnregisterImportHandler(this); + + if (game.IsNotNull()) + game.UnregisterImportHandler(this); } public override Popover GetPopover() => new FileChooserPopover(handledExtensions, currentFile); private class FileChooserPopover : OsuPopover { - public FileChooserPopover(string[] handledExtensions, Bindable currentFile) + public FileChooserPopover(string[] handledExtensions, Bindable currentFile) { Child = new Container { diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 0c12eff503..77d875b67f 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -61,6 +61,7 @@ namespace osu.Game.Screens.Edit.Timing selectedGroup.BindValueChanged(group => { + // TODO: This should scroll the selected row into view. foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue; }, true); } diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs index c8944d0357..c9f73411f1 100644 --- a/osu.Game/Screens/Edit/Timing/EffectSection.cs +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -31,18 +31,33 @@ namespace osu.Game.Screens.Edit.Timing }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + kiai.Current.BindValueChanged(_ => saveChanges()); + omitBarLine.Current.BindValueChanged(_ => saveChanges()); + scrollSpeedSlider.Current.BindValueChanged(_ => saveChanges()); + + void saveChanges() + { + if (!isRebinding) ChangeHandler?.SaveState(); + } + } + + private bool isRebinding; + protected override void OnControlPointChanged(ValueChangedEvent point) { if (point.NewValue != null) { + isRebinding = true; + kiai.Current = point.NewValue.KiaiModeBindable; - kiai.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; - omitBarLine.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable; - scrollSpeedSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + isRebinding = false; } } diff --git a/osu.Game/Screens/Edit/Timing/GroupSection.cs b/osu.Game/Screens/Edit/Timing/GroupSection.cs index bb2dd35a9c..f613488aae 100644 --- a/osu.Game/Screens/Edit/Timing/GroupSection.cs +++ b/osu.Game/Screens/Edit/Timing/GroupSection.cs @@ -37,7 +37,7 @@ namespace osu.Game.Screens.Edit.Timing RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding(10); + Padding = new MarginPadding(10) { Bottom = 0 }; InternalChildren = new Drawable[] { diff --git a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs index 57fcff6a4c..2ecd66a05f 100644 --- a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs @@ -3,6 +3,8 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -31,12 +33,18 @@ namespace osu.Game.Screens.Edit.Timing private IAdjustableClock metronomeClock; + private Sample clunk; + [Resolved] private OverlayColourProvider overlayColourProvider { get; set; } + public bool EnableClicking { get; set; } = true; + [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { + clunk = audio.Samples.Get(@"Multiplayer/countdown-tick"); + const float taper = 25; const float swing_vertical_offset = -23; const float lower_cover_height = 32; @@ -269,8 +277,24 @@ namespace osu.Game.Screens.Edit.Timing if (currentAngle != 0 && Math.Abs(currentAngle - targetAngle) > angle * 1.8f && isSwinging) { - using (stick.BeginDelayedSequence(beatLength / 2)) + using (BeginDelayedSequence(beatLength / 2)) + { stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint); + + Schedule(() => + { + if (!EnableClicking) + return; + + var channel = clunk?.GetChannel(); + + if (channel != null) + { + channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f); + channel.Play(); + } + }); + } } } } diff --git a/osu.Game/Screens/Edit/Timing/RepeatingButtonBehaviour.cs b/osu.Game/Screens/Edit/Timing/RepeatingButtonBehaviour.cs new file mode 100644 index 0000000000..595305b20f --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RepeatingButtonBehaviour.cs @@ -0,0 +1,92 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Threading; + +namespace osu.Game.Screens.Edit.Timing +{ + /// + /// Represents a component that provides the behaviour of triggering button clicks repeatedly while holding with mouse. + /// + public class RepeatingButtonBehaviour : Component + { + private const double initial_delay = 300; + private const double minimum_delay = 80; + + private readonly Drawable button; + + private Sample sample; + + /// + /// An additive modifier for the frequency of the sample played on next actuation. + /// This can be adjusted during the button's event to affect the repeat sample playback of that click. + /// + public double SampleFrequencyModifier { get; set; } + + public RepeatingButtonBehaviour(Drawable button) + { + this.button = button; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sample = audio.Samples.Get(@"UI/notch-tick"); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + beginRepeat(); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + adjustDelegate?.Cancel(); + base.OnMouseUp(e); + } + + private ScheduledDelegate adjustDelegate; + private double adjustDelay = initial_delay; + + private void beginRepeat() + { + adjustDelegate?.Cancel(); + + adjustDelay = initial_delay; + adjustNext(); + + void adjustNext() + { + if (IsHovered) + { + button.TriggerClick(); + adjustDelay = Math.Max(minimum_delay, adjustDelay * 0.9f); + + var channel = sample?.GetChannel(); + + if (channel != null) + { + double repeatModifier = 0.05f * (Math.Abs(adjustDelay - initial_delay) / minimum_delay); + channel.Frequency.Value = 1 + repeatModifier + SampleFrequencyModifier; + channel.Play(); + } + } + else + { + adjustDelay = initial_delay; + } + + adjustDelegate = Scheduler.AddDelayed(adjustNext, adjustDelay); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/Section.cs b/osu.Game/Screens/Edit/Timing/Section.cs index 139abfb187..17147c21f4 100644 --- a/osu.Game/Screens/Edit/Timing/Section.cs +++ b/osu.Game/Screens/Edit/Timing/Section.cs @@ -77,7 +77,7 @@ namespace osu.Game.Screens.Edit.Timing { Flow = new FillFlowContainer { - Padding = new MarginPadding(20), + Padding = new MarginPadding(10) { Top = 0 }, Spacing = new Vector2(20), RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Screens/Edit/Timing/TapButton.cs b/osu.Game/Screens/Edit/Timing/TapButton.cs new file mode 100644 index 0000000000..a6227cbe27 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/TapButton.cs @@ -0,0 +1,418 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.Edit.Timing +{ + internal class TapButton : CircularContainer, IKeyBindingHandler + { + public const float SIZE = 140; + + public readonly BindableBool IsHandlingTapping = new BindableBool(); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved(canBeNull: true)] + private Bindable? selectedGroup { get; set; } + + [Resolved(canBeNull: true)] + private IBeatSyncProvider? beatSyncSource { get; set; } + + private Circle hoverLayer = null!; + + private CircularContainer innerCircle = null!; + private Box innerCircleHighlight = null!; + + private int currentLight; + + private Container scaleContainer = null!; + private Container lights = null!; + private Container lightsGlow = null!; + private OsuSpriteText bpmText = null!; + private Container textContainer = null!; + + private bool grabbedMouseDown; + + private ScheduledDelegate? resetDelegate; + + private const int light_count = 8; + + private const int initial_taps_to_ignore = 4; + + private const int max_taps_to_consider = 128; + + private const double transition_length = 500; + + private const float angular_light_gap = 0.007f; + + private readonly List tapTimings = new List(); + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(SIZE); + + const float ring_width = 22; + const float light_padding = 3; + + InternalChild = scaleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + lights = new Container + { + RelativeSizeAxes = Axes.Both, + }, + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Name = @"outer masking", + Masking = true, + BorderThickness = light_padding, + BorderColour = colourProvider.Background4, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + } + }, + new Circle + { + Name = @"inner masking", + Size = new Vector2(SIZE - ring_width * 2 + light_padding * 2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colourProvider.Background4, + }, + lightsGlow = new Container + { + RelativeSizeAxes = Axes.Both, + }, + innerCircle = new CircularContainer + { + Size = new Vector2(SIZE - ring_width * 2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background2, + RelativeSizeAxes = Axes.Both, + }, + innerCircleHighlight = new Box + { + Colour = colourProvider.Colour3, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + textContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background1, + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.Torus.With(size: 34, weight: FontWeight.SemiBold), + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + Y = 5, + Text = "Tap", + }, + bpmText = new OsuSpriteText + { + Font = OsuFont.Torus.With(size: 23, weight: FontWeight.Regular), + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Y = -1, + }, + } + }, + hoverLayer = new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background1.Opacity(0.3f), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + } + }, + } + }; + + for (int i = 0; i < light_count; i++) + { + var light = new Light + { + Rotation = (i + 1) * (360f / light_count) + 360 * angular_light_gap / 2, + }; + + lights.Add(light); + lightsGlow.Add(light.Glow.CreateProxy()); + } + + reset(); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + hoverLayer.ReceivePositionalInputAt(screenSpacePos); + + private ColourInfo textColour + { + get + { + if (grabbedMouseDown) + return colourProvider.Background4; + + if (IsHovered) + return colourProvider.Content2; + + return colourProvider.Background1; + } + } + + protected override bool OnHover(HoverEvent e) + { + hoverLayer.FadeIn(transition_length, Easing.OutQuint); + textContainer.FadeColour(textColour, transition_length, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverLayer.FadeOut(transition_length, Easing.OutQuint); + textContainer.FadeColour(textColour, transition_length, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + const double in_duration = 100; + + grabbedMouseDown = true; + IsHandlingTapping.Value = true; + + resetDelegate?.Cancel(); + + handleTap(); + + textContainer.FadeColour(textColour, in_duration, Easing.OutQuint); + + scaleContainer.ScaleTo(0.99f, in_duration, Easing.OutQuint); + innerCircle.ScaleTo(0.96f, in_duration, Easing.OutQuint); + + innerCircleHighlight + .FadeIn(50, Easing.OutQuint) + .FlashColour(Color4.White, 1000, Easing.OutQuint); + + lights[currentLight % light_count].Hide(); + lights[(currentLight + light_count / 2) % light_count].Hide(); + + currentLight++; + + lights[currentLight % light_count].Show(); + lights[(currentLight + light_count / 2) % light_count].Show(); + + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + const double out_duration = 800; + + grabbedMouseDown = false; + + textContainer.FadeColour(textColour, out_duration, Easing.OutQuint); + + scaleContainer.ScaleTo(1, out_duration, Easing.OutQuint); + innerCircle.ScaleTo(1, out_duration, Easing.OutQuint); + + innerCircleHighlight.FadeOut(out_duration, Easing.OutQuint); + + resetDelegate = Scheduler.AddDelayed(reset, 1000); + + base.OnMouseUp(e); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.EditorTapForBPM && !e.Repeat) + { + // Direct through mouse handling to achieve animation + OnMouseDown(new MouseDownEvent(e.CurrentState, MouseButton.Left)); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == GlobalAction.EditorTapForBPM) + OnMouseUp(new MouseUpEvent(e.CurrentState, MouseButton.Left)); + } + + private void handleTap() + { + tapTimings.Add(Clock.CurrentTime); + + if (tapTimings.Count > initial_taps_to_ignore + max_taps_to_consider) + tapTimings.RemoveAt(0); + + if (tapTimings.Count < initial_taps_to_ignore * 2) + { + bpmText.Text = new string('.', tapTimings.Count); + return; + } + + double averageBeatLength = (tapTimings.Last() - tapTimings.Skip(initial_taps_to_ignore).First()) / (tapTimings.Count - initial_taps_to_ignore - 1); + double clockRate = beatSyncSource?.Clock?.Rate ?? 1; + + double bpm = Math.Round(60000 / averageBeatLength / clockRate); + + bpmText.Text = $"{bpm} BPM"; + + var timingPoint = selectedGroup?.Value.ControlPoints.OfType().FirstOrDefault(); + + if (timingPoint != null) + { + // Intentionally use the rounded BPM here. + timingPoint.BeatLength = 60000 / bpm; + } + } + + private void reset() + { + bpmText.FadeOut(transition_length, Easing.OutQuint); + + using (BeginDelayedSequence(tapTimings.Count > 0 ? transition_length : 0)) + { + Schedule(() => bpmText.Text = "the beat!"); + bpmText.FadeIn(800, Easing.OutQuint); + } + + foreach (var light in lights) + light.Hide(); + + tapTimings.Clear(); + currentLight = 0; + IsHandlingTapping.Value = false; + } + + private class Light : CompositeDrawable + { + public Drawable Glow { get; private set; } = null!; + + private Container fillContent = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = new Vector2(0.98f); // Avoid bleed into masking edge. + + InternalChildren = new Drawable[] + { + new CircularProgress + { + RelativeSizeAxes = Axes.Both, + Current = { Value = 1f / light_count - angular_light_gap }, + Colour = colourProvider.Background2, + }, + fillContent = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = colourProvider.Colour1, + Children = new[] + { + new CircularProgress + { + RelativeSizeAxes = Axes.Both, + Current = { Value = 1f / light_count - angular_light_gap }, + Blending = BlendingParameters.Additive + }, + // Please do not try and make sense of this. + // Getting the visual effect I was going for relies on what I can only imagine is broken implementation + // of `PadExtent`. If that's ever fixed in the future this will likely need to be adjusted. + Glow = new CircularProgress + { + RelativeSizeAxes = Axes.Both, + Current = { Value = 1f / light_count - 0.01f }, + Blending = BlendingParameters.Additive + }.WithEffect(new GlowEffect + { + Colour = colourProvider.Colour1.Opacity(0.4f), + BlurSigma = new Vector2(9f), + Strength = 10, + PadExtent = true + }), + } + }, + }; + } + + public override void Show() + { + fillContent + .FadeIn(50, Easing.OutQuint) + .FlashColour(Color4.White, 1000, Easing.OutQuint); + } + + public override void Hide() + { + fillContent + .FadeOut(300, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs index d0ab4d1f98..9b5574d3cb 100644 --- a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs +++ b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs @@ -1,29 +1,45 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Screens.Edit.Timing { public class TapTimingControl : CompositeDrawable { [Resolved] - private EditorClock editorClock { get; set; } + private EditorClock editorClock { get; set; } = null!; [Resolved] - private Bindable selectedGroup { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; + + [Resolved] + private Bindable selectedGroup { get; set; } = null!; + + private readonly BindableBool isHandlingTapping = new BindableBool(); + + private MetronomeDisplay metronome = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, OsuColour colours) { + const float padding = 10; + RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -44,7 +60,8 @@ namespace osu.Game.Screens.Edit.Timing RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, 200), - new Dimension(GridSizeMode.Absolute, 60), + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(GridSizeMode.Absolute, TapButton.SIZE + padding), }, Content = new[] { @@ -53,6 +70,7 @@ namespace osu.Game.Screens.Edit.Timing new Container { RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(padding), Children = new Drawable[] { new GridContainer @@ -67,7 +85,7 @@ namespace osu.Game.Screens.Edit.Timing { new Drawable[] { - new MetronomeDisplay + metronome = new MetronomeDisplay { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -77,43 +95,95 @@ namespace osu.Game.Screens.Edit.Timing }, } } - } + }, }, new Drawable[] { new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(10), + Padding = new MarginPadding { Bottom = padding, Horizontal = padding }, Children = new Drawable[] { - new RoundedButton + new TimingAdjustButton(1) { - Text = "Reset", - BackgroundColour = colours.Pink, - RelativeSizeAxes = Axes.X, - Width = 0.3f, - Action = reset, + Text = "Offset", + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.48f, 1), + Action = adjustOffset, }, - new RoundedButton + new TimingAdjustButton(0.1) { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Text = "Play from start", - RelativeSizeAxes = Axes.X, - BackgroundColour = colourProvider.Background1, - Width = 0.68f, - Action = tap, + Text = "BPM", + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.48f, 1), + Action = adjustBpm, } } }, - } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = padding, Horizontal = padding }, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + Height = 0.98f, + Width = TapButton.SIZE / 1.3f, + Masking = true, + CornerRadius = 15, + Children = new Drawable[] + { + new InlineButton(FontAwesome.Solid.Stop, Anchor.TopLeft) + { + BackgroundColour = colourProvider.Background1, + RelativeSizeAxes = Axes.Both, + Height = 0.49f, + Action = reset, + }, + new InlineButton(FontAwesome.Solid.Play, Anchor.BottomLeft) + { + BackgroundColour = colourProvider.Background1, + RelativeSizeAxes = Axes.Both, + Height = 0.49f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Action = start, + }, + }, + }, + new TapButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + IsHandlingTapping = { BindTarget = isHandlingTapping } + } + } + }, + }, } }, }; + + isHandlingTapping.BindValueChanged(handling => + { + metronome.EnableClicking = !handling.NewValue; + + if (handling.NewValue) + start(); + }, true); } - private void tap() + private void start() { editorClock.Seek(selectedGroup.Value.Time); editorClock.Start(); @@ -124,5 +194,96 @@ namespace osu.Game.Screens.Edit.Timing editorClock.Stop(); editorClock.Seek(selectedGroup.Value.Time); } + + private void adjustOffset(double adjust) + { + // VERY TEMPORARY + var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray(); + + beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value); + + double newOffset = selectedGroup.Value.Time + adjust; + + foreach (var cp in currentGroupItems) + beatmap.ControlPointInfo.Add(newOffset, cp); + + // the control point might not necessarily exist yet, if currentGroupItems was empty. + selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true); + + if (!editorClock.IsRunning) + editorClock.Seek(newOffset); + } + + private void adjustBpm(double adjust) + { + var timing = selectedGroup.Value.ControlPoints.OfType().FirstOrDefault(); + + if (timing == null) + return; + + timing.BeatLength = 60000 / (timing.BPM + adjust); + } + + private class InlineButton : OsuButton + { + private readonly IconUsage icon; + private readonly Anchor anchor; + + private SpriteIcon spriteIcon = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public InlineButton(IconUsage icon, Anchor anchor) + { + this.icon = icon; + this.anchor = anchor; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Content.CornerRadius = 0; + Content.Masking = false; + + BackgroundColour = colourProvider.Background2; + + Content.Add(new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(15), + Children = new Drawable[] + { + spriteIcon = new SpriteIcon + { + Icon = icon, + Size = new Vector2(22), + Anchor = anchor, + Origin = anchor, + Colour = colourProvider.Background1, + }, + } + }); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + // scale looks bad so don't call base. + return false; + } + + protected override bool OnHover(HoverEvent e) + { + spriteIcon.FadeColour(colourProvider.Content2, 200, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + spriteIcon.FadeColour(colourProvider.Background1, 200, Easing.OutQuint); + base.OnHoverLost(e); + } + } } } diff --git a/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs b/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs new file mode 100644 index 0000000000..9540547d89 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs @@ -0,0 +1,208 @@ +// 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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit.Timing +{ + /// + /// A button with variable constant output based on hold position and length. + /// + public class TimingAdjustButton : CompositeDrawable + { + public Action Action; + + private readonly double adjustAmount; + + private const int max_multiplier = 10; + private const int adjust_levels = 4; + + public Container Content { get; set; } + + private readonly Box background; + + private readonly OsuSpriteText text; + + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + private readonly RepeatingButtonBehaviour repeatBehaviour; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public TimingAdjustButton(double adjustAmount) + { + this.adjustAmount = adjustAmount; + + CornerRadius = 5; + Masking = true; + + AddInternal(Content = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(weight: FontWeight.SemiBold), + Padding = new MarginPadding(5), + Depth = float.MinValue + } + } + }); + + AddInternal(repeatBehaviour = new RepeatingButtonBehaviour(this)); + } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = colourProvider.Background3; + + for (int i = 1; i <= adjust_levels; i++) + { + Content.Add(new IncrementBox(i, adjustAmount)); + Content.Add(new IncrementBox(-i, adjustAmount)); + } + } + + protected override bool OnHover(HoverEvent e) => true; + + protected override bool OnClick(ClickEvent e) + { + var hoveredBox = Content.OfType().FirstOrDefault(d => d.IsHovered); + if (hoveredBox == null) + return false; + + Action(adjustAmount * hoveredBox.Multiplier); + + hoveredBox.Flash(); + + repeatBehaviour.SampleFrequencyModifier = (hoveredBox.Multiplier / max_multiplier) * 0.2; + return true; + } + + private class IncrementBox : CompositeDrawable + { + public readonly float Multiplier; + + private readonly Box box; + private readonly OsuSpriteText text; + + public IncrementBox(int index, double amount) + { + Multiplier = Math.Sign(index) * convertMultiplier(index); + + float ratio = (float)index / adjust_levels; + + RelativeSizeAxes = Axes.Both; + + Width = 0.5f * Math.Abs(ratio); + + Anchor direction = index < 0 ? Anchor.x2 : Anchor.x0; + + Origin |= direction; + + Depth = Math.Abs(index); + + Anchor = Anchor.TopCentre; + + InternalChildren = new Drawable[] + { + box = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive + }, + text = new OsuSpriteText + { + Anchor = direction, + Origin = direction, + Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold), + Text = $"{(index > 0 ? "+" : "-")}{Math.Abs(Multiplier * amount)}", + Padding = new MarginPadding(2), + Alpha = 0, + } + }; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + box.Colour = colourProvider.Background1; + box.Alpha = 0.1f; + } + + private float convertMultiplier(int m) + { + switch (Math.Abs(m)) + { + default: return 1; + + case 2: return 2; + + case 3: return 5; + + case 4: + return max_multiplier; + } + } + + protected override bool OnHover(HoverEvent e) + { + box.Colour = colourProvider.Colour0; + + box.FadeTo(0.2f, 100, Easing.OutQuint); + text.FadeIn(100, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + box.Colour = colourProvider.Background1; + + box.FadeTo(0.1f, 500, Easing.OutQuint); + text.FadeOut(100, Easing.OutQuint); + base.OnHoverLost(e); + } + + public void Flash() + { + box + .FadeTo(0.4f, 20, Easing.OutQuint) + .Then() + .FadeTo(0.2f, 400, Easing.OutQuint); + + text + .MoveToY(-5, 20, Easing.OutQuint) + .Then() + .MoveToY(0, 400, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index f71a8d7d22..f498aa917e 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -1,12 +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; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -19,7 +21,7 @@ namespace osu.Game.Screens.Edit.Timing public class TimingScreen : EditorScreenWithTimeline { [Cached] - private Bindable selectedGroup = new Bindable(); + public readonly Bindable SelectedGroup = new Bindable(); public TimingScreen() : base(EditorScreenMode.Timing) @@ -51,6 +53,8 @@ namespace osu.Game.Screens.Edit.Timing private readonly IBindableList controlPointGroups = new BindableList(); + private RoundedButton addButton; + [Resolved] private EditorClock clock { get; set; } @@ -105,9 +109,8 @@ namespace osu.Game.Screens.Edit.Timing Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, }, - new RoundedButton + addButton = new RoundedButton { - Text = "+ Add at current time", Action = addNew, Size = new Vector2(160, 30), Anchor = Anchor.BottomRight, @@ -122,7 +125,14 @@ namespace osu.Game.Screens.Edit.Timing { base.LoadComplete(); - selectedGroup.BindValueChanged(selected => { deleteButton.Enabled.Value = selected.NewValue != null; }, true); + selectedGroup.BindValueChanged(selected => + { + deleteButton.Enabled.Value = selected.NewValue != null; + + addButton.Text = selected.NewValue != null + ? "+ Clone to current time" + : "+ Add at current time"; + }, true); controlPointGroups.BindTo(Beatmap.ControlPointInfo.Groups); controlPointGroups.BindCollectionChanged((sender, args) => @@ -132,6 +142,61 @@ namespace osu.Game.Screens.Edit.Timing }, true); } + protected override bool OnClick(ClickEvent e) + { + selectedGroup.Value = null; + return true; + } + + protected override void Update() + { + base.Update(); + + trackActivePoint(); + + addButton.Enabled.Value = clock.CurrentTimeAccurate != selectedGroup.Value?.Time; + } + + private Type trackedType; + + /// + /// Given the user has selected a control point group, we want to track any group which is + /// active at the current point in time which matches the type the user has selected. + /// + /// So if the user is currently looking at a timing point and seeks into the future, a + /// future timing point would be automatically selected if it is now the new "current" point. + /// + private void trackActivePoint() + { + // For simplicity only match on the first type of the active control point. + if (selectedGroup.Value == null) + trackedType = null; + else + { + // If the selected group only has one control point, update the tracking type. + if (selectedGroup.Value.ControlPoints.Count == 1) + trackedType = selectedGroup.Value?.ControlPoints.Single().GetType(); + // If the selected group has more than one control point, choose the first as the tracking type + // if we don't already have a singular tracked type. + else if (trackedType == null) + trackedType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType(); + } + + if (trackedType != null) + { + // We don't have an efficient way of looking up groups currently, only individual point types. + // To improve the efficiency of this in the future, we should reconsider the overall structure of ControlPointInfo. + + // Find the next group which has the same type as the selected one. + var found = Beatmap.ControlPointInfo.Groups + .Where(g => g.ControlPoints.Any(cp => cp.GetType() == trackedType)) + .LastOrDefault(g => g.Time <= clock.CurrentTimeAccurate); + + if (found != null) + selectedGroup.Value = found; + } + } + private void delete() { if (selectedGroup.Value == null) @@ -144,7 +209,27 @@ namespace osu.Game.Screens.Edit.Timing private void addNew() { - selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); + bool isFirstControlPoint = !Beatmap.ControlPointInfo.TimingPoints.Any(); + + var group = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); + + if (isFirstControlPoint) + group.Add(new TimingControlPoint()); + else + { + // Try and create matching types from the currently selected control point. + var selected = selectedGroup.Value; + + if (selected != null && selected != group) + { + foreach (var controlPoint in selected.ControlPoints) + { + group.Add(controlPoint.DeepClone()); + } + } + } + + selectedGroup.Value = group; } } } diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs index a5abd96d72..1a97058d73 100644 --- a/osu.Game/Screens/Edit/Timing/TimingSection.cs +++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs @@ -28,15 +28,31 @@ namespace osu.Game.Screens.Edit.Timing }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + bpmTextEntry.Current.BindValueChanged(_ => saveChanges()); + timeSignature.Current.BindValueChanged(_ => saveChanges()); + + void saveChanges() + { + if (!isRebinding) ChangeHandler?.SaveState(); + } + } + + private bool isRebinding; + protected override void OnControlPointChanged(ValueChangedEvent point) { if (point.NewValue != null) { - bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; - bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + isRebinding = true; + bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; timeSignature.Current = point.NewValue.TimeSignatureBindable; - timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + isRebinding = false; } } diff --git a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs index c80d3c4261..0745187e43 100644 --- a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; @@ -26,6 +27,8 @@ namespace osu.Game.Screens.Edit.Timing { private const int total_waveforms = 8; + private const float corner_radius = LabelledDrawable.CORNER_RADIUS; + private readonly BindableNumber beatLength = new BindableDouble(); [Resolved] @@ -42,18 +45,22 @@ namespace osu.Game.Screens.Edit.Timing private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT; - private int lastDisplayedBeatIndex; + private double displayedTime; private double selectedGroupStartTime; private double selectedGroupEndTime; private readonly IBindableList controlPointGroups = new BindableList(); + private readonly BindableBool displayLocked = new BindableBool(); + + private LockedOverlay lockedOverlay = null!; + public WaveformComparisonDisplay() { RelativeSizeAxes = Axes.Both; - CornerRadius = LabelledDrawable.CORNER_RADIUS; + CornerRadius = corner_radius; Masking = true; } @@ -63,7 +70,7 @@ namespace osu.Game.Screens.Edit.Timing for (int i = 0; i < total_waveforms; i++) { - AddInternal(new WaveformRow + AddInternal(new WaveformRow(i == total_waveforms / 2) { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Both, @@ -81,72 +88,112 @@ namespace osu.Game.Screens.Edit.Timing Width = 3, }); + AddInternal(lockedOverlay = new LockedOverlay()); + selectedGroup.BindValueChanged(_ => updateTimingGroup(), true); controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups); controlPointGroups.BindCollectionChanged((_, __) => updateTimingGroup()); - beatLength.BindValueChanged(_ => showFrom(lastDisplayedBeatIndex), true); + beatLength.BindValueChanged(_ => regenerateDisplay(true), true); + + displayLocked.BindValueChanged(locked => + { + if (locked.NewValue) + lockedOverlay.Show(); + else + lockedOverlay.Hide(); + }, true); } private void updateTimingGroup() { beatLength.UnbindBindings(); - selectedGroupStartTime = 0; - selectedGroupEndTime = beatmap.Value.Track.Length; - var tcp = selectedGroup.Value?.ControlPoints.OfType().FirstOrDefault(); if (tcp == null) { timingPoint = new TimingControlPoint(); + // During movement of a control point's offset, this clause can be hit momentarily, + // as moving a control point is implemented by removing it and inserting it at the new time. + // We don't want to reset the `selectedGroupStartTime` here as we rely on having the + // last value to update the waveform display below. + selectedGroupEndTime = beatmap.Value.Track.Length; return; } timingPoint = tcp; beatLength.BindTo(timingPoint.BeatLengthBindable); - selectedGroupStartTime = selectedGroup.Value?.Time ?? 0; + double? newStartTime = selectedGroup.Value?.Time; + double? offsetChange = newStartTime - selectedGroupStartTime; var nextGroup = editorBeatmap.ControlPointInfo.TimingPoints .SkipWhile(g => g != tcp) .Skip(1) .FirstOrDefault(); - if (nextGroup != null) - selectedGroupEndTime = nextGroup.Time; + selectedGroupStartTime = newStartTime ?? 0; + selectedGroupEndTime = nextGroup?.Time ?? beatmap.Value.Track.Length; + + if (newStartTime.HasValue && offsetChange.HasValue) + { + // The offset of the selected point may have changed. + // This handles the case the user has locked the view and expects the display to update with this change. + showFromTime(displayedTime + offsetChange.Value, true); + } } protected override bool OnHover(HoverEvent e) => true; protected override bool OnMouseMove(MouseMoveEvent e) { - float trackLength = (float)beatmap.Value.Track.Length; - int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength); + if (!displayLocked.Value) + { + float trackLength = (float)beatmap.Value.Track.Length; + int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength); - Scheduler.AddOnce(showFrom, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable)); + Scheduler.AddOnce(showFromBeat, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable)); + } return base.OnMouseMove(e); } + protected override bool OnClick(ClickEvent e) + { + displayLocked.Toggle(); + return true; + } + protected override void Update() { base.Update(); - if (!IsHovered) + if (!IsHovered && !displayLocked.Value) { int currentBeat = (int)Math.Floor((editorClock.CurrentTimeAccurate - selectedGroupStartTime) / timingPoint.BeatLength); - showFrom(currentBeat); + showFromBeat(currentBeat); } } - private void showFrom(int beatIndex) + private void showFromBeat(int beatIndex) => + showFromTime(selectedGroupStartTime + beatIndex * timingPoint.BeatLength, false); + + private void showFromTime(double time, bool animated) { - if (lastDisplayedBeatIndex == beatIndex) + if (displayedTime == time) return; + displayedTime = time; + regenerateDisplay(animated); + } + + private void regenerateDisplay(bool animated) + { + double index = (displayedTime - selectedGroupStartTime) / timingPoint.BeatLength; + // Chosen as a pretty usable number across all BPMs. // Optimally we'd want this to scale with the BPM in question, but performing // scaling of the display is both expensive in resampling, and decreases usability @@ -156,38 +203,115 @@ namespace osu.Game.Screens.Edit.Timing float trackLength = (float)beatmap.Value.Track.Length; float scale = trackLength / visible_width; + const int start_offset = total_waveforms / 2; + // Start displaying from before the current beat - beatIndex -= total_waveforms / 2; + index -= start_offset; foreach (var row in InternalChildren.OfType()) { // offset to the required beat index. - double time = selectedGroupStartTime + beatIndex * timingPoint.BeatLength; + double time = selectedGroupStartTime + index * timingPoint.BeatLength; float offset = (float)(time - visible_width / 2) / trackLength * scale; row.Alpha = time < selectedGroupStartTime || time > selectedGroupEndTime ? 0.2f : 1; - row.WaveformOffset = -offset; + row.WaveformOffsetTo(-offset, animated); row.WaveformScale = new Vector2(scale, 1); - row.BeatIndex = beatIndex++; + row.BeatIndex = (int)Math.Floor(index); + + index++; + } + } + + internal class LockedOverlay : CompositeDrawable + { + private OsuSpriteText text = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = corner_radius; + BorderColour = colours.Red; + BorderThickness = 3; + Alpha = 0; + + InternalChildren = new Drawable[] + { + new Box + { + AlwaysPresent = true, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colours.Red, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Colour = colours.GrayF, + Text = "Locked", + Margin = new MarginPadding(5), + Shadow = false, + Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), + } + } + }, + }; } - lastDisplayedBeatIndex = beatIndex; + public override void Show() + { + this.FadeIn(100, Easing.OutQuint); + + text + .FadeIn().Then().Delay(600) + .FadeOut().Then().Delay(600) + .Loop(); + } + + public override void Hide() + { + this.FadeOut(100, Easing.OutQuint); + } } internal class WaveformRow : CompositeDrawable { + private readonly bool isMainRow; private OsuSpriteText beatIndexText = null!; private WaveformGraph waveformGraph = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + public WaveformRow(bool isMainRow) + { + this.isMainRow = isMainRow; + } + [BackgroundDependencyLoader] private void load(IBindable beatmap) { InternalChildren = new Drawable[] { + new Box + { + Colour = colourProvider.Background3, + Alpha = isMainRow ? 1 : 0, + RelativeSizeAxes = Axes.Both, + }, waveformGraph = new WaveformGraph { RelativeSizeAxes = Axes.Both, @@ -212,7 +336,15 @@ namespace osu.Game.Screens.Edit.Timing public int BeatIndex { set => beatIndexText.Text = value.ToString(); } public Vector2 WaveformScale { set => waveformGraph.Scale = value; } - public float WaveformOffset { set => waveformGraph.X = value; } + + public void WaveformOffsetTo(float value, bool animated) => + this.TransformTo(nameof(waveformOffset), value, animated ? 300 : 0, Easing.OutQuint); + + private float waveformOffset + { + get => waveformGraph.X; + set => waveformGraph.X = value; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index 35c903eb0c..0015cf8bf9 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -55,7 +55,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private void load(AudioManager audio) { sampleSelect = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); - sampleJoin = audio.Samples.Get($@"UI/{HoverSampleSet.Submit.GetDescription()}-select"); + sampleJoin = audio.Samples.Get($@"UI/{HoverSampleSet.Button.GetDescription()}-select"); AddRangeInternal(new Drawable[] { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 3bf8a09cb9..43b128b971 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -82,7 +82,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); // todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area. - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(GameplayState.Ruleset.RulesetInfo, ScoreProcessor, users), l => + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(users), l => { if (!LoadedBeatmapSuccessfully) return; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs index 4545913db8..4e9ab07e4c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs @@ -2,19 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; using osu.Framework.Timing; using osu.Game.Online.Multiplayer; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { public class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard { - public MultiSpectatorLeaderboard(RulesetInfo ruleset, [NotNull] ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) - : base(ruleset, scoreProcessor, users) + public MultiSpectatorLeaderboard(MultiplayerRoomUser[] users) + : base(users) { } @@ -23,52 +20,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate if (!UserScores.TryGetValue(userId, out var data)) throw new ArgumentException(@"Provided user is not tracked by this leaderboard", nameof(userId)); - ((SpectatingTrackedUserData)data).Clock = clock; + data.ScoreProcessor.ReferenceClock = clock; } - public void RemoveClock(int userId) - { - if (!UserScores.TryGetValue(userId, out var data)) - throw new ArgumentException(@"Provided user is not tracked by this leaderboard", nameof(userId)); - - ((SpectatingTrackedUserData)data).Clock = null; - } - - protected override TrackedUserData CreateUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) => new SpectatingTrackedUserData(user, ruleset, scoreProcessor); - protected override void Update() { base.Update(); foreach (var (_, data) in UserScores) - data.UpdateScore(); - } - - private class SpectatingTrackedUserData : TrackedUserData - { - [CanBeNull] - public IClock Clock; - - public SpectatingTrackedUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) - : base(user, ruleset, scoreProcessor) - { - } - - public override void UpdateScore() - { - if (Frames.Count == 0) - return; - - if (Clock == null) - return; - - int frameIndex = Frames.BinarySearch(new TimedFrame(Clock.CurrentTime)); - if (frameIndex < 0) - frameIndex = ~frameIndex; - frameIndex = Math.Clamp(frameIndex - 1, 0, Frames.Count - 1); - - SetFrame(Frames[frameIndex]); - } + data.ScoreProcessor.UpdateScore(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 2d03276fe5..d9c19cdfdd 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -128,12 +128,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate syncManager.AddPlayerClock(instances[i].GameplayClock); } - // Todo: This is not quite correct - it should be per-user to adjust for other mod combinations. - var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - var scoreProcessor = Ruleset.Value.CreateInstance().CreateScoreProcessor(); - scoreProcessor.ApplyBeatmap(playableBeatmap); - - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(Ruleset.Value, scoreProcessor, users) + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(users) { Expanded = { Value = true }, }, l => @@ -240,7 +235,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate instance.FadeColour(colours.Gray4, 400, Easing.OutQuint); syncManager.RemovePlayerClock(instance.GameplayClock); - leaderboard.RemoveClock(userId); } public override bool OnBackButton() diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index c6a072da74..586cdcda51 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; using osu.Framework.Bindables; @@ -8,10 +10,9 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -#nullable enable - namespace osu.Game.Screens.Play { /// @@ -39,6 +40,8 @@ namespace osu.Game.Screens.Play /// public readonly Score Score; + public readonly ScoreProcessor ScoreProcessor; + /// /// Whether gameplay completed without the user failing. /// @@ -61,7 +64,7 @@ namespace osu.Game.Screens.Play private readonly Bindable lastJudgementResult = new Bindable(); - public GameplayState(IBeatmap beatmap, Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null) + public GameplayState(IBeatmap beatmap, Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null, ScoreProcessor? scoreProcessor = null) { Beatmap = beatmap; Ruleset = ruleset; @@ -72,7 +75,8 @@ namespace osu.Game.Screens.Play Ruleset = ruleset.RulesetInfo } }; - Mods = mods ?? ArraySegment.Empty; + Mods = mods ?? Array.Empty(); + ScoreProcessor = scoreProcessor ?? ruleset.CreateScoreProcessor(); } /// diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 41b40e9a91..5ee6000cf0 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -17,9 +18,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Spectator; -using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD @@ -43,8 +42,6 @@ namespace osu.Game.Screens.Play.HUD [Resolved] private UserLookupCache userLookupCache { get; set; } - private readonly RulesetInfo ruleset; - private readonly ScoreProcessor scoreProcessor; private readonly MultiplayerRoomUser[] playingUsers; private Bindable scoringMode; @@ -55,57 +52,56 @@ namespace osu.Game.Screens.Play.HUD /// /// Construct a new leaderboard. /// - /// The ruleset. - /// A score processor instance to handle score calculation for scores of users in the match. /// IDs of all users in this match. - public MultiplayerGameplayLeaderboard(RulesetInfo ruleset, ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) + public MultiplayerGameplayLeaderboard(MultiplayerRoomUser[] users) { - // todo: this will eventually need to be created per user to support different mod combinations. - this.ruleset = ruleset; - this.scoreProcessor = scoreProcessor; - playingUsers = users; } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, IAPIProvider api) + private void load(OsuConfigManager config, IAPIProvider api, CancellationToken cancellationToken) { scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); foreach (var user in playingUsers) { - var trackedUser = CreateUserData(user, ruleset, scoreProcessor); - - trackedUser.ScoringMode.BindTo(scoringMode); - trackedUser.Score.BindValueChanged(_ => Scheduler.AddOnce(updateTotals)); + var scoreProcessor = new SpectatorScoreProcessor(user.UserID); + scoreProcessor.Mode.BindTo(scoringMode); + scoreProcessor.TotalScore.BindValueChanged(_ => Scheduler.AddOnce(updateTotals)); + AddInternal(scoreProcessor); + var trackedUser = new TrackedUserData(user, scoreProcessor); UserScores[user.UserID] = trackedUser; if (trackedUser.Team is int team && !TeamScores.ContainsKey(team)) TeamScores.Add(team, new BindableLong()); } - userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray()).ContinueWith(task => Schedule(() => - { - var users = task.GetResultSafely(); + userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray(), cancellationToken) + .ContinueWith(task => + { + Schedule(() => + { + var users = task.GetResultSafely(); - for (int i = 0; i < users.Length; i++) - { - var user = users[i] ?? new APIUser - { - Id = playingUsers[i].UserID, - Username = "Unknown user", - }; + for (int i = 0; i < users.Length; i++) + { + var user = users[i] ?? new APIUser + { + Id = playingUsers[i].UserID, + Username = "Unknown user", + }; - var trackedUser = UserScores[user.Id]; + var trackedUser = UserScores[user.Id]; - var leaderboardScore = Add(user, user.Id == api.LocalUser.Value.Id); - leaderboardScore.Accuracy.BindTo(trackedUser.Accuracy); - leaderboardScore.TotalScore.BindTo(trackedUser.Score); - leaderboardScore.Combo.BindTo(trackedUser.CurrentCombo); - leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); - } - })); + var leaderboardScore = Add(user, user.Id == api.LocalUser.Value.Id); + leaderboardScore.Accuracy.BindTo(trackedUser.ScoreProcessor.Accuracy); + leaderboardScore.TotalScore.BindTo(trackedUser.ScoreProcessor.TotalScore); + leaderboardScore.Combo.BindTo(trackedUser.ScoreProcessor.Combo); + leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); + } + }); + }, cancellationToken); } protected override void LoadComplete() @@ -118,20 +114,15 @@ namespace osu.Game.Screens.Play.HUD spectatorClient.WatchUser(user.UserID); if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(user.UserID)) - usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { user.UserID })); + playingUsersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { user.UserID })); } // bind here is to support players leaving the match. // new players are not supported. playingUserIds.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); - playingUserIds.BindCollectionChanged(usersChanged); - - // this leaderboard should be guaranteed to be completely loaded before the gameplay starts (is a prerequisite in MultiplayerPlayer). - spectatorClient.OnNewFrames += handleIncomingFrames; + playingUserIds.BindCollectionChanged(playingUsersChanged); } - protected virtual TrackedUserData CreateUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) => new TrackedUserData(user, ruleset, scoreProcessor); - protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) { var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked); @@ -157,7 +148,7 @@ namespace osu.Game.Screens.Play.HUD } } - private void usersChanged(object sender, NotifyCollectionChangedEventArgs e) + private void playingUsersChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { @@ -174,15 +165,6 @@ namespace osu.Game.Screens.Play.HUD } } - private void handleIncomingFrames(int userId, FrameDataBundle bundle) => Schedule(() => - { - if (!UserScores.TryGetValue(userId, out var trackedData)) - return; - - trackedData.Frames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header)); - trackedData.UpdateScore(); - }); - private void updateTotals() { if (!hasTeams) @@ -196,7 +178,7 @@ namespace osu.Game.Screens.Play.HUD continue; if (TeamScores.TryGetValue(u.Team.Value, out var team)) - team.Value += (int)Math.Round(u.Score.Value); + team.Value += (int)Math.Round(u.ScoreProcessor.TotalScore.Value); } } @@ -207,83 +189,26 @@ namespace osu.Game.Screens.Play.HUD if (spectatorClient != null) { foreach (var user in playingUsers) - { spectatorClient.StopWatchingUser(user.UserID); - } - - spectatorClient.OnNewFrames -= handleIncomingFrames; } } protected class TrackedUserData { public readonly MultiplayerRoomUser User; - public readonly ScoreProcessor ScoreProcessor; + public readonly SpectatorScoreProcessor ScoreProcessor; - public readonly BindableDouble Score = new BindableDouble(); - public readonly BindableDouble Accuracy = new BindableDouble(1); - public readonly BindableInt CurrentCombo = new BindableInt(); public readonly BindableBool UserQuit = new BindableBool(); - public readonly IBindable ScoringMode = new Bindable(); - - public readonly List Frames = new List(); - public int? Team => (User.MatchState as TeamVersusUserState)?.TeamID; - private readonly ScoreInfo scoreInfo; - - public TrackedUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) + public TrackedUserData(MultiplayerRoomUser user, SpectatorScoreProcessor scoreProcessor) { User = user; ScoreProcessor = scoreProcessor; - - scoreInfo = new ScoreInfo { Ruleset = ruleset }; - - ScoringMode.BindValueChanged(_ => UpdateScore()); } public void MarkUserQuit() => UserQuit.Value = true; - - public virtual void UpdateScore() - { - if (Frames.Count == 0) - return; - - SetFrame(Frames.Last()); - } - - protected void SetFrame(TimedFrame frame) - { - var header = frame.Header; - - scoreInfo.MaxCombo = header.MaxCombo; - scoreInfo.Statistics = header.Statistics; - - Score.Value = ScoreProcessor.ComputePartialScore(ScoringMode.Value, scoreInfo); - - Accuracy.Value = header.Accuracy; - CurrentCombo.Value = header.Combo; - } - } - - protected class TimedFrame : IComparable - { - public readonly double Time; - public readonly FrameHeader Header; - - public TimedFrame(double time) - { - Time = time; - } - - public TimedFrame(double time, FrameHeader header) - { - Time = time; - Header = header; - } - - public int CompareTo(TimedFrame other) => Time.CompareTo(other.Time); } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 51c1e6b43b..dfc0fa1d1d 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -213,8 +213,8 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(DrawableRuleset); ScoreProcessor = ruleset.CreateScoreProcessor(); - ScoreProcessor.ApplyBeatmap(playableBeatmap); ScoreProcessor.Mods.Value = gameplayMods; + ScoreProcessor.ApplyBeatmap(playableBeatmap); dependencies.CacheAs(ScoreProcessor); @@ -237,7 +237,7 @@ namespace osu.Game.Screens.Play Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; - dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score)); + dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor)); var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 5b3129dad6..b924fbd5df 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -159,6 +159,8 @@ namespace osu.Game.Screens.Ranking.Expanded Origin = Anchor.TopCentre, Text = beatmap.DifficultyName, Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, }, new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) { diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index a59f14647d..e62b285966 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Caching; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -97,6 +98,8 @@ namespace osu.Game.Screens.Select protected readonly CarouselScrollContainer Scroll; + private readonly NoResultsPlaceholder noResultsPlaceholder; + private IEnumerable beatmapSets => root.Children.OfType(); // todo: only used for testing, maybe remove. @@ -170,7 +173,8 @@ namespace osu.Game.Screens.Select Scroll = new CarouselScrollContainer { RelativeSizeAxes = Axes.Both, - } + }, + noResultsPlaceholder = new NoResultsPlaceholder() } }; } @@ -633,7 +637,7 @@ namespace osu.Game.Screens.Select protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) { // handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed). - if ((invalidation & Invalidation.Layout) > 0) + if (invalidation.HasFlagFast(Invalidation.DrawSize)) itemsCache.Invalidate(); return base.OnInvalidate(invalidation, source); @@ -648,8 +652,18 @@ namespace osu.Game.Screens.Select // First we iterate over all non-filtered carousel items and populate their // vertical position data. if (revalidateItems) + { updateYPositions(); + if (visibleItems.Count == 0) + { + noResultsPlaceholder.Filter = activeCriteria; + noResultsPlaceholder.Show(); + } + else + noResultsPlaceholder.Hide(); + } + // if there is a pending scroll action we apply it without animation and transfer the difference in position to the panels. // this is intentionally applied before updating the visible range below, to avoid animating new items (sourced from pool) from locations off-screen, as it looks bad. if (pendingScrollOperation != PendingScrollOperation.None) diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 9772b1feb3..98b885eb43 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -8,6 +8,7 @@ using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -244,7 +245,7 @@ namespace osu.Game.Screens.Select.Carousel } if (hideRequested != null) - items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmapInfo))); + items.Add(new OsuMenuItem(CommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo))); return items.ToArray(); } diff --git a/osu.Game/Screens/Select/Filter/SortMode.cs b/osu.Game/Screens/Select/Filter/SortMode.cs index 5dfa2a2664..3dd3381059 100644 --- a/osu.Game/Screens/Select/Filter/SortMode.cs +++ b/osu.Game/Screens/Select/Filter/SortMode.cs @@ -15,7 +15,7 @@ namespace osu.Game.Screens.Select.Filter [Description("Author")] Author, - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatsBpm))] + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))] BPM, [Description("Date Added")] @@ -28,10 +28,10 @@ namespace osu.Game.Screens.Select.Filter Length, // todo: pending support (https://github.com/ppy/osu/issues/4917) - // [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchFiltersRank))] + // [Description("Rank Achieved")] // RankAchieved, - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoSource))] + [Description("Source")] Source, [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingTitle))] diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 9cb178ca8b..1587f43258 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -66,7 +66,7 @@ namespace osu.Game.Screens.Select private readonly Box light; public FooterButton() - : base(HoverSampleSet.Button) + : base(HoverSampleSet.Toolbar) { AutoSizeAxes = Axes.Both; Shear = SHEAR; diff --git a/osu.Game/Screens/Select/ImportFromStablePopup.cs b/osu.Game/Screens/Select/ImportFromStablePopup.cs deleted file mode 100644 index d8137432bd..0000000000 --- a/osu.Game/Screens/Select/ImportFromStablePopup.cs +++ /dev/null @@ -1,33 +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; -using osu.Framework.Graphics.Sprites; -using osu.Game.Overlays.Dialog; - -namespace osu.Game.Screens.Select -{ - public class ImportFromStablePopup : PopupDialog - { - public ImportFromStablePopup(Action importFromStable) - { - HeaderText = @"You have no beatmaps!"; - BodyText = "Would you like to import your beatmaps, skins, collections and scores from an existing osu!stable installation?\nThis will create a second copy of all files on disk."; - - Icon = FontAwesome.Solid.Plane; - - Buttons = new PopupDialogButton[] - { - new PopupDialogOkButton - { - Text = @"Yes please!", - Action = importFromStable - }, - new PopupDialogCancelButton - { - Text = @"No, I'd like to start from scratch", - }, - }; - } - } -} diff --git a/osu.Game/Screens/Select/NoResultsPlaceholder.cs b/osu.Game/Screens/Select/NoResultsPlaceholder.cs new file mode 100644 index 0000000000..28a0541a22 --- /dev/null +++ b/osu.Game/Screens/Select/NoResultsPlaceholder.cs @@ -0,0 +1,145 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Select +{ + public class NoResultsPlaceholder : VisibilityContainer + { + private FilterCriteria? filter; + + private LinkFlowContainer textFlow = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private FirstRunSetupOverlay? firstRunSetupOverlay { get; set; } + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + public FilterCriteria Filter + { + set + { + if (filter == value) + return; + + filter = value; + Scheduler.AddOnce(updateText); + } + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Masking = true; + CornerRadius = 10; + + Width = 300; + AutoSizeAxes = Axes.Y; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Gray2, + RelativeSizeAxes = Axes.Both, + }, + new SpriteIcon + { + Icon = FontAwesome.Regular.SadTear, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(10), + Size = new Vector2(50), + }, + textFlow = new LinkFlowContainer + { + Y = 60, + Padding = new MarginPadding(10), + TextAnchor = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + }; + } + + protected override void PopIn() + { + this.FadeIn(600, Easing.OutQuint); + + Scheduler.AddOnce(updateText); + } + + protected override void PopOut() + { + this.FadeOut(200, Easing.OutQuint); + } + + private void updateText() + { + // TODO: Refresh this text when new beatmaps are imported. Right now it won't get up-to-date suggestions. + + // Bounce should play every time the filter criteria is updated. + this.ScaleTo(0.9f) + .ScaleTo(1f, 1000, Easing.OutElastic); + + textFlow.Clear(); + + if (beatmaps.QueryBeatmapSet(s => !s.Protected && !s.DeletePending) == null) + { + textFlow.AddParagraph("No beatmaps found!"); + textFlow.AddParagraph(string.Empty); + + textFlow.AddParagraph("Consider using the \""); + textFlow.AddLink(FirstRunSetupOverlayStrings.FirstRunSetupTitle, () => firstRunSetupOverlay?.Show()); + textFlow.AddText("\" to download or import some beatmaps!"); + } + else + { + textFlow.AddParagraph("No beatmaps match your filter criteria!"); + textFlow.AddParagraph(string.Empty); + + if (string.IsNullOrEmpty(filter?.SearchText)) + { + // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). + // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. + if (filter?.Ruleset.OnlineID > 0 && !filter.AllowConvertedBeatmaps) + { + textFlow.AddParagraph("Beatmaps may be available by "); + textFlow.AddLink("enabling automatic conversion", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + textFlow.AddText("!"); + } + } + else + { + textFlow.AddParagraph("You can try "); + textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); + textFlow.AddText(" for this query."); + } + } + + // TODO: add clickable link to reset criteria. + } + } +} diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index 6ecb96f723..df7dd47ef3 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -77,7 +77,7 @@ namespace osu.Game.Screens.Select.Options public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); public BeatmapOptionsButton() - : base(HoverSampleSet.Submit) + : base(HoverSampleSet.Button) { Width = width; RelativeSizeAxes = Axes.Y; diff --git a/osu.Game/Screens/Select/SkinDeleteDialog.cs b/osu.Game/Screens/Select/SkinDeleteDialog.cs new file mode 100644 index 0000000000..4262118658 --- /dev/null +++ b/osu.Game/Screens/Select/SkinDeleteDialog.cs @@ -0,0 +1,42 @@ +// 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.Graphics.Sprites; +using osu.Game.Skinning; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Select +{ + public class SkinDeleteDialog : PopupDialog + { + [Resolved] + private SkinManager manager { get; set; } + + public SkinDeleteDialog(Skin skin) + { + BodyText = skin.SkinInfo.Value.Name; + Icon = FontAwesome.Regular.TrashAlt; + HeaderText = @"Confirm deletion of"; + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = @"Yes. Totally. Delete it.", + Action = () => + { + if (manager == null) + return; + + manager.Delete(skin.SkinInfo.Value); + manager.CurrentSkinInfo.SetDefault(); + }, + }, + new PopupDialogCancelButton + { + Text = @"Firetruck, I didn't mean to!", + }, + }; + } + } +} diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 8870239485..3bfdc845ab 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -28,7 +28,6 @@ using osuTK.Input; using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; @@ -37,7 +36,6 @@ using osu.Game.Graphics.UserInterface; using System.Diagnostics; using JetBrains.Annotations; using osu.Game.Screens.Play; -using osu.Game.Database; using osu.Game.Skinning; namespace osu.Game.Screens.Select @@ -59,8 +57,6 @@ namespace osu.Game.Screens.Select protected virtual bool ShowFooter => true; - protected virtual bool DisplayStableImportPrompt => legacyImportManager?.SupportsImportFromStable == true; - public override bool? AllowTrackAdjustments => true; /// @@ -94,14 +90,13 @@ namespace osu.Game.Screens.Select protected Container LeftArea { get; private set; } private BeatmapInfoWedge beatmapInfoWedge; - private IDialogOverlay dialogOverlay; + + [Resolved(canBeNull: true)] + private IDialogOverlay dialogOverlay { get; set; } [Resolved] private BeatmapManager beatmaps { get; set; } - [Resolved(CanBeNull = true)] - private LegacyImportManager legacyImportManager { get; set; } - protected ModSelectOverlay ModSelect { get; private set; } protected Sample SampleConfirm { get; private set; } @@ -127,7 +122,7 @@ namespace osu.Game.Screens.Select internal IOverlayManager OverlayManager { get; private set; } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, IDialogOverlay dialog, OsuColour colours, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) + private void load(AudioManager audio, OsuColour colours, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) { // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). transferRulesetValue(); @@ -289,26 +284,9 @@ namespace osu.Game.Screens.Select BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => clearScores(Beatmap.Value.BeatmapInfo)); } - dialogOverlay = dialog; - sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty"); sampleChangeBeatmap = audio.Samples.Get(@"SongSelect/select-expand"); SampleConfirm = audio.Samples.Get(@"SongSelect/confirm-selection"); - - if (dialogOverlay != null) - { - Schedule(() => - { - // if we have no beatmaps, let's prompt the user to import from over a stable install if he has one. - if (beatmaps.QueryBeatmapSet(s => !s.Protected && !s.DeletePending) == null && DisplayStableImportPrompt) - { - dialogOverlay.Push(new ImportFromStablePopup(() => - { - Task.Run(() => legacyImportManager.ImportFromStableAsync(StableContent.All)); - })); - } - }); - } } protected override void LoadComplete() diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index e36d5ca3c6..095763de18 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -29,6 +29,8 @@ namespace osu.Game.Skinning.Editor { public const double TRANSITION_DURATION = 500; + public const float MENU_HEIGHT = 40; + public readonly BindableList SelectedComponents = new BindableList(); protected override bool StartHidden => true; @@ -78,8 +80,6 @@ namespace osu.Game.Skinning.Editor { RelativeSizeAxes = Axes.Both; - const float menu_height = 40; - InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, @@ -102,7 +102,7 @@ namespace osu.Game.Skinning.Editor Name = "Menu container", RelativeSizeAxes = Axes.X, Depth = float.MinValue, - Height = menu_height, + Height = MENU_HEIGHT, Children = new Drawable[] { new EditorMenuBar @@ -322,7 +322,10 @@ namespace osu.Game.Skinning.Editor protected override void PopIn() { - this.FadeIn(TRANSITION_DURATION, Easing.OutQuint); + this + // align animation to happen after the majority of the ScalingContainer animation completes. + .Delay(ScalingContainer.TRANSITION_DURATION * 0.3f) + .FadeIn(TRANSITION_DURATION, Easing.OutQuint); } protected override void PopOut() diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index 497283a820..a3110ced24 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -12,6 +12,8 @@ using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Screens; +using osu.Game.Screens.Edit.Components; +using osuTK; namespace osu.Game.Skinning.Editor { @@ -33,6 +35,8 @@ namespace osu.Game.Skinning.Editor private OsuScreen lastTargetScreen; + private Vector2 lastDrawSize; + public SkinEditorOverlay(ScalingContainer scalingContainer) { this.scalingContainer = scalingContainer; @@ -81,15 +85,42 @@ namespace osu.Game.Skinning.Editor protected override void PopOut() => skinEditor?.Hide(); + protected override void Update() + { + base.Update(); + + if (game.DrawSize != lastDrawSize) + { + lastDrawSize = game.DrawSize; + updateScreenSizing(); + } + } + + private void updateScreenSizing() + { + if (skinEditor?.State.Value != Visibility.Visible) return; + + const float padding = 10; + + float relativeSidebarWidth = (EditorSidebar.WIDTH + padding) / DrawWidth; + float relativeToolbarHeight = (SkinEditorSceneLibrary.HEIGHT + SkinEditor.MENU_HEIGHT + padding) / DrawHeight; + + var rect = new RectangleF( + relativeSidebarWidth, + relativeToolbarHeight, + 1 - relativeSidebarWidth * 2, + 1f - relativeToolbarHeight - padding / DrawHeight); + + scalingContainer.SetCustomRect(rect, true); + } + private void updateComponentVisibility() { Debug.Assert(skinEditor != null); - const float toolbar_padding_requirement = 0.18f; - if (skinEditor.State.Value == Visibility.Visible) { - scalingContainer.SetCustomRect(new RectangleF(toolbar_padding_requirement, 0.2f, 0.8f - toolbar_padding_requirement, 0.7f), true); + Scheduler.AddOnce(updateScreenSizing); game?.Toolbar.Hide(); game?.CloseAllOverlays(); @@ -127,6 +158,9 @@ namespace osu.Game.Skinning.Editor private void setTarget(OsuScreen target) { + if (target == null) + return; + Debug.Assert(skinEditor != null); if (!target.IsLoaded) diff --git a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs index 2124ba9b6d..dc5a8aefc0 100644 --- a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs @@ -27,6 +27,8 @@ namespace osu.Game.Skinning.Editor { public class SkinEditorSceneLibrary : CompositeDrawable { + public const float HEIGHT = BUTTON_HEIGHT + padding * 2; + public const float BUTTON_HEIGHT = 40; private const float padding = 10; @@ -42,7 +44,7 @@ namespace osu.Game.Skinning.Editor public SkinEditorSceneLibrary() { - Height = BUTTON_HEIGHT + padding * 2; + Height = HEIGHT; } [BackgroundDependencyLoader] diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index b65ba8b04c..9524d3f615 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -303,8 +303,13 @@ namespace osu.Game.Skinning if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString(), out string val)) { // special case for handling skins which use 1 or 0 to signify a boolean state. + // ..or in some cases 2 (https://github.com/ppy/osu/issues/18579). if (typeof(TValue) == typeof(bool)) - val = val == "1" ? "true" : "false"; + { + val = bool.TryParse(val, out bool boolVal) + ? Convert.ChangeType(boolVal, typeof(bool)).ToString() + : Convert.ChangeType(Convert.ToInt32(val), typeof(bool)).ToString(); + } var bindable = new Bindable(); if (val != null) diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index 9481fc7182..34714b9dc5 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -23,7 +23,7 @@ namespace osu.Game.Skinning /// The which is being transformed. /// [NotNull] - protected internal ISkin Skin { get; } + public ISkin Skin { get; } protected LegacySkinTransformer([NotNull] ISkin skin) { diff --git a/osu.Game/Tests/Gameplay/TestGameplayState.cs b/osu.Game/Tests/Gameplay/TestGameplayState.cs index 0d00f52d15..f14f8c44ec 100644 --- a/osu.Game/Tests/Gameplay/TestGameplayState.cs +++ b/osu.Game/Tests/Gameplay/TestGameplayState.cs @@ -26,7 +26,10 @@ namespace osu.Game.Tests.Gameplay var workingBeatmap = new TestWorkingBeatmap(beatmap); var playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods); - return new GameplayState(playableBeatmap, ruleset, mods, score); + var scoreProcessor = ruleset.CreateScoreProcessor(); + scoreProcessor.ApplyBeatmap(playableBeatmap); + + return new GameplayState(playableBeatmap, ruleset, mods, score, scoreProcessor); } } } diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index b6a347a896..df3974664e 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -67,7 +68,25 @@ namespace osu.Game.Tests.Visual.OnlinePlay // To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead. var beatmapManager = dependencies.Get(); - ((DummyAPIAccess)API).HandleRequest = request => handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); + ((DummyAPIAccess)API).HandleRequest = request => + { + TaskCompletionSource tcs = new TaskCompletionSource(); + + // Because some of the handlers use realm, we need to ensure the game is still alive when firing. + // If we don't, a stray `PerformAsync` could hit an `ObjectDisposedException` if running too late. + Scheduler.Add(() => + { + bool result = handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); + tcs.SetResult(result); + }, false); + +#pragma warning disable RS0030 + // We can't GetResultSafely() here (will fail with "Can't use GetResultSafely from inside an async operation."), but Wait is safe enough due to + // the task being a TaskCompletionSource. + // Importantly, this doesn't deadlock because of the scheduler call above running inline where feasible (see the `false` argument). + return tcs.Task.Result; +#pragma warning restore RS0030 + }; }); /// diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index 8290af8f78..8fea77833e 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -44,9 +46,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay switch (request) { case CreateRoomRequest createRoomRequest: - var apiRoom = new Room(); - - apiRoom.CopyFrom(createRoomRequest.Room); + var apiRoom = cloneRoom(createRoomRequest.Room); // Passwords are explicitly not copied between rooms. apiRoom.HasPassword.Value = !string.IsNullOrEmpty(createRoomRequest.Room.Password.Value); @@ -178,12 +178,31 @@ namespace osu.Game.Tests.Visual.OnlinePlay private Room createResponseRoom(Room room, bool withParticipants) { - var responseRoom = new Room(); - responseRoom.CopyFrom(room); + var responseRoom = cloneRoom(room); + + // Password is hidden from the response, and is only propagated via HasPassword. + bool hadPassword = responseRoom.HasPassword.Value; responseRoom.Password.Value = null; + responseRoom.HasPassword.Value = hadPassword; + if (!withParticipants) responseRoom.RecentParticipants.Clear(); + return responseRoom; } + + private Room cloneRoom(Room source) + { + var result = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); + Debug.Assert(result != null); + + // Playlist item IDs aren't serialised. + if (source.CurrentPlaylistItem.Value != null) + result.CurrentPlaylistItem.Value.ID = source.CurrentPlaylistItem.Value.ID; + for (int i = 0; i < source.Playlist.Count; i++) + result.Playlist[i].ID = source.Playlist[i].ID; + + return result; + } } } diff --git a/osu.Game/Users/Drawables/ClickableAvatar.cs b/osu.Game/Users/Drawables/ClickableAvatar.cs index 0dd135b500..d85648c078 100644 --- a/osu.Game/Users/Drawables/ClickableAvatar.cs +++ b/osu.Game/Users/Drawables/ClickableAvatar.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Users.Drawables @@ -74,11 +73,6 @@ namespace osu.Game.Users.Drawables { private LocalisableString tooltip = default_tooltip_text; - public ClickableArea() - : base(HoverSampleSet.Submit) - { - } - public override LocalisableString TooltipText { get => Enabled.Value ? tooltip : default; diff --git a/osu.Game/Users/Drawables/UpdateableFlag.cs b/osu.Game/Users/Drawables/UpdateableFlag.cs index e5debc0683..d0ef760e59 100644 --- a/osu.Game/Users/Drawables/UpdateableFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableFlag.cs @@ -49,7 +49,7 @@ namespace osu.Game.Users.Drawables { RelativeSizeAxes = Axes.Both }, - new HoverClickSounds(HoverSampleSet.Submit) + new HoverClickSounds() } }; } diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 248debf1d3..40d70ca406 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -32,7 +32,7 @@ namespace osu.Game.Users protected Drawable Background { get; private set; } protected UserPanel(APIUser user) - : base(HoverSampleSet.Submit) + : base(HoverSampleSet.Button) { if (user == null) throw new ArgumentNullException(nameof(user)); diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 137bf7e0aa..c12fd607b4 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -43,14 +43,13 @@ namespace osu.Game.Utils sentrySession = SentrySdk.Init(options => { // Not setting the dsn will completely disable sentry. - if (game.IsDeployedBuild) + if (game.IsDeployedBuild && game.CreateEndpoints().WebsiteRootUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal)) options.Dsn = "https://ad9f78529cef40ac874afb95a9aca04e@sentry.ppy.sh/2"; options.AutoSessionTracking = true; options.IsEnvironmentUser = false; - // The reported release needs to match release tags on github in order for sentry - // to automatically associate and track against releases. - options.Release = game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty); + // The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml + options.Release = $"osu@{game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty)}"; }); Logger.NewEntry += processLogEntry; @@ -160,6 +159,7 @@ namespace osu.Game.Utils Game = game.Clock.CurrentTime, }; + scope.SetTag(@"beatmap", $"{beatmap.OnlineID}"); scope.SetTag(@"ruleset", ruleset.ShortName); scope.SetTag(@"os", $"{RuntimeInfo.OS} ({Environment.OSVersion})"); scope.SetTag(@"processor count", Environment.ProcessorCount.ToString()); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 79cfd7c917..63b8cf4cb5 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -29,13 +29,14 @@ + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index b1ba64beba..a0fafa635b 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,7 +61,7 @@ - + @@ -84,11 +84,11 @@ - + - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 68cf8138e2..286a1eb29f 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -790,6 +790,15 @@ See the LICENCE file in the repository root for full licence text. <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True + True + True + True + True + True + True + True + True True True True