diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs new file mode 100644 index 0000000000..c48cbd9992 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -0,0 +1,76 @@ +// 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.Game.Rulesets.UI; +using osu.Game.Rulesets.Mods; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Graphics.UserInterface; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModNoScope : Mod, IUpdatableByPlayfield, IApplicableToScoreProcessor + { + /// + /// Slightly higher than the cutoff for . + /// + private const float min_alpha = 0.0002f; + + private const float transition_duration = 100; + + public override string Name => "No Scope"; + public override string Acronym => "NS"; + public override ModType Type => ModType.Fun; + public override IconUsage? Icon => FontAwesome.Solid.EyeSlash; + public override string Description => "Where's the cursor?"; + public override double ScoreMultiplier => 1; + + private BindableNumber currentCombo; + + private float targetAlpha; + + [SettingSource( + "Hidden at combo", + "The combo count at which the cursor becomes completely hidden", + SettingControlType = typeof(SettingsSlider) + )] + public BindableInt HiddenComboCount { get; } = new BindableInt + { + Default = 10, + Value = 10, + MinValue = 0, + MaxValue = 50, + }; + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + if (HiddenComboCount.Value == 0) return; + + currentCombo = scoreProcessor.Combo.GetBoundCopy(); + currentCombo.BindValueChanged(combo => + { + targetAlpha = Math.Max(min_alpha, 1 - (float)combo.NewValue / HiddenComboCount.Value); + }, true); + } + + public virtual void Update(Playfield playfield) + { + playfield.Cursor.Alpha = (float)Interpolation.Lerp(playfield.Cursor.Alpha, targetAlpha, Math.Clamp(playfield.Time.Elapsed / transition_duration, 0, 1)); + } + } + + public class HiddenComboSlider : OsuSliderBar + { + public override LocalisableString TooltipText => Current.Value == 0 ? "always hidden" : base.TooltipText; + } +} diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index f4a93a571d..ee4712c3b8 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -192,6 +192,7 @@ namespace osu.Game.Rulesets.Osu new OsuModBarrelRoll(), new OsuModApproachDifferent(), new OsuModMuted(), + new OsuModNoScope(), }; case ModType.System: diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs new file mode 100644 index 0000000000..f4e0838be1 --- /dev/null +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -0,0 +1,54 @@ +// 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.Game.Models; +using osu.Game.Stores; + +namespace osu.Game.Tests.Database +{ + public class RulesetStoreTests : RealmTest + { + [Test] + public void TestCreateStore() + { + RunTestWithRealm((realmFactory, storage) => + { + var rulesets = new RealmRulesetStore(realmFactory, storage); + + Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); + Assert.AreEqual(4, realmFactory.Context.All().Count()); + }); + } + + [Test] + public void TestCreateStoreTwiceDoesntAddRulesetsAgain() + { + RunTestWithRealm((realmFactory, storage) => + { + var rulesets = new RealmRulesetStore(realmFactory, storage); + var rulesets2 = new RealmRulesetStore(realmFactory, storage); + + Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); + Assert.AreEqual(4, rulesets2.AvailableRulesets.Count()); + + Assert.AreEqual(rulesets.AvailableRulesets.First(), rulesets2.AvailableRulesets.First()); + Assert.AreEqual(4, realmFactory.Context.All().Count()); + }); + } + + [Test] + public void TestRetrievedRulesetsAreDetached() + { + RunTestWithRealm((realmFactory, storage) => + { + var rulesets = new RealmRulesetStore(realmFactory, storage); + + Assert.IsTrue((rulesets.AvailableRulesets.First() as RealmRuleset)?.IsManaged == false); + Assert.IsTrue((rulesets.GetRuleset(0) as RealmRuleset)?.IsManaged == false); + Assert.IsTrue((rulesets.GetRuleset("mania") as RealmRuleset)?.IsManaged == false); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs index cb7c334656..bd723eeed6 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs @@ -4,7 +4,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; -using osu.Game.Database; +using osu.Game.Overlays.Notifications; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Navigation @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestImportCreatedNotification() { - AddUntilStep("Import notification was presented", () => Game.Notifications.ChildrenOfType().Count() == 1); + AddUntilStep("Import notification was presented", () => Game.Notifications.ChildrenOfType().Count() == 1); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs index 393420e700..1b7f65f9a0 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.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; +using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { @@ -19,28 +23,62 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("create component", () => { - LabelledSliderBar component; + FillFlowContainer flow; - Child = new Container + Child = flow = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 500, AutoSizeAxes = Axes.Y, - Child = component = new LabelledSliderBar + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Current = new BindableDouble(5) + new LabelledSliderBar { - MinValue = 0, - MaxValue = 10, - Precision = 1, - } - } + Current = new BindableDouble(5) + { + MinValue = 0, + MaxValue = 10, + Precision = 1, + }, + Label = "a sample component", + Description = hasDescription ? "this text describes the component" : string.Empty, + }, + }, }; - component.Label = "a sample component"; - component.Description = hasDescription ? "this text describes the component" : string.Empty; + foreach (var colour in Enum.GetValues(typeof(OverlayColourScheme)).OfType()) + { + flow.Add(new OverlayColourContainer(colour) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new LabelledSliderBar + { + Current = new BindableDouble(5) + { + MinValue = 0, + MaxValue = 10, + Precision = 1, + }, + Label = "a sample component", + Description = hasDescription ? "this text describes the component" : string.Empty, + } + }); + } }); } + + private class OverlayColourContainer : Container + { + [Cached] + private OverlayColourProvider colourProvider; + + public OverlayColourContainer(OverlayColourScheme scheme) + { + colourProvider = new OverlayColourProvider(scheme); + } + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs new file mode 100644 index 0000000000..fb04c5bad0 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs @@ -0,0 +1,68 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneSettingsCheckbox : OsuTestScene + { + [TestCase] + public void TestCheckbox() + { + AddStep("create component", () => + { + FillFlowContainer flow; + + Child = flow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "a sample component", + }, + }, + }; + + foreach (var colour1 in Enum.GetValues(typeof(OverlayColourScheme)).OfType()) + { + flow.Add(new OverlayColourContainer(colour1) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SettingsCheckbox + { + LabelText = "a sample component", + } + }); + } + }); + } + + private class OverlayColourContainer : Container + { + [Cached] + private OverlayColourProvider colourProvider; + + public OverlayColourContainer(OverlayColourScheme scheme) + { + colourProvider = new OverlayColourProvider(scheme); + } + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 14175f251b..562cbfabf0 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -176,11 +176,6 @@ namespace osu.Game.Beatmaps } } - /// - /// Fired when the user requests to view the resulting import. - /// - public Action>> PresentImport { set => beatmapModelManager.PostImport = value; } - /// /// Delete a beatmap difficulty. /// @@ -338,5 +333,14 @@ namespace osu.Game.Beatmaps } #endregion + + #region Implementation of IPostImports + + public Action>> PostImport + { + set => beatmapModelManager.PostImport = value; + } + + #endregion } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index c235fc7728..84e33e3f36 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -30,7 +30,7 @@ namespace osu.Game.Database /// /// The model type. /// The associated file join type. - public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager, IModelFileManager, IPostImports + public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager, IModelFileManager where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete where TFileModel : class, INamedFileInfo, new() { diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index 8e658cb0f5..479f33c3b4 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -13,7 +13,7 @@ namespace osu.Game.Database /// A class which handles importing of associated models to the game store. /// /// The model type. - public interface IModelImporter : IPostNotifications + public interface IModelImporter : IPostNotifications, IPostImports where TModel : class { /// diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index c3810eb441..82d51e365e 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -5,7 +5,6 @@ using System; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Development; -using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Statistics; @@ -18,7 +17,7 @@ namespace osu.Game.Database /// /// A factory which provides both the main (update thread bound) realm context and creates contexts for async usage. /// - public class RealmContextFactory : Component, IRealmFactory + public class RealmContextFactory : IDisposable, IRealmFactory { private readonly Storage storage; @@ -79,10 +78,11 @@ namespace osu.Game.Database /// public bool Compact() => Realm.Compact(getConfiguration()); - protected override void Update() + /// + /// Perform a blocking refresh on the main realm context. + /// + public void Refresh() { - base.Update(); - lock (contextLock) { if (context?.Refresh() == true) @@ -92,7 +92,7 @@ namespace osu.Game.Database public Realm CreateContext() { - if (IsDisposed) + if (isDisposed) throw new ObjectDisposedException(nameof(RealmContextFactory)); try @@ -132,7 +132,7 @@ namespace osu.Game.Database /// An which should be disposed to end the blocking section. public IDisposable BlockAllOperations() { - if (IsDisposed) + if (isDisposed) throw new ObjectDisposedException(nameof(RealmContextFactory)); if (!ThreadSafety.IsUpdateThread) @@ -176,21 +176,23 @@ namespace osu.Game.Database }); } - protected override void Dispose(bool isDisposing) + private bool isDisposed; + + public void Dispose() { lock (contextLock) { context?.Dispose(); } - if (!IsDisposed) + if (!isDisposed) { // intentionally block context creation indefinitely. this ensures that nothing can start consuming a new context after disposal. contextCreationLock.Wait(); contextCreationLock.Dispose(); - } - base.Dispose(isDisposing); + isDisposed = true; + } } } } diff --git a/osu.Game/Graphics/UserInterface/Nub.cs b/osu.Game/Graphics/UserInterface/Nub.cs index 6807d007bb..8f0fed580f 100644 --- a/osu.Game/Graphics/UserInterface/Nub.cs +++ b/osu.Game/Graphics/UserInterface/Nub.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -12,63 +13,74 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface { - public class Nub : CircularContainer, IHasCurrentValue, IHasAccentColour + public class Nub : CompositeDrawable, IHasCurrentValue, IHasAccentColour { - public const float COLLAPSED_SIZE = 20; - public const float EXPANDED_SIZE = 40; + public const float HEIGHT = 15; + + public const float EXPANDED_SIZE = 50; private const float border_width = 3; - private const double animate_in_duration = 150; + private const double animate_in_duration = 200; private const double animate_out_duration = 500; + private readonly Box fill; + private readonly Container main; + public Nub() { - Box fill; + Size = new Vector2(EXPANDED_SIZE, HEIGHT); - Size = new Vector2(COLLAPSED_SIZE, 12); - - BorderColour = Color4.White; - BorderThickness = border_width; - - Masking = true; - - Children = new[] + InternalChildren = new[] { - fill = new Box + main = new CircularContainer { + BorderColour = Color4.White, + BorderThickness = border_width, + Masking = true, RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + } }, }; - - Current.ValueChanged += filled => - { - fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); - this.TransformTo(nameof(BorderThickness), filled.NewValue ? 8.5f : border_width, 200, Easing.OutQuint); - }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) { - AccentColour = colours.Pink; - GlowingAccentColour = colours.PinkLighter; - GlowColour = colours.PinkDarker; + AccentColour = colourProvider?.Highlight1 ?? colours.Pink; + GlowingAccentColour = colourProvider?.Highlight1.Lighten(0.2f) ?? colours.PinkLighter; + GlowColour = colourProvider?.Highlight1 ?? colours.PinkLighter; - EdgeEffect = new EdgeEffectParameters + main.EdgeEffect = new EdgeEffectParameters { Colour = GlowColour.Opacity(0), Type = EdgeEffectType.Glow, - Radius = 10, - Roundness = 8, + Radius = 8, + Roundness = 5, }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(onCurrentValueChanged, true); + } + private bool glowing; public bool Glowing @@ -80,28 +92,17 @@ namespace osu.Game.Graphics.UserInterface if (value) { - this.FadeColour(GlowingAccentColour, animate_in_duration, Easing.OutQuint); - FadeEdgeEffectTo(1, animate_in_duration, Easing.OutQuint); + main.FadeColour(GlowingAccentColour, animate_in_duration, Easing.OutQuint); + main.FadeEdgeEffectTo(0.2f, animate_in_duration, Easing.OutQuint); } else { - FadeEdgeEffectTo(0, animate_out_duration); - this.FadeColour(AccentColour, animate_out_duration); + main.FadeEdgeEffectTo(0, animate_out_duration, Easing.OutQuint); + main.FadeColour(AccentColour, animate_out_duration, Easing.OutQuint); } } } - public bool Expanded - { - set - { - if (value) - this.ResizeTo(new Vector2(EXPANDED_SIZE, 12), animate_in_duration, Easing.OutQuint); - else - this.ResizeTo(new Vector2(COLLAPSED_SIZE, 12), animate_out_duration, Easing.OutQuint); - } - } - private readonly Bindable current = new Bindable(); public Bindable Current @@ -126,7 +127,7 @@ namespace osu.Game.Graphics.UserInterface { accentColour = value; if (!Glowing) - Colour = value; + main.Colour = value; } } @@ -139,7 +140,7 @@ namespace osu.Game.Graphics.UserInterface { glowingAccentColour = value; if (Glowing) - Colour = value; + main.Colour = value; } } @@ -152,10 +153,22 @@ namespace osu.Game.Graphics.UserInterface { glowColour = value; - var effect = EdgeEffect; + var effect = main.EdgeEffect; effect.Colour = Glowing ? value : value.Opacity(0); - EdgeEffect = effect; + main.EdgeEffect = effect; } } + + private void onCurrentValueChanged(ValueChangedEvent filled) + { + fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); + + if (filled.NewValue) + main.ResizeWidthTo(1, animate_in_duration, Easing.OutElasticHalf); + else + main.ResizeWidthTo(0.9f, animate_out_duration, Easing.OutElastic); + + main.TransformTo(nameof(BorderThickness), filled.NewValue ? 8.5f : border_width, 200, Easing.OutQuint); + } } } diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index 5f2d884cd7..e8f80dec57 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -9,16 +9,11 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { public class OsuCheckbox : Checkbox { - public Color4 CheckedColor { get; set; } = Color4.Cyan; - public Color4 UncheckedColor { get; set; } = Color4.White; - public int FadeDuration { get; set; } - /// /// Whether to play sounds when the state changes as a result of user interaction. /// @@ -104,14 +99,12 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { Nub.Glowing = true; - Nub.Expanded = true; return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { Nub.Glowing = false; - Nub.Expanded = false; base.OnHoverLost(e); } diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index f85f9327fa..6963f7335e 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -3,11 +3,13 @@ using System; using System.Globalization; +using JetBrains.Annotations; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -16,6 +18,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface { @@ -52,34 +55,63 @@ namespace osu.Game.Graphics.UserInterface { accentColour = value; leftBox.Colour = value; + } + } + + private Colour4 backgroundColour; + + public Color4 BackgroundColour + { + get => backgroundColour; + set + { + backgroundColour = value; rightBox.Colour = value; } } public OsuSliderBar() { - Height = 12; - RangePadding = 20; + Height = Nub.HEIGHT; + RangePadding = Nub.EXPANDED_SIZE / 2; Children = new Drawable[] { - leftBox = new Box + new Container { - Height = 2, - EdgeSmoothness = new Vector2(0, 0.5f), - Position = new Vector2(2, 0), - RelativeSizeAxes = Axes.None, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - }, - rightBox = new Box - { - Height = 2, - EdgeSmoothness = new Vector2(0, 0.5f), - Position = new Vector2(-2, 0), - RelativeSizeAxes = Axes.None, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Alpha = 0.5f, + Padding = new MarginPadding { Horizontal = 2 }, + Child = new CircularContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Masking = true, + CornerRadius = 5f, + Children = new Drawable[] + { + leftBox = new Box + { + Height = 5, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.None, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + rightBox = new Box + { + Height = 5, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.None, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Alpha = 0.5f, + }, + }, + }, }, nubContainer = new Container { @@ -88,7 +120,7 @@ namespace osu.Game.Graphics.UserInterface { Origin = Anchor.TopCentre, RelativePositionAxes = Axes.X, - Expanded = true, + Current = { Value = true } }, }, new HoverClickSounds() @@ -97,11 +129,12 @@ namespace osu.Game.Graphics.UserInterface Current.DisabledChanged += disabled => { Alpha = disabled ? 0.3f : 1; }; } - [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load(AudioManager audio, [CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) { sample = audio.Samples.Get(@"UI/notch-tick"); - AccentColour = colours.Pink; + AccentColour = colourProvider?.Highlight1 ?? colours.Pink; + BackgroundColour = colourProvider?.Background5 ?? colours.Pink.Opacity(0.5f); } protected override void Update() @@ -119,26 +152,25 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { - Nub.Glowing = true; + updateGlow(); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - Nub.Glowing = false; + updateGlow(); base.OnHoverLost(e); } - protected override bool OnMouseDown(MouseDownEvent e) + protected override void OnDragEnd(DragEndEvent e) { - Nub.Current.Value = true; - return base.OnMouseDown(e); + updateGlow(); + base.OnDragEnd(e); } - protected override void OnMouseUp(MouseUpEvent e) + private void updateGlow() { - Nub.Current.Value = false; - base.OnMouseUp(e); + Nub.Glowing = IsHovered || IsDragged; } protected override void OnUserChange(T value) diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs index 27e28f1e03..23ebc6e98d 100644 --- a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -23,10 +25,10 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour colours) { - BackgroundColour = colours.Blue3; + BackgroundColour = overlayColourProvider?.Highlight1 ?? colours.Blue3; } protected override void LoadComplete() diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 75bbaec0ef..28505f6b0e 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -374,7 +374,7 @@ namespace osu.Game.Online.Multiplayer UserJoined?.Invoke(user); RoomUpdated?.Invoke(); - }, false); + }); } Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) => diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 5960451c9c..820597488b 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -643,7 +643,7 @@ namespace osu.Game SkinManager.PostNotification = n => Notifications.Post(n); BeatmapManager.PostNotification = n => Notifications.Post(n); - BeatmapManager.PresentImport = items => PresentBeatmap(items.First().Value); + BeatmapManager.PostImport = items => PresentBeatmap(items.First().Value); ScoreManager.PostNotification = n => Notifications.Post(n); ScoreManager.PostImport = items => PresentScore(items.First().Value); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 09eb482d16..f6ec22a536 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -187,8 +187,6 @@ namespace osu.Game dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client")); - AddInternal(realmFactory); - dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); @@ -529,6 +527,7 @@ namespace osu.Game LocalConfig?.Dispose(); contextFactory?.FlushConnections(); + realmFactory?.Dispose(); } } } diff --git a/osu.Game/Overlays/Settings/SettingsSlider.cs b/osu.Game/Overlays/Settings/SettingsSlider.cs index 9fc3379b94..bb9c0dd4d7 100644 --- a/osu.Game/Overlays/Settings/SettingsSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsSlider.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Settings { protected override Drawable CreateControl() => new TSlider { - Margin = new MarginPadding { Top = 5, Bottom = 5 }, + Margin = new MarginPadding { Vertical = 10 }, RelativeSizeAxes = Axes.X }; diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index cf22a8fda4..8494cdcd22 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -25,7 +25,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring { - public class ScoreManager : IModelManager, IModelFileManager, IModelDownloader, ICanAcceptFiles, IPostImports + public class ScoreManager : IModelManager, IModelFileManager, IModelDownloader, ICanAcceptFiles { private readonly Scheduler scheduler; private readonly Func difficulties; diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs index 8a4acacb24..26887327cd 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Play.PlayerSettings { Nub.AccentColour = colours.Yellow; Nub.GlowingAccentColour = colours.YellowLighter; - Nub.GlowColour = colours.YellowDarker; + Nub.GlowColour = colours.YellowDark; } } } diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs index c8e281195a..216e46d429 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Play.PlayerSettings AccentColour = colours.Yellow; Nub.AccentColour = colours.Yellow; Nub.GlowingAccentColour = colours.YellowLighter; - Nub.GlowColour = colours.YellowDarker; + Nub.GlowColour = colours.YellowDark; } } } diff --git a/osu.Game/Stores/RealmRulesetStore.cs b/osu.Game/Stores/RealmRulesetStore.cs new file mode 100644 index 0000000000..27eb5d797f --- /dev/null +++ b/osu.Game/Stores/RealmRulesetStore.cs @@ -0,0 +1,263 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using osu.Framework; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Models; +using osu.Game.Rulesets; + +#nullable enable + +namespace osu.Game.Stores +{ + public class RealmRulesetStore : IDisposable + { + private readonly RealmContextFactory realmFactory; + + private const string ruleset_library_prefix = @"osu.Game.Rulesets"; + + private readonly Dictionary loadedAssemblies = new Dictionary(); + + /// + /// All available rulesets. + /// + public IEnumerable AvailableRulesets => availableRulesets; + + private readonly List availableRulesets = new List(); + + public RealmRulesetStore(RealmContextFactory realmFactory, Storage? storage = null) + { + this.realmFactory = realmFactory; + + // On android in release configuration assemblies are loaded from the apk directly into memory. + // We cannot read assemblies from cwd, so should check loaded assemblies instead. + loadFromAppDomain(); + + // This null check prevents Android from attempting to load the rulesets from disk, + // as the underlying path "AppContext.BaseDirectory", despite being non-nullable, it returns null on android. + // See https://github.com/xamarin/xamarin-android/issues/3489. + if (RuntimeInfo.StartupDirectory != null) + loadFromDisk(); + + // the event handler contains code for resolving dependency on the game assembly for rulesets located outside the base game directory. + // It needs to be attached to the assembly lookup event before the actual call to loadUserRulesets() else rulesets located out of the base game directory will fail + // to load as unable to locate the game core assembly. + AppDomain.CurrentDomain.AssemblyResolve += resolveRulesetDependencyAssembly; + + var rulesetStorage = storage?.GetStorageForDirectory(@"rulesets"); + if (rulesetStorage != null) + loadUserRulesets(rulesetStorage); + + addMissingRulesets(); + } + + /// + /// Retrieve a ruleset using a known ID. + /// + /// The ruleset's internal ID. + /// A ruleset, if available, else null. + public IRulesetInfo? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id); + + /// + /// Retrieve a ruleset using a known short name. + /// + /// The ruleset's short name. + /// A ruleset, if available, else null. + public IRulesetInfo? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName); + + private Assembly? resolveRulesetDependencyAssembly(object? sender, ResolveEventArgs args) + { + var asm = new AssemblyName(args.Name); + + // the requesting assembly may be located out of the executable's base directory, thus requiring manual resolving of its dependencies. + // this attempts resolving the ruleset dependencies on game core and framework assemblies by returning assemblies with the same assembly name + // already loaded in the AppDomain. + var domainAssembly = AppDomain.CurrentDomain.GetAssemblies() + // Given name is always going to be equally-or-more qualified than the assembly name. + .Where(a => + { + string? name = a.GetName().Name; + if (name == null) + return false; + + return args.Name.Contains(name, StringComparison.Ordinal); + }) + // Pick the greatest assembly version. + .OrderByDescending(a => a.GetName().Version) + .FirstOrDefault(); + + if (domainAssembly != null) + return domainAssembly; + + return loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == asm.FullName); + } + + private void addMissingRulesets() + { + realmFactory.Context.Write(realm => + { + var rulesets = realm.All(); + + List instances = loadedAssemblies.Values + .Select(r => Activator.CreateInstance(r) as Ruleset) + .Where(r => r != null) + .Select(r => r.AsNonNull()) + .ToList(); + + // add all legacy rulesets first to ensure they have exclusive choice of primary key. + foreach (var r in instances.Where(r => r is ILegacyRuleset)) + { + if (realm.All().FirstOrDefault(rr => rr.OnlineID == r.RulesetInfo.ID) == null) + realm.Add(new RealmRuleset(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.ID)); + } + + // add any other rulesets which have assemblies present but are not yet in the database. + foreach (var r in instances.Where(r => !(r is ILegacyRuleset))) + { + if (rulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null) + { + var existingSameShortName = rulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName); + + if (existingSameShortName != null) + { + // even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName. + // this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one. + // in such cases, update the instantiation info of the existing entry to point to the new one. + existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo; + } + else + realm.Add(new RealmRuleset(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.ID)); + } + } + + List detachedRulesets = new List(); + + // perform a consistency check and detach final rulesets from realm for cross-thread runtime usage. + foreach (var r in rulesets) + { + try + { + var type = Type.GetType(r.InstantiationInfo); + + if (type == null) + throw new InvalidOperationException(@"Type resolution failure."); + + var rInstance = (Activator.CreateInstance(type) as Ruleset)?.RulesetInfo; + + if (rInstance == null) + throw new InvalidOperationException(@"Instantiation failure."); + + r.Name = rInstance.Name; + r.ShortName = rInstance.ShortName; + r.InstantiationInfo = rInstance.InstantiationInfo; + r.Available = true; + + detachedRulesets.Add(r.Clone()); + } + catch (Exception ex) + { + r.Available = false; + Logger.Log($"Could not load ruleset {r}: {ex.Message}"); + } + } + + availableRulesets.AddRange(detachedRulesets); + }); + } + + private void loadFromAppDomain() + { + foreach (var ruleset in AppDomain.CurrentDomain.GetAssemblies()) + { + string? rulesetName = ruleset.GetName().Name; + + if (rulesetName == null) + continue; + + if (!rulesetName.StartsWith(ruleset_library_prefix, StringComparison.InvariantCultureIgnoreCase) || rulesetName.Contains(@"Tests")) + continue; + + addRuleset(ruleset); + } + } + + private void loadUserRulesets(Storage rulesetStorage) + { + var rulesets = rulesetStorage.GetFiles(@".", @$"{ruleset_library_prefix}.*.dll"); + + foreach (var ruleset in rulesets.Where(f => !f.Contains(@"Tests"))) + loadRulesetFromFile(rulesetStorage.GetFullPath(ruleset)); + } + + private void loadFromDisk() + { + try + { + var files = Directory.GetFiles(RuntimeInfo.StartupDirectory, @$"{ruleset_library_prefix}.*.dll"); + + foreach (string file in files.Where(f => !Path.GetFileName(f).Contains("Tests"))) + loadRulesetFromFile(file); + } + catch (Exception e) + { + Logger.Error(e, $"Could not load rulesets from directory {RuntimeInfo.StartupDirectory}"); + } + } + + private void loadRulesetFromFile(string file) + { + var filename = Path.GetFileNameWithoutExtension(file); + + if (loadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename)) + return; + + try + { + addRuleset(Assembly.LoadFrom(file)); + } + catch (Exception e) + { + Logger.Error(e, $"Failed to load ruleset {filename}"); + } + } + + private void addRuleset(Assembly assembly) + { + if (loadedAssemblies.ContainsKey(assembly)) + return; + + // the same assembly may be loaded twice in the same AppDomain (currently a thing in certain Rider versions https://youtrack.jetbrains.com/issue/RIDER-48799). + // as a failsafe, also compare by FullName. + if (loadedAssemblies.Any(a => a.Key.FullName == assembly.FullName)) + return; + + try + { + loadedAssemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset))); + } + catch (Exception e) + { + Logger.Error(e, $"Failed to add ruleset {assembly}"); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; + } + } +} diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 2c0ca0b872..5e4e5942d9 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -53,7 +53,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public MultiplayerRoomUser AddUser(User user, bool markAsPlaying = false) { var roomUser = new MultiplayerRoomUser(user.Id) { User = user }; - ((IMultiplayerClient)this).UserJoined(roomUser); + + addUser(roomUser); if (markAsPlaying) PlayingUserIds.Add(user.Id); @@ -61,7 +62,15 @@ namespace osu.Game.Tests.Visual.Multiplayer return roomUser; } - public void AddNullUser() => ((IMultiplayerClient)this).UserJoined(new MultiplayerRoomUser(TestUserLookupCache.NULL_USER_ID)); + public void AddNullUser() => addUser(new MultiplayerRoomUser(TestUserLookupCache.NULL_USER_ID)); + + private void addUser(MultiplayerRoomUser user) + { + ((IMultiplayerClient)this).UserJoined(user).Wait(); + + // We want the user to be immediately available for testing, so force a scheduler update to run the update-bound continuation. + Scheduler.Update(); + } public void RemoveUser(User user) {