diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index b4004ff572..76ae7340ff 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public partial class DrawableOsuJudgement : DrawableJudgement { - protected SkinnableLighting Lighting { get; private set; } + internal SkinnableLighting Lighting { get; private set; } [Resolved] private OsuConfigManager config { get; set; } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs index 68b61f9b2b..b39b9c4c54 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs @@ -10,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public partial class SkinnableLighting : SkinnableSprite + internal partial class SkinnableLighting : SkinnableSprite { private DrawableHitObject targetObject; private JudgementResult targetResult; diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index 5d7cbad46d..04b8c6dd4e 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -66,9 +66,9 @@ namespace osu.Game.Tests.Skins { var skin = new TestSkin(new SkinInfo(), null, storage); - foreach (var target in skin.DrawableComponentInfo) + foreach (var target in skin.LayoutInfos) { - foreach (var info in target.Value) + foreach (var info in target.Value.AllDrawables) instantiatedTypes.Add(info.Type); } } @@ -87,8 +87,8 @@ namespace osu.Game.Tests.Skins { var skin = new TestSkin(new SkinInfo(), null, storage); - Assert.That(skin.DrawableComponentInfo, Has.Count.EqualTo(2)); - Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents], Has.Length.EqualTo(9)); + Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(9)); } } @@ -100,11 +100,11 @@ namespace osu.Game.Tests.Skins { var skin = new TestSkin(new SkinInfo(), null, storage); - Assert.That(skin.DrawableComponentInfo, Has.Count.EqualTo(2)); - Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents], Has.Length.EqualTo(6)); - Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.SongSelect], Has.Length.EqualTo(1)); + Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(6)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.SongSelect].AllDrawables.ToArray(), Has.Length.EqualTo(1)); - var skinnableInfo = skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.SongSelect].First(); + var skinnableInfo = skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.SongSelect].AllDrawables.First(); Assert.That(skinnableInfo.Type, Is.EqualTo(typeof(SkinnableSprite))); Assert.That(skinnableInfo.Settings.First().Key, Is.EqualTo("sprite_name")); @@ -115,10 +115,10 @@ namespace osu.Game.Tests.Skins using (var storage = new ZipArchiveReader(stream)) { var skin = new TestSkin(new SkinInfo(), null, storage); - Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents], Has.Length.EqualTo(8)); - Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); - Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); - Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); } } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index e41afd7722..c8d06b82e8 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; @@ -26,6 +27,7 @@ using osu.Game.Screens.Play.HUD.JudgementCounter; using osu.Game.Skinning; using osuTK; using osu.Game.Localisation; +using osu.Game.Rulesets; namespace osu.Game.Screens.Play { @@ -100,20 +102,22 @@ namespace osu.Game.Screens.Play public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods, bool alwaysShowLeaderboard = true) { + Drawable rulesetComponents; + this.drawableRuleset = drawableRuleset; this.mods = mods; RelativeSizeAxes = Axes.Both; - Children = new Drawable[] + Children = new[] { CreateFailingLayer(), //Needs to be initialized before skinnable drawables. tally = new JudgementTally(), - mainComponents = new MainComponentsContainer - { - AlwaysPresent = true, - }, + mainComponents = new HUDComponentsContainer { AlwaysPresent = true, }, + rulesetComponents = drawableRuleset != null + ? new HUDComponentsContainer(drawableRuleset.Ruleset.RulesetInfo) { AlwaysPresent = true, } + : Empty(), topRightElements = new FillFlowContainer { Anchor = Anchor.TopRight, @@ -155,7 +159,7 @@ namespace osu.Game.Screens.Play clicksPerSecondCalculator = new ClicksPerSecondCalculator(), }; - hideTargets = new List { mainComponents, KeyCounter, topRightElements }; + hideTargets = new List { mainComponents, rulesetComponents, KeyCounter, topRightElements }; if (!alwaysShowLeaderboard) hideTargets.Add(LeaderboardFlow); @@ -390,15 +394,15 @@ namespace osu.Game.Screens.Play } } - private partial class MainComponentsContainer : SkinComponentsContainer + private partial class HUDComponentsContainer : SkinComponentsContainer { private Bindable scoringMode; [Resolved] private OsuConfigManager config { get; set; } - public MainComponentsContainer() - : base(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.MainHUDComponents)) + public HUDComponentsContainer([CanBeNull] RulesetInfo ruleset = null) + : base(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, ruleset)) { RelativeSizeAxes = Axes.Both; } diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index 737b222b9d..a9b26f13e8 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -91,6 +91,10 @@ namespace osu.Game.Skinning switch (lookup) { case SkinComponentsContainerLookup containerLookup: + // Only handle global level defaults for now. + if (containerLookup.Ruleset != null) + return null; + switch (containerLookup.Target) { case SkinComponentsContainerLookup.TargetArea.SongSelect: diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 98d9f1d617..e46eaf90c1 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -344,10 +344,14 @@ namespace osu.Game.Skinning switch (lookup) { case SkinComponentsContainerLookup containerLookup: + // Only handle global level defaults for now. + if (containerLookup.Ruleset != null) + return null; + switch (containerLookup.Target) { case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: - var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => + return new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); @@ -387,8 +391,6 @@ namespace osu.Game.Skinning new BarHitErrorMeter(), } }; - - return skinnableTargetWrapper; } return null; diff --git a/osu.Game/Skinning/SerialisedDrawableInfo.cs b/osu.Game/Skinning/SerialisedDrawableInfo.cs index 29078f0d74..c515f228f7 100644 --- a/osu.Game/Skinning/SerialisedDrawableInfo.cs +++ b/osu.Game/Skinning/SerialisedDrawableInfo.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Extensions; +using osu.Game.Rulesets; using osuTK; namespace osu.Game.Skinning @@ -100,13 +101,18 @@ namespace osu.Game.Skinning } } - public static Type[] GetAllAvailableDrawables() + /// + /// Retrieve all types available which support serialisation. + /// + /// The ruleset to filter results to. If null, global components will be returned instead. + public static Type[] GetAllAvailableDrawables(RulesetInfo? ruleset = null) { - return typeof(OsuGame).Assembly.GetTypes() - .Where(t => !t.IsInterface && !t.IsAbstract) - .Where(t => typeof(ISerialisableDrawable).IsAssignableFrom(t)) - .OrderBy(t => t.Name) - .ToArray(); + return (ruleset?.CreateInstance().GetType() ?? typeof(OsuGame)) + .Assembly.GetTypes() + .Where(t => !t.IsInterface && !t.IsAbstract && t.IsPublic) + .Where(t => typeof(ISerialisableDrawable).IsAssignableFrom(t)) + .OrderBy(t => t.Name) + .ToArray(); } } } diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index f261937012..a6250d7488 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -37,9 +37,10 @@ namespace osu.Game.Skinning public SkinConfiguration Configuration { get; set; } - public IDictionary DrawableComponentInfo => drawableComponentInfo; + public IDictionary LayoutInfos => layoutInfos; - private readonly Dictionary drawableComponentInfo = new Dictionary(); + private readonly Dictionary layoutInfos = + new Dictionary(); public abstract ISample? GetSample(ISampleInfo sampleInfo); @@ -113,18 +114,41 @@ namespace osu.Game.Skinning { string jsonContent = Encoding.UTF8.GetString(bytes); - // handle namespace changes... + SkinLayoutInfo? layoutInfo = null; - // can be removed 2023-01-31 - jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress"); - jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); + try + { + // First attempt to deserialise using the new SkinLayoutInfo format + layoutInfo = JsonConvert.DeserializeObject(jsonContent); + } + catch + { + } - var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + // Of note, the migration code below runs on read of skins, but there's nothing to + // force a rewrite after migration. Let's not remove these migration rules until we + // have something in place to ensure we don't end up breaking skins of users that haven't + // manually saved their skin since a change was implemented. - if (deserializedContent == null) - continue; + // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. + if (layoutInfo == null) + { + // handle namespace changes... + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); - DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + + if (deserializedContent == null) + continue; + + layoutInfo = new SkinLayoutInfo(); + layoutInfo.Update(null, deserializedContent.ToArray()); + + Logger.Log($"Ferrying {deserializedContent.Count()} components in {skinnableTarget} to global section of new {nameof(SkinLayoutInfo)} format"); + } + + LayoutInfos[skinnableTarget] = layoutInfo; } catch (Exception ex) { @@ -145,7 +169,7 @@ namespace osu.Game.Skinning /// The target container to reset. public void ResetDrawableTarget(SkinComponentsContainer targetContainer) { - DrawableComponentInfo.Remove(targetContainer.Lookup.Target); + LayoutInfos.Remove(targetContainer.Lookup.Target); } /// @@ -154,7 +178,10 @@ namespace osu.Game.Skinning /// The target container to serialise to this skin. public void UpdateDrawableTarget(SkinComponentsContainer targetContainer) { - DrawableComponentInfo[targetContainer.Lookup.Target] = ((ISerialisableDrawableContainer)targetContainer).CreateSerialisedInfo().ToArray(); + if (!LayoutInfos.TryGetValue(targetContainer.Lookup.Target, out var layoutInfo)) + layoutInfos[targetContainer.Lookup.Target] = layoutInfo = new SkinLayoutInfo(); + + layoutInfo.Update(targetContainer.Lookup.Ruleset, ((ISerialisableDrawableContainer)targetContainer).CreateSerialisedInfo().ToArray()); } public virtual Drawable? GetDrawableComponent(ISkinComponentLookup lookup) @@ -166,18 +193,16 @@ namespace osu.Game.Skinning return this.GetAnimation(sprite.LookupName, false, false); case SkinComponentsContainerLookup containerLookup: - if (!DrawableComponentInfo.TryGetValue(containerLookup.Target, out var skinnableInfo)) - return null; - var components = new List(); - - foreach (var i in skinnableInfo) - components.Add(i.CreateInstance()); + // It is important to return null if the user has not configured this yet. + // This allows skin transformers the opportunity to provide default components. + if (!LayoutInfos.TryGetValue(containerLookup.Target, out var layoutInfo)) return null; + if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null; return new Container { RelativeSizeAxes = Axes.Both, - Children = components, + ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance()) }; } diff --git a/osu.Game/Skinning/SkinComponentsContainer.cs b/osu.Game/Skinning/SkinComponentsContainer.cs index 7d7a4965f6..d18e9023cd 100644 --- a/osu.Game/Skinning/SkinComponentsContainer.cs +++ b/osu.Game/Skinning/SkinComponentsContainer.cs @@ -66,25 +66,20 @@ namespace osu.Game.Skinning components.Clear(); ComponentsLoaded = false; - if (componentsContainer == null) - return; - - content = componentsContainer; + content = componentsContainer ?? new Container + { + RelativeSizeAxes = Axes.Both + }; cancellationSource?.Cancel(); cancellationSource = null; - if (content != null) + LoadComponentAsync(content, wrapper => { - LoadComponentAsync(content, wrapper => - { - AddInternal(wrapper); - components.AddRange(wrapper.Children.OfType()); - ComponentsLoaded = true; - }, (cancellationSource = new CancellationTokenSource()).Token); - } - else + AddInternal(wrapper); + components.AddRange(wrapper.Children.OfType()); ComponentsLoaded = true; + }, (cancellationSource = new CancellationTokenSource()).Token); } /// diff --git a/osu.Game/Skinning/SkinComponentsContainerLookup.cs b/osu.Game/Skinning/SkinComponentsContainerLookup.cs index b0b87f18b5..812ba5d495 100644 --- a/osu.Game/Skinning/SkinComponentsContainerLookup.cs +++ b/osu.Game/Skinning/SkinComponentsContainerLookup.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.Rulesets; + namespace osu.Game.Skinning { /// @@ -13,9 +15,16 @@ namespace osu.Game.Skinning /// public readonly TargetArea Target; - public SkinComponentsContainerLookup(TargetArea target) + /// + /// The ruleset for which skin components should be returned. + /// A value means that returned components are global and should be applied for all rulesets. + /// + public readonly RulesetInfo? Ruleset; + + public SkinComponentsContainerLookup(TargetArea target, RulesetInfo? ruleset = null) { Target = target; + Ruleset = ruleset; } /// diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 8ce265719c..9e2e02876d 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -201,7 +201,7 @@ namespace osu.Game.Skinning } // Then serialise each of the drawable component groups into respective files. - foreach (var drawableInfo in skin.DrawableComponentInfo) + foreach (var drawableInfo in skin.LayoutInfos) { string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); diff --git a/osu.Game/Skinning/SkinLayoutInfo.cs b/osu.Game/Skinning/SkinLayoutInfo.cs new file mode 100644 index 0000000000..115d59b9d0 --- /dev/null +++ b/osu.Game/Skinning/SkinLayoutInfo.cs @@ -0,0 +1,37 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Linq; +using Newtonsoft.Json; +using osu.Game.Rulesets; + +namespace osu.Game.Skinning +{ + /// + /// A serialisable model describing layout of a . + /// May contain multiple configurations for different rulesets, each of which should manifest their own as required. + /// + [Serializable] + public class SkinLayoutInfo + { + private const string global_identifier = @"global"; + + [JsonIgnore] + public IEnumerable AllDrawables => DrawableInfo.Values.SelectMany(v => v); + + [JsonProperty] + public Dictionary DrawableInfo { get; set; } = new Dictionary(); + + public bool TryGetDrawableInfo(RulesetInfo? ruleset, [NotNullWhen(true)] out SerialisedDrawableInfo[]? components) => + DrawableInfo.TryGetValue(ruleset?.ShortName ?? global_identifier, out components); + + public void Reset(RulesetInfo? ruleset) => + DrawableInfo.Remove(ruleset?.ShortName ?? global_identifier); + + public void Update(RulesetInfo? ruleset, SerialisedDrawableInfo[] components) => + DrawableInfo[ruleset?.ShortName ?? global_identifier] = components; + } +} diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index 12cdb71744..e88b827807 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -69,6 +69,10 @@ namespace osu.Game.Skinning switch (lookup) { case SkinComponentsContainerLookup containerLookup: + // Only handle global level defaults for now. + if (containerLookup.Ruleset != null) + return null; + switch (containerLookup.Target) { case SkinComponentsContainerLookup.TargetArea.SongSelect: