diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs new file mode 100644 index 0000000000..8f5990aeb1 --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -0,0 +1,200 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Game.Configuration; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Tests.Visual.Background +{ + public class TestSceneSeasonalBackgroundLoader : ScreenTestScene + { + [Resolved] + private OsuConfigManager config { get; set; } + + [Resolved] + private SessionStatics statics { get; set; } + + [Cached(typeof(LargeTextureStore))] + private LookupLoggingTextureStore textureStore = new LookupLoggingTextureStore(); + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private SeasonalBackgroundLoader backgroundLoader; + private Container backgroundContainer; + + // in real usages these would be online URLs, but correct execution of this test + // shouldn't be coupled to existence of online assets. + private static readonly List seasonal_background_urls = new List + { + "Backgrounds/bg2", + "Backgrounds/bg4", + "Backgrounds/bg3" + }; + + [BackgroundDependencyLoader] + private void load(LargeTextureStore wrappedStore) + { + textureStore.AddStore(wrappedStore); + + Add(backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both + }); + } + + [SetUp] + public void SetUp() => Schedule(() => + { + // reset API response in statics to avoid test crosstalk. + statics.Set(Static.SeasonalBackgrounds, null); + textureStore.PerformedLookups.Clear(); + dummyAPI.SetState(APIState.Online); + + backgroundContainer.Clear(); + }); + + [TestCase(-5)] + [TestCase(5)] + public void TestAlwaysSeasonal(int daysOffset) + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Always); + + createLoader(); + + for (int i = 0; i < 4; ++i) + loadNextBackground(); + + AddAssert("all backgrounds cycled", () => new HashSet(textureStore.PerformedLookups).SetEquals(seasonal_background_urls)); + } + + [TestCase(-5)] + [TestCase(5)] + public void TestNeverSeasonal(int daysOffset) + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Never); + + createLoader(); + + assertNoBackgrounds(); + } + + [Test] + public void TestSometimesInSeason() + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(5)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes); + + createLoader(); + + assertAnyBackground(); + } + + [Test] + public void TestSometimesOutOfSeason() + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(-10)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes); + + createLoader(); + + assertNoBackgrounds(); + } + + [Test] + public void TestDelayedConnectivity() + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(30)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Always); + AddStep("go offline", () => dummyAPI.SetState(APIState.Offline)); + + createLoader(); + assertNoBackgrounds(); + + AddStep("go online", () => dummyAPI.SetState(APIState.Online)); + + assertAnyBackground(); + } + + private void registerBackgroundsResponse(DateTimeOffset endDate) + => AddStep("setup request handler", () => + { + dummyAPI.HandleRequest = request => + { + if (dummyAPI.State.Value != APIState.Online || !(request is GetSeasonalBackgroundsRequest backgroundsRequest)) + return; + + backgroundsRequest.TriggerSuccess(new APISeasonalBackgrounds + { + Backgrounds = seasonal_background_urls.Select(url => new APISeasonalBackground { Url = url }).ToList(), + EndDate = endDate + }); + }; + }); + + private void setSeasonalBackgroundMode(SeasonalBackgroundMode mode) + => AddStep($"set seasonal mode to {mode}", () => config.Set(OsuSetting.SeasonalBackgroundMode, mode)); + + private void createLoader() + { + AddStep("create loader", () => + { + if (backgroundLoader != null) + Remove(backgroundLoader); + + LoadComponentAsync(backgroundLoader = new SeasonalBackgroundLoader(), Add); + }); + + AddUntilStep("wait for loaded", () => backgroundLoader.IsLoaded); + } + + private void loadNextBackground() + { + SeasonalBackground background = null; + + AddStep("create next background", () => + { + background = backgroundLoader.LoadNextBackground(); + LoadComponentAsync(background, bg => backgroundContainer.Child = bg); + }); + + AddUntilStep("background loaded", () => background.IsLoaded); + } + + private void assertAnyBackground() + { + loadNextBackground(); + AddAssert("background looked up", () => textureStore.PerformedLookups.Any()); + } + + private void assertNoBackgrounds() + { + AddAssert("no background available", () => backgroundLoader.LoadNextBackground() == null); + AddAssert("no lookups performed", () => !textureStore.PerformedLookups.Any()); + } + + private class LookupLoggingTextureStore : LargeTextureStore + { + public List PerformedLookups { get; } = new List(); + + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) + { + PerformedLookups.Add(name); + return base.Get(name, wrapModeS, wrapModeT); + } + } + } +} diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index ae081e5dcb..73ecd69f74 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -133,6 +133,7 @@ namespace osu.Game.Configuration Set(OsuSetting.IntroSequence, IntroSequence.Triangles); Set(OsuSetting.MenuBackgroundSource, BackgroundSource.Skin); + Set(OsuSetting.SeasonalBackgroundMode, SeasonalBackgroundMode.Sometimes); } public OsuConfigManager(Storage storage) @@ -242,6 +243,7 @@ namespace osu.Game.Configuration HitLighting, MenuBackgroundSource, GameplayDisableWinKey, + SeasonalBackgroundMode, AutomaticallyDownloadWhenSpectating, } } diff --git a/osu.Game/Configuration/SeasonalBackgroundMode.cs b/osu.Game/Configuration/SeasonalBackgroundMode.cs new file mode 100644 index 0000000000..6ef835ce5f --- /dev/null +++ b/osu.Game/Configuration/SeasonalBackgroundMode.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Configuration +{ + public enum SeasonalBackgroundMode + { + /// + /// Seasonal backgrounds are shown regardless of season, if at all available. + /// + Always, + + /// + /// Seasonal backgrounds are shown only during their corresponding season. + /// + Sometimes, + + /// + /// Seasonal backgrounds are never shown. + /// + Never + } +} diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 40b2adb867..03bc434aac 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.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.Game.Online.API.Requests.Responses; + namespace osu.Game.Configuration { /// @@ -12,12 +14,19 @@ namespace osu.Game.Configuration { Set(Static.LoginOverlayDisplayed, false); Set(Static.MutedAudioNotificationShownOnce, false); + Set(Static.SeasonalBackgrounds, null); } } public enum Static { LoginOverlayDisplayed, - MutedAudioNotificationShownOnce + MutedAudioNotificationShownOnce, + + /// + /// Info about seasonal backgrounds available fetched from API - see . + /// Value under this lookup can be null if there are no backgrounds available (or API is not reachable). + /// + SeasonalBackgrounds, } } diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs new file mode 100644 index 0000000000..99f3a8a6e8 --- /dev/null +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -0,0 +1,104 @@ +// 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.Textures; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Graphics.Backgrounds +{ + [LongRunningLoad] + public class SeasonalBackgroundLoader : Component + { + /// + /// Fired when background should be changed due to receiving backgrounds from API + /// or when the user setting is changed (as it might require unloading the seasonal background). + /// + public event Action SeasonalBackgroundChanged; + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly IBindable apiState = new Bindable(); + private Bindable seasonalBackgroundMode; + private Bindable seasonalBackgrounds; + + private int current; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, SessionStatics sessionStatics) + { + seasonalBackgroundMode = config.GetBindable(OsuSetting.SeasonalBackgroundMode); + seasonalBackgroundMode.BindValueChanged(_ => SeasonalBackgroundChanged?.Invoke()); + + seasonalBackgrounds = sessionStatics.GetBindable(Static.SeasonalBackgrounds); + seasonalBackgrounds.BindValueChanged(_ => SeasonalBackgroundChanged?.Invoke()); + + apiState.BindTo(api.State); + apiState.BindValueChanged(fetchSeasonalBackgrounds, true); + } + + private void fetchSeasonalBackgrounds(ValueChangedEvent stateChanged) + { + if (seasonalBackgrounds.Value != null || stateChanged.NewValue != APIState.Online) + return; + + var request = new GetSeasonalBackgroundsRequest(); + request.Success += response => + { + seasonalBackgrounds.Value = response; + current = RNG.Next(0, response.Backgrounds?.Count ?? 0); + }; + + api.PerformAsync(request); + } + + public SeasonalBackground LoadNextBackground() + { + if (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Never + || (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Sometimes && !isInSeason)) + { + return null; + } + + var backgrounds = seasonalBackgrounds.Value?.Backgrounds; + if (backgrounds == null || !backgrounds.Any()) + return null; + + current = (current + 1) % backgrounds.Count; + string url = backgrounds[current].Url; + + return new SeasonalBackground(url); + } + + private bool isInSeason => seasonalBackgrounds.Value != null && DateTimeOffset.Now < seasonalBackgrounds.Value.EndDate; + } + + [LongRunningLoad] + public class SeasonalBackground : Background + { + private readonly string url; + private const string fallback_texture_name = @"Backgrounds/bg1"; + + public SeasonalBackground(string url) + { + this.url = url; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + Sprite.Texture = textures.Get(url) ?? textures.Get(fallback_texture_name); + // ensure we're not loading in without a transition. + this.FadeInFromZero(200, Easing.InOutSine); + } + } +} diff --git a/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs b/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs new file mode 100644 index 0000000000..941b47244a --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetSeasonalBackgroundsRequest : APIRequest + { + protected override string Target => @"seasonal-backgrounds"; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs b/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs new file mode 100644 index 0000000000..8e395f7397 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs @@ -0,0 +1,24 @@ +// 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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APISeasonalBackgrounds + { + [JsonProperty("ends_at")] + public DateTimeOffset EndDate; + + [JsonProperty("backgrounds")] + public List Backgrounds { get; set; } + } + + public class APISeasonalBackground + { + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs index d5de32ed05..7682967d10 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs @@ -39,6 +39,12 @@ namespace osu.Game.Overlays.Settings.Sections.Audio LabelText = "Background source", Current = config.GetBindable(OsuSetting.MenuBackgroundSource), Items = Enum.GetValues(typeof(BackgroundSource)).Cast() + }, + new SettingsDropdown + { + LabelText = "Seasonal backgrounds", + Current = config.GetBindable(OsuSetting.SeasonalBackgroundMode), + Items = Enum.GetValues(typeof(SeasonalBackgroundMode)).Cast() } }; } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index ef41c5be3d..3f6210310f 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -25,6 +26,7 @@ namespace osu.Game.Screens.Backgrounds private Bindable skin; private Bindable mode; private Bindable introSequence; + private readonly SeasonalBackgroundLoader seasonalBackgroundLoader = new SeasonalBackgroundLoader(); [Resolved] private IBindable beatmap { get; set; } @@ -47,10 +49,12 @@ namespace osu.Game.Screens.Backgrounds mode.ValueChanged += _ => Next(); beatmap.ValueChanged += _ => Next(); introSequence.ValueChanged += _ => Next(); + seasonalBackgroundLoader.SeasonalBackgroundChanged += Next; currentDisplay = RNG.Next(0, background_count); - display(createBackground()); + LoadComponentAsync(seasonalBackgroundLoader); + Next(); } private void display(Background newBackground) @@ -63,11 +67,14 @@ namespace osu.Game.Screens.Backgrounds } private ScheduledDelegate nextTask; + private CancellationTokenSource cancellationTokenSource; public void Next() { nextTask?.Cancel(); - nextTask = Scheduler.AddDelayed(() => { LoadComponentAsync(createBackground(), display); }, 100); + cancellationTokenSource?.Cancel(); + cancellationTokenSource = new CancellationTokenSource(); + nextTask = Scheduler.AddDelayed(() => LoadComponentAsync(createBackground(), display, cancellationTokenSource.Token), 100); } private Background createBackground() @@ -75,6 +82,14 @@ namespace osu.Game.Screens.Backgrounds Background newBackground; string backgroundName; + var seasonalBackground = seasonalBackgroundLoader.LoadNextBackground(); + + if (seasonalBackground != null) + { + seasonalBackground.Depth = currentDisplay; + return seasonalBackground; + } + switch (introSequence.Value) { case IntroSequence.Welcome: