diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 8f425edc44..68d36af9ac 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -9,6 +9,7 @@ using System.Linq; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -16,6 +17,7 @@ using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { @@ -106,6 +108,20 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { new LegacyManiaComboCounter(), }; + + case GlobalSkinnableContainers.Playfield: + return new Container + { + RelativeSizeAxes = Axes.Both, + Child = new LegacyHealthDisplay + { + Rotation = -90f, + Anchor = Anchor.BottomRight, + Origin = Anchor.TopLeft, + X = 1, + Scale = new Vector2(0.7f), + }, + }; } return null; diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs deleted file mode 100644 index 7372557161..0000000000 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Textures; -using osu.Framework.IO.Stores; -using osu.Game.Audio; -using osu.Game.IO; -using osu.Game.IO.Archives; -using osu.Game.Screens.Menu; -using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Play.HUD.HitErrorMeters; -using osu.Game.Skinning; -using osu.Game.Skinning.Components; -using osu.Game.Tests.Resources; - -namespace osu.Game.Tests.Skins -{ - /// - /// Test that the main components (which are serialised based on namespace/class name) - /// remain compatible with any changes. - /// - /// - /// If this test breaks, check any naming or class structure changes. - /// Migration rules may need to be added to . - /// - [TestFixture] - public class SkinDeserialisationTest - { - private static readonly string[] available_skins = - { - // Covers song progress before namespace changes, and most other components. - "Archives/modified-default-20220723.osk", - "Archives/modified-classic-20220723.osk", - // Covers legacy song progress, UR counter, colour hit error metre. - "Archives/modified-classic-20220801.osk", - // Covers clicks/s counter - "Archives/modified-default-20220818.osk", - // Covers longest combo counter - "Archives/modified-default-20221012.osk", - // Covers Argon variant of song progress bar - "Archives/modified-argon-20221024.osk", - // Covers TextElement and BeatmapInfoDrawable - "Archives/modified-default-20221102.osk", - // Covers BPM counter. - "Archives/modified-default-20221205.osk", - // Covers judgement counter. - "Archives/modified-default-20230117.osk", - // Covers player avatar and flag. - "Archives/modified-argon-20230305.osk", - // Covers key counters - "Archives/modified-argon-pro-20230618.osk", - // Covers "Argon" health display - "Archives/modified-argon-pro-20231001.osk", - // Covers player name text component. - "Archives/modified-argon-20231106.osk", - // Covers "Argon" accuracy/score/combo counters, and wedges - "Archives/modified-argon-20231108.osk", - // Covers "Argon" performance points counter - "Archives/modified-argon-20240305.osk", - // Covers default rank display - "Archives/modified-default-20230809.osk", - // Covers legacy rank display - "Archives/modified-classic-20230809.osk", - // Covers legacy key counter - "Archives/modified-classic-20240724.osk" - }; - - /// - /// If this test fails, new test resources should be added to include new components. - /// - [Test] - public void TestSkinnableComponentsCoveredByDeserialisationTests() - { - HashSet instantiatedTypes = new HashSet(); - - foreach (string oskFile in available_skins) - { - using (var stream = TestResources.OpenResource(oskFile)) - using (var storage = new ZipArchiveReader(stream)) - { - var skin = new TestSkin(new SkinInfo(), null, storage); - - foreach (var target in skin.LayoutInfos) - { - foreach (var info in target.Value.AllDrawables) - instantiatedTypes.Add(info.Type); - } - } - } - - var editableTypes = SerialisedDrawableInfo.GetAllAvailableDrawables().Where(t => (Activator.CreateInstance(t) as ISerialisableDrawable)?.IsEditable == true); - - Assert.That(instantiatedTypes, Is.EquivalentTo(editableTypes)); - } - - [Test] - public void TestDeserialiseModifiedDefault() - { - using (var stream = TestResources.OpenResource("Archives/modified-default-20220723.osk")) - using (var storage = new ZipArchiveReader(stream)) - { - var skin = new TestSkin(new SkinInfo(), null, storage); - - Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(9)); - } - } - - [Test] - public void TestDeserialiseModifiedArgon() - { - using (var stream = TestResources.OpenResource("Archives/modified-argon-20231106.osk")) - using (var storage = new ZipArchiveReader(stream)) - { - var skin = new TestSkin(new SkinInfo(), null, storage); - - Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(10)); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(PlayerName))); - } - } - - [Test] - public void TestDeserialiseInvalidDrawables() - { - using (var stream = TestResources.OpenResource("Archives/argon-invalid-drawable.osk")) - using (var storage = new ZipArchiveReader(stream)) - { - var skin = new TestSkin(new SkinInfo(), null, storage); - - Assert.That(skin.LayoutInfos.Any(kvp => kvp.Value.AllDrawables.Any(d => d.Type == typeof(StarFountain))), Is.False); - } - } - - [Test] - public void TestDeserialiseModifiedClassic() - { - using (var stream = TestResources.OpenResource("Archives/modified-classic-20220723.osk")) - using (var storage = new ZipArchiveReader(stream)) - { - var skin = new TestSkin(new SkinInfo(), null, storage); - - Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(6)); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.SongSelect].AllDrawables.ToArray(), Has.Length.EqualTo(1)); - - var skinnableInfo = skin.LayoutInfos[GlobalSkinnableContainers.SongSelect].AllDrawables.First(); - - Assert.That(skinnableInfo.Type, Is.EqualTo(typeof(SkinnableSprite))); - Assert.That(skinnableInfo.Settings.First().Key, Is.EqualTo("sprite_name")); - Assert.That(skinnableInfo.Settings.First().Value, Is.EqualTo("ppy_logo-2.png")); - } - - using (var stream = TestResources.OpenResource("Archives/modified-classic-20220801.osk")) - using (var storage = new ZipArchiveReader(stream)) - { - var skin = new TestSkin(new SkinInfo(), null, storage); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8)); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); - } - } - - private class TestSkin : Skin - { - public TestSkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? fallbackStore = null, string configurationFilename = "skin.ini") - : base(skin, resources, fallbackStore, configurationFilename) - { - } - - public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - - public override IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); - - public override ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); - } - } -} diff --git a/osu.Game.Tests/Skins/TestSceneSkinDeserialisation.cs b/osu.Game.Tests/Skins/TestSceneSkinDeserialisation.cs new file mode 100644 index 0000000000..595180c27c --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneSkinDeserialisation.cs @@ -0,0 +1,194 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Game.Database; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + /// + /// Test that the main components (which are serialised based on namespace/class name) + /// remain compatible with any changes. + /// + /// + /// If this test breaks, check any naming or class structure changes. + /// Migration rules may need to be added to . + /// + public partial class TestSceneSkinDeserialisation : OsuTestScene + { + private static readonly string[] available_skins = + { + // Covers song progress before namespace changes, and most other components. + "Archives/modified-default-20220723.osk", + "Archives/modified-classic-20220723.osk", + // Covers legacy song progress, UR counter, colour hit error metre. + "Archives/modified-classic-20220801.osk", + // Covers clicks/s counter + "Archives/modified-default-20220818.osk", + // Covers longest combo counter + "Archives/modified-default-20221012.osk", + // Covers Argon variant of song progress bar + "Archives/modified-argon-20221024.osk", + // Covers TextElement and BeatmapInfoDrawable + "Archives/modified-default-20221102.osk", + // Covers BPM counter. + "Archives/modified-default-20221205.osk", + // Covers judgement counter. + "Archives/modified-default-20230117.osk", + // Covers player avatar and flag. + "Archives/modified-argon-20230305.osk", + // Covers key counters + "Archives/modified-argon-pro-20230618.osk", + // Covers "Argon" health display + "Archives/modified-argon-pro-20231001.osk", + // Covers player name text component. + "Archives/modified-argon-20231106.osk", + // Covers "Argon" accuracy/score/combo counters, and wedges + "Archives/modified-argon-20231108.osk", + // Covers "Argon" performance points counter + "Archives/modified-argon-20240305.osk", + // Covers default rank display + "Archives/modified-default-20230809.osk", + // Covers legacy rank display + "Archives/modified-classic-20230809.osk", + // Covers legacy key counter + "Archives/modified-classic-20240724.osk" + }; + + [Resolved] + private SkinManager skins { get; set; } = null!; + + /// + /// If this test fails, new test resources should be added to include new components. + /// + [Test] + public void TestSkinnableComponentsCoveredByDeserialisationTests() + { + HashSet instantiatedTypes = new HashSet(); + + AddStep("load skin", () => + { + foreach (string oskFile in available_skins) + { + var skin = importSkinFromArchives(oskFile); + + foreach (var target in skin.LayoutInfos) + { + foreach (var info in target.Value.AllDrawables) + instantiatedTypes.Add(info.Type); + } + } + }); + + var existingTypes = SerialisedDrawableInfo.GetAllAvailableDrawables().Where(t => (Activator.CreateInstance(t) as ISerialisableDrawable)?.IsEditable == true); + + AddAssert("all types available", () => instantiatedTypes, () => Is.EquivalentTo(existingTypes)); + } + + [Test] + public void TestDeserialiseModifiedDefault() + { + Skin skin = null!; + + AddStep("load skin", () => skin = importSkinFromArchives("Archives/modified-default-20220723.osk")); + + AddAssert("layouts count = 2", () => skin.LayoutInfos, () => Has.Count.EqualTo(2)); + AddAssert("hud count = 12", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), + () => Has.Length.EqualTo(12)); + } + + [Test] + public void TestDeserialiseModifiedArgon() + { + Skin skin = null!; + + AddStep("load skin", () => skin = importSkinFromArchives("Archives/modified-argon-20231106.osk")); + + AddAssert("layouts count = 2", () => skin.LayoutInfos, () => Has.Count.EqualTo(2)); + AddAssert("hud count = 13", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), + () => Has.Length.EqualTo(13)); + + AddAssert("hud contains player name", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), + () => Does.Contain(typeof(PlayerName))); + } + + [Test] + public void TestDeserialiseInvalidDrawables() + { + Skin skin = null!; + + AddStep("load skin", () => skin = importSkinFromArchives("Archives/argon-invalid-drawable.osk")); + + AddAssert("skin does not contain star fountain", + () => skin.LayoutInfos.SelectMany(kvp => kvp.Value.AllDrawables).Select(d => d.Type), + () => Does.Not.Contain(typeof(StarFountain))); + } + + [Test] + public void TestDeserialiseModifiedClassic() + { + Skin skin = null!; + + AddStep("load skin", () => skin = importSkinFromArchives("Archives/modified-classic-20220723.osk")); + + AddAssert("layouts count = 2", () => skin.LayoutInfos, () => Has.Count.EqualTo(2)); + AddAssert("hud count = 11", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), + () => Has.Length.EqualTo(11)); + + AddAssert("song select count = 1", + () => skin.LayoutInfos[GlobalSkinnableContainers.SongSelect].AllDrawables.ToArray(), + () => Has.Length.EqualTo(1)); + + AddAssert("song select component correct", () => + { + var skinnableInfo = skin.LayoutInfos[GlobalSkinnableContainers.SongSelect].AllDrawables.First(); + + Assert.That(skinnableInfo.Type, Is.EqualTo(typeof(SkinnableSprite))); + Assert.That(skinnableInfo.Settings.First().Key, Is.EqualTo("sprite_name")); + Assert.That(skinnableInfo.Settings.First().Value, Is.EqualTo("ppy_logo-2.png")); + return true; + }); + + AddStep("load another skin", () => skin = importSkinFromArchives("Archives/modified-classic-20220801.osk")); + + AddAssert("hud count = 13", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), + () => Has.Length.EqualTo(13)); + + AddAssert("hud contains ur counter", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), + () => Does.Contain(typeof(UnstableRateCounter))); + + AddAssert("hud contains colour hit error", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), + () => Does.Contain(typeof(ColourHitErrorMeter))); + + AddAssert("hud contains legacy song progress", + () => skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), + () => Does.Contain(typeof(LegacySongProgress))); + } + + private Skin importSkinFromArchives(string filename) + { + var imported = skins.Import(new ImportTask(TestResources.OpenResource(filename), Path.GetFileNameWithoutExtension(filename))).GetResultSafely(); + return imported.PerformRead(skinInfo => skins.GetSkin(skinInfo)); + } + } +} diff --git a/osu.Game.Tests/Skins/TestSceneSkinMigration.cs b/osu.Game.Tests/Skins/TestSceneSkinMigration.cs new file mode 100644 index 0000000000..baee5ec2f4 --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneSkinMigration.cs @@ -0,0 +1,400 @@ +// 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 System.Text; +using Newtonsoft.Json; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Dummy; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + [HeadlessTest] + public partial class TestSceneSkinMigration : OsuTestScene + { + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Test] + public void TestEmptyConfiguration() + { + LegacySkin skin = null!; + + AddStep("load skin with empty configuration", () => skin = loadSkin()); + AddAssert("skin has no configuration", () => !skin.LayoutInfos.Any()); + } + + [Test] + public void TestSomeConfiguration() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { GlobalSkinnableContainers.MainHUDComponents, createLayout(SkinLayoutInfo.LATEST_VERSION, [nameof(LegacyHealthDisplay)]) }, + }); + }); + + AddAssert("skin has correct configuration", () => + { + return skin.LayoutInfos.Single().Value.DrawableInfo["global"].Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyHealthDisplay)]) && + skin.LayoutInfos.Single().Value.DrawableInfo.Where(d => d.Key != @"global") + .All(d => d.Value.Single().Type.Name == nameof(BigBlackBox)); + }); + } + + #region Version 1 + + [Test] + public void TestMigration_1() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.MainHUDComponents, + createLayout(0, [nameof(LegacyDefaultComboCounter)]) + }, + }); + }); + + AddAssert("combo counter removed from global", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"global").Value.Single().Type.Name == nameof(BigBlackBox); + }); + AddAssert("combo counter moved to each ruleset", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Where(kvp => kvp.Key != @"global").All(kvp => kvp.Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyDefaultComboCounter)])); + }); + } + + [Test] + public void TestMigration_1_NoComboCounter() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.MainHUDComponents, + createLayout(0) + }, + }); + }); + + AddAssert("nothing removed from global", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"global").Value.Single().Type.Name == nameof(BigBlackBox); + }); + AddAssert("no combo counter added", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Where(kvp => kvp.Key != @"global").All(kvp => kvp.Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox)])); + }); + } + + #endregion + + #region Version 2 + + [Test] + public void TestMigration_2() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.MainHUDComponents, + createLayout(1, [nameof(LegacyHealthDisplay)]) + }, + { + GlobalSkinnableContainers.Playfield, + createLayout(1) + } + }); + }); + + // HUD + AddAssert("health display removed from global HUD", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"global").Value.Single().Type.Name == nameof(BigBlackBox); + }); + AddAssert("health display moved to each ruleset except mania", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo.ToArray(); + dict = dict.Where(kvp => kvp.Key != @"global" && kvp.Key != @"mania").ToArray(); + + return dict.All(kvp => kvp.Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyHealthDisplay)])) && + dict.All(kvp => kvp.Value.Single(d => d.Type.Name == nameof(LegacyHealthDisplay)).Rotation == 0f); + }); + AddAssert("no health display in mania HUD", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"mania").Value.Single().Type.Name == nameof(BigBlackBox); + }); + + // Playfield + AddAssert("health display in mania moved to playfield", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.Playfield].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"mania").Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyHealthDisplay)]) && + dict.Single(kvp => kvp.Key == @"mania").Value.Single(d => d.Type.Name == nameof(LegacyHealthDisplay)).Rotation == -90f; + }); + AddAssert("rest is unaffected", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.Playfield].DrawableInfo; + return dict.Where(kvp => kvp.Key != @"mania").All(kvp => kvp.Value.Single().Type.Name == nameof(BigBlackBox)); + }); + } + + [Test] + public void TestMigration_2_NonLegacySkin() + { + ArgonSkin skin = null!; + + AddStep("load argon skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.MainHUDComponents, + createLayout(1, [nameof(LegacyHealthDisplay)]) + }, + { + GlobalSkinnableContainers.Playfield, + createLayout(1) + } + }); + }); + + // HUD + AddAssert("health display still in global HUD", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + + return dict.Single(kvp => kvp.Key == @"global").Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyHealthDisplay)]) && + dict.Single(kvp => kvp.Key == @"global").Value.Single(d => d.Type.Name == nameof(LegacyHealthDisplay)).Rotation == 0f; + }); + AddAssert("ruleset HUDs unaffected", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo.ToArray(); + return dict.Where(kvp => kvp.Key != @"global").All(kvp => kvp.Value.Single().Type.Name == nameof(BigBlackBox)); + }); + + // Playfield + AddAssert("playfield unaffected", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.Playfield].DrawableInfo.ToArray(); + return dict.All(kvp => kvp.Value.Single().Type.Name == nameof(BigBlackBox)); + }); + } + + [Test] + public void TestMigration_2_NoHUD() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.Playfield, + createLayout(1) + }, + }); + }); + + // In this case, we must add a health display to the Playfield target, + // otherwise on mania the user will not see a health display anymore. + + // HUD + AddAssert("HUD not configured", () => !skin.LayoutInfos.ContainsKey(GlobalSkinnableContainers.MainHUDComponents)); + + // Playfield + AddAssert("health display in mania moved to playfield", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.Playfield].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"mania").Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyHealthDisplay)]) && + dict.Single(kvp => kvp.Key == @"mania").Value.Single(d => d.Type.Name == nameof(LegacyHealthDisplay)).Rotation == -90f; + }); + AddAssert("rest is unaffected", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.Playfield].DrawableInfo; + return dict.Where(kvp => kvp.Key != @"mania").All(d => d.Value.Single().Type.Name == nameof(BigBlackBox)); + }); + } + + [Test] + public void TestMigration_2_NoPlayfield() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.MainHUDComponents, + createLayout(1, [nameof(LegacyHealthDisplay)]) + } + }); + }); + + // HUD + AddAssert("health display removed from global HUD", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"global").Value.Single().Type.Name == nameof(BigBlackBox); + }); + AddAssert("health display moved to each ruleset except mania", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo.ToArray(); + dict = dict.Where(kvp => kvp.Key != @"global" && kvp.Key != @"mania").ToArray(); + + return dict.All(kvp => kvp.Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyHealthDisplay)])) && + dict.All(kvp => kvp.Value.Single(d => d.Type.Name == nameof(LegacyHealthDisplay)).Rotation == 0f); + }); + AddAssert("no health display in mania HUD", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"mania").Value.Single().Type.Name == nameof(BigBlackBox); + }); + + // Playfield + AddAssert("playfield not configured", () => !skin.LayoutInfos.ContainsKey(GlobalSkinnableContainers.Playfield)); + } + + #endregion + + private SkinLayoutInfo createLayout(int version, string[]? globalComponents = null, string? ruleset = null, string[]? rulesetComponents = null) + { + var info = new SkinLayoutInfo { Version = version }; + + if (globalComponents != null) + info.DrawableInfo.Add(@"global", globalComponents.Select(c => resolveComponent(c).CreateSerialisedInfo()).ToArray()); + + if (ruleset != null && rulesetComponents != null) + info.DrawableInfo.Add(ruleset, rulesetComponents.Select(c => resolveComponent(c).CreateSerialisedInfo()).ToArray()); + + // add random drawable to ensure nothing is incorrectly discarded + foreach (string key in rulesets.AvailableRulesets.Select(r => r.ShortName).Prepend(@"global")) + { + if (!info.DrawableInfo.TryGetValue(key, out var drawables)) + info.DrawableInfo.Add(key, drawables = Array.Empty()); + + info.DrawableInfo[key] = drawables.Prepend(new BigBlackBox().CreateSerialisedInfo()).ToArray(); + } + + return info; + } + + private Drawable resolveComponent(string name, string? ruleset = null) + { + var drawables = SerialisedDrawableInfo.GetAllAvailableDrawables(); + + if (ruleset != null) + drawables = drawables.Concat(SerialisedDrawableInfo.GetAllAvailableDrawables(rulesets.GetRuleset(ruleset))).ToArray(); + + return (Drawable)Activator.CreateInstance(drawables.Single(d => d.Name == name))!; + } + + private T loadSkin(IDictionary? layout = null) + where T : Skin + { + var info = new TestSkinInfo(typeof(T).GetInvariantInstantiationInfo(), layout); + return (T)info.CreateInstance(new TestStorageResourceProvider(layout, info.Files, Realm)); + } + + private class TestSkinInfo : SkinInfo + { + public override IList Files { get; } = new List(); + + public TestSkinInfo(string instantiationInfo, IDictionary? layout) + : base("test skin", "me", instantiationInfo) + { + if (layout != null) + { + foreach (var kvp in layout) + Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = Guid.NewGuid().ToString().ComputeMD5Hash() }, kvp.Key + ".json")); + } + } + } + + private class TestStorageResourceProvider : IStorageResourceProvider + { + public IRenderer Renderer { get; } = new DummyRenderer(); + public IResourceStore Resources { get; } = new ResourceStore(); + public IResourceStore? CreateTextureLoaderStore(IResourceStore underlyingStore) => null; + + public AudioManager? AudioManager => null; + + public IResourceStore Files { get; } + public RealmAccess RealmAccess { get; } + + public TestStorageResourceProvider(IDictionary? layout, IList files, RealmAccess realm) + { + Files = new TestResourceStore(layout, files); + RealmAccess = realm; + } + + private class TestResourceStore : ResourceStore + { + private readonly IDictionary? layout; + private readonly IList files; + + public TestResourceStore(IDictionary? layout, IList files) + { + this.layout = layout; + this.files = files; + } + + public override byte[] Get(string name) + { + string? filename = files.SingleOrDefault(f => f.File.GetStoragePath() == name)?.Filename; + if (filename == null || layout == null) + return base.Get(name); + + if (!Enum.TryParse(filename.Replace(@".json", string.Empty), out var type) || + !layout.TryGetValue(type, out var info)) + return base.Get(name); + + string json = JsonConvert.SerializeObject(info, new JsonSerializerSettings { Formatting = Formatting.Indented }); + return Encoding.UTF8.GetBytes(json); + } + } + } + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 6faadfba9b..da7c95a46f 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -376,7 +376,8 @@ namespace osu.Game.Skinning } }) { - new LegacyDefaultComboCounter() + new LegacyDefaultComboCounter(), + new LegacyHealthDisplay(), }; } @@ -415,7 +416,6 @@ namespace osu.Game.Skinning new LegacyScoreCounter(), new LegacyAccuracyCounter(), new LegacySongProgress(), - new LegacyHealthDisplay(), new BarHitErrorMeter(), } }; diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index e93a10d50b..5f046959af 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -23,6 +23,7 @@ using osu.Game.Database; using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Screens.Play.HUD; +using osuTK; namespace osu.Game.Skinning { @@ -277,6 +278,10 @@ namespace osu.Game.Skinning private void applyMigration(SkinLayoutInfo layout, GlobalSkinnableContainers target, int version) { + Debug.Assert(resources != null); + + bool isLegacySkin = SkinInfo.PerformRead(s => s.GetInstanceType().IsAssignableTo(typeof(LegacySkin))); + switch (version) { case 1: @@ -284,14 +289,13 @@ namespace osu.Game.Skinning // Combo counters were moved out of the global HUD components into per-ruleset. // This is to allow some rulesets to customise further (ie. mania and catch moving the combo to within their play area). if (target != GlobalSkinnableContainers.MainHUDComponents || - !layout.TryGetDrawableInfo(null, out var globalHUDComponents) || - resources == null) + !layout.TryGetDrawableInfo(null, out var globalHUDComponents)) break; var comboCounters = globalHUDComponents.Where(c => - c.Type.Name == nameof(LegacyDefaultComboCounter) || - c.Type.Name == nameof(DefaultComboCounter) || - c.Type.Name == nameof(ArgonComboCounter)).ToArray(); + c.Type == typeof(LegacyDefaultComboCounter) || + c.Type == typeof(DefaultComboCounter) || + c.Type == typeof(ArgonComboCounter)).ToArray(); layout.Update(null, globalHUDComponents.Except(comboCounters).ToArray()); @@ -307,6 +311,70 @@ namespace osu.Game.Skinning break; } + + case 2: + { + // Health displays are moved out of the global HUD components and into per-ruleset, + // except for osu!mania, wherein the health display is moved to the Playfield target. + switch (target) + { + case GlobalSkinnableContainers.MainHUDComponents: + if (!isLegacySkin || !layout.TryGetDrawableInfo(null, out var globalHUDComponents)) + break; + + var healthDisplays = globalHUDComponents.Where(c => c.Type == typeof(LegacyHealthDisplay)).ToArray(); + layout.Update(null, globalHUDComponents.Except(healthDisplays).ToArray()); + + resources.RealmAccess.Run(r => + { + foreach (var ruleset in r.All()) + { + // for mania, the health display is moved from MainHUDComponents to Playfield. + if (ruleset.ShortName == @"mania") + continue; + + layout.Update(ruleset, layout.TryGetDrawableInfo(ruleset, out var rulesetHUDComponents) + ? rulesetHUDComponents.Concat(healthDisplays).ToArray() + : healthDisplays); + } + }); + + break; + + case GlobalSkinnableContainers.Playfield: + if (!isLegacySkin) + { + // One may argue that if a LegacyHealthDisplay exists in a non-legacy skin, + // then it should be swapped with the mania variant similar to legacy skins. + // This is not simple to achieve as we have to be aware of the presence of + // the health display in the HUD layout while migrating the Playfield layout, + // which is impossible with the current structure of skin layout migration. + // Instead, don't touch any non-legacy skin and call it a day. + break; + } + + resources.RealmAccess.Run(r => + { + var maniaRuleset = r.Find(@"mania"); + + if (!layout.TryGetDrawableInfo(maniaRuleset, out var maniaPlayfieldComponents)) + maniaPlayfieldComponents = Array.Empty(); + + layout.Update(maniaRuleset, maniaPlayfieldComponents.Append(new LegacyHealthDisplay + { + Rotation = -90f, + Anchor = Anchor.BottomRight, + Origin = Anchor.TopLeft, + X = 1, + Scale = new Vector2(0.7f), + }.CreateSerialisedInfo()).ToArray()); + }); + + break; + } + + break; + } } } diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 9763d3b57e..571deca8aa 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -39,7 +39,10 @@ namespace osu.Game.Skinning public bool Protected { get; set; } - public virtual Skin CreateInstance(IStorageResourceProvider resources) + /// + /// Returns the specific subtype of that will be constructed on calling . + /// + internal Type GetInstanceType() { var type = string.IsNullOrEmpty(InstantiationInfo) // handle the case of skins imported before InstantiationInfo was added. @@ -52,13 +55,15 @@ namespace osu.Game.Skinning // for user modified skins. This aims to amicably handle that. // If we ever add more default skins in the future this will need some kind of proper migration rather than // a single fallback. - return new TrianglesSkin(this, resources); + return typeof(TrianglesSkin); } - return (Skin)Activator.CreateInstance(type, this, resources)!; + return type; } - public IList Files { get; } = null!; + public virtual Skin CreateInstance(IStorageResourceProvider resources) => (Skin)Activator.CreateInstance(GetInstanceType(), this, resources)!; + + public virtual IList Files { get; } = null!; public bool DeletePending { get; set; } diff --git a/osu.Game/Skinning/SkinLayoutInfo.cs b/osu.Game/Skinning/SkinLayoutInfo.cs index bf6c693621..4eee1cac5a 100644 --- a/osu.Game/Skinning/SkinLayoutInfo.cs +++ b/osu.Game/Skinning/SkinLayoutInfo.cs @@ -26,9 +26,10 @@ namespace osu.Game.Skinning /// /// 0: Initial version of all skin layouts. /// 1: Moves existing combo counters from global to per-ruleset HUD targets. + /// 2: Moves existing legacy health bars from global to per-ruleset HUD targets, and to playfield target on mania. /// /// - public const int LATEST_VERSION = 1; + public const int LATEST_VERSION = 2; [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] public int Version = LATEST_VERSION;