Merge pull request #22673 from peppy/skin-per-ruleset-layouts

Add support for per-ruleset skin layouts
This commit is contained in:
Bartłomiej Dach 2023-02-20 21:33:28 +01:00 committed by GitHub
commit 8818341047
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 152 additions and 66 deletions

View File

@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
public partial class DrawableOsuJudgement : DrawableJudgement public partial class DrawableOsuJudgement : DrawableJudgement
{ {
protected SkinnableLighting Lighting { get; private set; } internal SkinnableLighting Lighting { get; private set; }
[Resolved] [Resolved]
private OsuConfigManager config { get; set; } private OsuConfigManager config { get; set; }

View File

@ -10,7 +10,7 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Objects.Drawables namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
public partial class SkinnableLighting : SkinnableSprite internal partial class SkinnableLighting : SkinnableSprite
{ {
private DrawableHitObject targetObject; private DrawableHitObject targetObject;
private JudgementResult targetResult; private JudgementResult targetResult;

View File

@ -66,9 +66,9 @@ namespace osu.Game.Tests.Skins
{ {
var skin = new TestSkin(new SkinInfo(), null, storage); 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); instantiatedTypes.Add(info.Type);
} }
} }
@ -87,8 +87,8 @@ namespace osu.Game.Tests.Skins
{ {
var skin = new TestSkin(new SkinInfo(), null, storage); var skin = new TestSkin(new SkinInfo(), null, storage);
Assert.That(skin.DrawableComponentInfo, Has.Count.EqualTo(2)); Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2));
Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents], Has.Length.EqualTo(9)); 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); var skin = new TestSkin(new SkinInfo(), null, storage);
Assert.That(skin.DrawableComponentInfo, Has.Count.EqualTo(2)); Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2));
Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents], Has.Length.EqualTo(6)); Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(6));
Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.SongSelect], Has.Length.EqualTo(1)); 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.Type, Is.EqualTo(typeof(SkinnableSprite)));
Assert.That(skinnableInfo.Settings.First().Key, Is.EqualTo("sprite_name")); 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)) using (var storage = new ZipArchiveReader(stream))
{ {
var skin = new TestSkin(new SkinInfo(), null, storage); var skin = new TestSkin(new SkinInfo(), null, storage);
Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents], Has.Length.EqualTo(8)); Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8));
Assert.That(skin.DrawableComponentInfo[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.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.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.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.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress)));
} }
} }

View File

@ -6,6 +6,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.EnumExtensions;
@ -26,6 +27,7 @@ using osu.Game.Screens.Play.HUD.JudgementCounter;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Rulesets;
namespace osu.Game.Screens.Play namespace osu.Game.Screens.Play
{ {
@ -100,20 +102,22 @@ namespace osu.Game.Screens.Play
public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods, bool alwaysShowLeaderboard = true) public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods, bool alwaysShowLeaderboard = true)
{ {
Drawable rulesetComponents;
this.drawableRuleset = drawableRuleset; this.drawableRuleset = drawableRuleset;
this.mods = mods; this.mods = mods;
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
Children = new Drawable[] Children = new[]
{ {
CreateFailingLayer(), CreateFailingLayer(),
//Needs to be initialized before skinnable drawables. //Needs to be initialized before skinnable drawables.
tally = new JudgementTally(), tally = new JudgementTally(),
mainComponents = new MainComponentsContainer mainComponents = new HUDComponentsContainer { AlwaysPresent = true, },
{ rulesetComponents = drawableRuleset != null
AlwaysPresent = true, ? new HUDComponentsContainer(drawableRuleset.Ruleset.RulesetInfo) { AlwaysPresent = true, }
}, : Empty(),
topRightElements = new FillFlowContainer topRightElements = new FillFlowContainer
{ {
Anchor = Anchor.TopRight, Anchor = Anchor.TopRight,
@ -155,7 +159,7 @@ namespace osu.Game.Screens.Play
clicksPerSecondCalculator = new ClicksPerSecondCalculator(), clicksPerSecondCalculator = new ClicksPerSecondCalculator(),
}; };
hideTargets = new List<Drawable> { mainComponents, KeyCounter, topRightElements }; hideTargets = new List<Drawable> { mainComponents, rulesetComponents, KeyCounter, topRightElements };
if (!alwaysShowLeaderboard) if (!alwaysShowLeaderboard)
hideTargets.Add(LeaderboardFlow); 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> scoringMode; private Bindable<ScoringMode> scoringMode;
[Resolved] [Resolved]
private OsuConfigManager config { get; set; } private OsuConfigManager config { get; set; }
public MainComponentsContainer() public HUDComponentsContainer([CanBeNull] RulesetInfo ruleset = null)
: base(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.MainHUDComponents)) : base(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, ruleset))
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
} }

View File

@ -91,6 +91,10 @@ namespace osu.Game.Skinning
switch (lookup) switch (lookup)
{ {
case SkinComponentsContainerLookup containerLookup: case SkinComponentsContainerLookup containerLookup:
// Only handle global level defaults for now.
if (containerLookup.Ruleset != null)
return null;
switch (containerLookup.Target) switch (containerLookup.Target)
{ {
case SkinComponentsContainerLookup.TargetArea.SongSelect: case SkinComponentsContainerLookup.TargetArea.SongSelect:

View File

@ -344,10 +344,14 @@ namespace osu.Game.Skinning
switch (lookup) switch (lookup)
{ {
case SkinComponentsContainerLookup containerLookup: case SkinComponentsContainerLookup containerLookup:
// Only handle global level defaults for now.
if (containerLookup.Ruleset != null)
return null;
switch (containerLookup.Target) switch (containerLookup.Target)
{ {
case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: case SkinComponentsContainerLookup.TargetArea.MainHUDComponents:
var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => return new DefaultSkinComponentsContainer(container =>
{ {
var score = container.OfType<LegacyScoreCounter>().FirstOrDefault(); var score = container.OfType<LegacyScoreCounter>().FirstOrDefault();
var accuracy = container.OfType<GameplayAccuracyCounter>().FirstOrDefault(); var accuracy = container.OfType<GameplayAccuracyCounter>().FirstOrDefault();
@ -387,8 +391,6 @@ namespace osu.Game.Skinning
new BarHitErrorMeter(), new BarHitErrorMeter(),
} }
}; };
return skinnableTargetWrapper;
} }
return null; return null;

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Rulesets;
using osuTK; using osuTK;
namespace osu.Game.Skinning namespace osu.Game.Skinning
@ -100,13 +101,18 @@ namespace osu.Game.Skinning
} }
} }
public static Type[] GetAllAvailableDrawables() /// <summary>
/// Retrieve all types available which support serialisation.
/// </summary>
/// <param name="ruleset">The ruleset to filter results to. If <c>null</c>, global components will be returned instead.</param>
public static Type[] GetAllAvailableDrawables(RulesetInfo? ruleset = null)
{ {
return typeof(OsuGame).Assembly.GetTypes() return (ruleset?.CreateInstance().GetType() ?? typeof(OsuGame))
.Where(t => !t.IsInterface && !t.IsAbstract) .Assembly.GetTypes()
.Where(t => typeof(ISerialisableDrawable).IsAssignableFrom(t)) .Where(t => !t.IsInterface && !t.IsAbstract && t.IsPublic)
.OrderBy(t => t.Name) .Where(t => typeof(ISerialisableDrawable).IsAssignableFrom(t))
.ToArray(); .OrderBy(t => t.Name)
.ToArray();
} }
} }
} }

View File

@ -37,9 +37,10 @@ namespace osu.Game.Skinning
public SkinConfiguration Configuration { get; set; } public SkinConfiguration Configuration { get; set; }
public IDictionary<SkinComponentsContainerLookup.TargetArea, SerialisedDrawableInfo[]> DrawableComponentInfo => drawableComponentInfo; public IDictionary<SkinComponentsContainerLookup.TargetArea, SkinLayoutInfo> LayoutInfos => layoutInfos;
private readonly Dictionary<SkinComponentsContainerLookup.TargetArea, SerialisedDrawableInfo[]> drawableComponentInfo = new Dictionary<SkinComponentsContainerLookup.TargetArea, SerialisedDrawableInfo[]>(); private readonly Dictionary<SkinComponentsContainerLookup.TargetArea, SkinLayoutInfo> layoutInfos =
new Dictionary<SkinComponentsContainerLookup.TargetArea, SkinLayoutInfo>();
public abstract ISample? GetSample(ISampleInfo sampleInfo); public abstract ISample? GetSample(ISampleInfo sampleInfo);
@ -113,18 +114,41 @@ namespace osu.Game.Skinning
{ {
string jsonContent = Encoding.UTF8.GetString(bytes); string jsonContent = Encoding.UTF8.GetString(bytes);
// handle namespace changes... SkinLayoutInfo? layoutInfo = null;
// can be removed 2023-01-31 try
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"); // First attempt to deserialise using the new SkinLayoutInfo format
layoutInfo = JsonConvert.DeserializeObject<SkinLayoutInfo>(jsonContent);
}
catch
{
}
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SerialisedDrawableInfo>>(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) // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list.
continue; 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<IEnumerable<SerialisedDrawableInfo>>(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) catch (Exception ex)
{ {
@ -145,7 +169,7 @@ namespace osu.Game.Skinning
/// <param name="targetContainer">The target container to reset.</param> /// <param name="targetContainer">The target container to reset.</param>
public void ResetDrawableTarget(SkinComponentsContainer targetContainer) public void ResetDrawableTarget(SkinComponentsContainer targetContainer)
{ {
DrawableComponentInfo.Remove(targetContainer.Lookup.Target); LayoutInfos.Remove(targetContainer.Lookup.Target);
} }
/// <summary> /// <summary>
@ -154,7 +178,10 @@ namespace osu.Game.Skinning
/// <param name="targetContainer">The target container to serialise to this skin.</param> /// <param name="targetContainer">The target container to serialise to this skin.</param>
public void UpdateDrawableTarget(SkinComponentsContainer targetContainer) 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) public virtual Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
@ -166,18 +193,16 @@ namespace osu.Game.Skinning
return this.GetAnimation(sprite.LookupName, false, false); return this.GetAnimation(sprite.LookupName, false, false);
case SkinComponentsContainerLookup containerLookup: case SkinComponentsContainerLookup containerLookup:
if (!DrawableComponentInfo.TryGetValue(containerLookup.Target, out var skinnableInfo))
return null;
var components = new List<Drawable>(); // It is important to return null if the user has not configured this yet.
// This allows skin transformers the opportunity to provide default components.
foreach (var i in skinnableInfo) if (!LayoutInfos.TryGetValue(containerLookup.Target, out var layoutInfo)) return null;
components.Add(i.CreateInstance()); if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null;
return new Container return new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = components, ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance())
}; };
} }

View File

@ -66,25 +66,20 @@ namespace osu.Game.Skinning
components.Clear(); components.Clear();
ComponentsLoaded = false; ComponentsLoaded = false;
if (componentsContainer == null) content = componentsContainer ?? new Container
return; {
RelativeSizeAxes = Axes.Both
content = componentsContainer; };
cancellationSource?.Cancel(); cancellationSource?.Cancel();
cancellationSource = null; cancellationSource = null;
if (content != null) LoadComponentAsync(content, wrapper =>
{ {
LoadComponentAsync(content, wrapper => AddInternal(wrapper);
{ components.AddRange(wrapper.Children.OfType<ISerialisableDrawable>());
AddInternal(wrapper);
components.AddRange(wrapper.Children.OfType<ISerialisableDrawable>());
ComponentsLoaded = true;
}, (cancellationSource = new CancellationTokenSource()).Token);
}
else
ComponentsLoaded = true; ComponentsLoaded = true;
}, (cancellationSource = new CancellationTokenSource()).Token);
} }
/// <inheritdoc cref="ISerialisableDrawableContainer"/> /// <inheritdoc cref="ISerialisableDrawableContainer"/>

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets;
namespace osu.Game.Skinning namespace osu.Game.Skinning
{ {
/// <summary> /// <summary>
@ -13,9 +15,16 @@ namespace osu.Game.Skinning
/// </summary> /// </summary>
public readonly TargetArea Target; public readonly TargetArea Target;
public SkinComponentsContainerLookup(TargetArea target) /// <summary>
/// The ruleset for which skin components should be returned.
/// A <see langword="null"/> value means that returned components are global and should be applied for all rulesets.
/// </summary>
public readonly RulesetInfo? Ruleset;
public SkinComponentsContainerLookup(TargetArea target, RulesetInfo? ruleset = null)
{ {
Target = target; Target = target;
Ruleset = ruleset;
} }
/// <summary> /// <summary>

View File

@ -201,7 +201,7 @@ namespace osu.Game.Skinning
} }
// Then serialise each of the drawable component groups into respective files. // 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 }); string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented });

View File

@ -0,0 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// A serialisable model describing layout of a <see cref="SkinComponentsContainer"/>.
/// May contain multiple configurations for different rulesets, each of which should manifest their own <see cref="SkinComponentsContainer"/> as required.
/// </summary>
[Serializable]
public class SkinLayoutInfo
{
private const string global_identifier = @"global";
[JsonIgnore]
public IEnumerable<SerialisedDrawableInfo> AllDrawables => DrawableInfo.Values.SelectMany(v => v);
[JsonProperty]
public Dictionary<string, SerialisedDrawableInfo[]> DrawableInfo { get; set; } = new Dictionary<string, SerialisedDrawableInfo[]>();
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;
}
}

View File

@ -69,6 +69,10 @@ namespace osu.Game.Skinning
switch (lookup) switch (lookup)
{ {
case SkinComponentsContainerLookup containerLookup: case SkinComponentsContainerLookup containerLookup:
// Only handle global level defaults for now.
if (containerLookup.Ruleset != null)
return null;
switch (containerLookup.Target) switch (containerLookup.Target)
{ {
case SkinComponentsContainerLookup.TargetArea.SongSelect: case SkinComponentsContainerLookup.TargetArea.SongSelect: