1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-05 09:42:54 +08:00

Compare commits

...

13 Commits

Author SHA1 Message Date
Salman Alshamrani
782a0494ea
Merge c12cb2582e into be05f2a1c2 2024-12-03 16:58:59 +09:00
Bartłomiej Dach
c12cb2582e
Document & reduce visibility of GetInstanceType() 2024-12-02 10:03:19 +01:00
Salman Alshamrani
15a4726d68 Refactor skin deserialisation tests to better support skin migration 2024-11-28 23:27:04 -05:00
Salman Alshamrani
01f3a2dd14 Move non-legacy conditional reasoning to migration code 2024-11-27 06:12:52 -05:00
Salman Alshamrani
0e920a61da Use type equality 2024-11-25 23:49:55 -05:00
Salman Alshamrani
5ab9074f5d Move test and mark headless 2024-11-25 23:43:46 -05:00
Salman Alshamrani
fcbfbd02fd Add migration logic for legacy health displays 2024-11-25 23:39:37 -05:00
Salman Alshamrani
c2215b10cf Add extensive test scene for skin migrations
This is different from `SkinDeserialisationTest` in that layouts can be written programmatically with as much ease, allowing to test migration logic with different scenarios without running the game and exporting skins and attaching them to tests.
2024-11-25 23:23:39 -05:00
Salman Alshamrani
83ecfbd155 Revert "Refactor skin migration to allow mutating multiple layouts at once"
This reverts commit f1b5686904.
2024-11-25 23:23:39 -05:00
Salman Alshamrani
7df7727591 Revert "Migrate legacy health bar display to per-ruleset target"
This reverts commit 76f79ce083.
2024-11-25 23:23:38 -05:00
Salman Alshamrani
76f79ce083 Migrate legacy health bar display to per-ruleset target 2024-11-25 00:02:27 -05:00
Salman Alshamrani
f1b5686904 Refactor skin migration to allow mutating multiple layouts at once 2024-11-25 00:02:10 -05:00
Salman Alshamrani
8dca69e3f9 Add osu!mania specifications for health bar display 2024-11-25 00:02:10 -05:00
8 changed files with 696 additions and 197 deletions

View File

@ -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;

View File

@ -1,185 +0,0 @@
// 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.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
{
/// <summary>
/// Test that the main components (which are serialised based on namespace/class name)
/// remain compatible with any changes.
/// </summary>
/// <remarks>
/// If this test breaks, check any naming or class structure changes.
/// Migration rules may need to be added to <see cref="Skin"/>.
/// </remarks>
[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"
};
/// <summary>
/// If this test fails, new test resources should be added to include new components.
/// </summary>
[Test]
public void TestSkinnableComponentsCoveredByDeserialisationTests()
{
HashSet<Type> instantiatedTypes = new HashSet<Type>();
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<byte[]>? 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<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => throw new NotImplementedException();
public override ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,194 @@
// 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.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
{
/// <summary>
/// Test that the main components (which are serialised based on namespace/class name)
/// remain compatible with any changes.
/// </summary>
/// <remarks>
/// If this test breaks, check any naming or class structure changes.
/// Migration rules may need to be added to <see cref="Skin"/>.
/// </remarks>
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!;
/// <summary>
/// If this test fails, new test resources should be added to include new components.
/// </summary>
[Test]
public void TestSkinnableComponentsCoveredByDeserialisationTests()
{
HashSet<Type> instantiatedTypes = new HashSet<Type>();
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));
}
}
}

View File

@ -0,0 +1,400 @@
// 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.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<LegacySkin>());
AddAssert("skin has no configuration", () => !skin.LayoutInfos.Any());
}
[Test]
public void TestSomeConfiguration()
{
LegacySkin skin = null!;
AddStep("load skin", () =>
{
skin = loadSkin<LegacySkin>(new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>
{
{ 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<LegacySkin>(new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>
{
{
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<LegacySkin>(new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>
{
{
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<LegacySkin>(new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>
{
{
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<ArgonSkin>(new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>
{
{
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<LegacySkin>(new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>
{
{
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<LegacySkin>(new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>
{
{
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<SerialisedDrawableInfo>());
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<T>(IDictionary<GlobalSkinnableContainers, SkinLayoutInfo>? 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<RealmNamedFileUsage> Files { get; } = new List<RealmNamedFileUsage>();
public TestSkinInfo(string instantiationInfo, IDictionary<GlobalSkinnableContainers, SkinLayoutInfo>? 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<byte[]> Resources { get; } = new ResourceStore<byte[]>();
public IResourceStore<TextureUpload>? CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => null;
public AudioManager? AudioManager => null;
public IResourceStore<byte[]> Files { get; }
public RealmAccess RealmAccess { get; }
public TestStorageResourceProvider(IDictionary<GlobalSkinnableContainers, SkinLayoutInfo>? layout, IList<RealmNamedFileUsage> files, RealmAccess realm)
{
Files = new TestResourceStore(layout, files);
RealmAccess = realm;
}
private class TestResourceStore : ResourceStore<byte[]>
{
private readonly IDictionary<GlobalSkinnableContainers, SkinLayoutInfo>? layout;
private readonly IList<RealmNamedFileUsage> files;
public TestResourceStore(IDictionary<GlobalSkinnableContainers, SkinLayoutInfo>? layout, IList<RealmNamedFileUsage> 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<GlobalSkinnableContainers>(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);
}
}
}
}
}

View File

@ -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(),
}
};

View File

@ -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<RulesetInfo>())
{
// 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<RulesetInfo>(@"mania");
if (!layout.TryGetDrawableInfo(maniaRuleset, out var maniaPlayfieldComponents))
maniaPlayfieldComponents = Array.Empty<SerialisedDrawableInfo>();
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;
}
}
}

View File

@ -39,7 +39,10 @@ namespace osu.Game.Skinning
public bool Protected { get; set; }
public virtual Skin CreateInstance(IStorageResourceProvider resources)
/// <summary>
/// Returns the specific subtype of <see cref="Skin"/> that will be constructed on calling <see cref="CreateInstance"/>.
/// </summary>
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<RealmNamedFileUsage> Files { get; } = null!;
public virtual Skin CreateInstance(IStorageResourceProvider resources) => (Skin)Activator.CreateInstance(GetInstanceType(), this, resources)!;
public virtual IList<RealmNamedFileUsage> Files { get; } = null!;
public bool DeletePending { get; set; }

View File

@ -26,9 +26,10 @@ namespace osu.Game.Skinning
/// <list type="bullet">
/// <item><description>0: Initial version of all skin layouts.</description></item>
/// <item><description>1: Moves existing combo counters from global to per-ruleset HUD targets.</description></item>
/// <item><description>2: Moves existing legacy health bars from global to per-ruleset HUD targets, and to playfield target on mania.</description></item>
/// </list>
/// </remarks>
public const int LATEST_VERSION = 1;
public const int LATEST_VERSION = 2;
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public int Version = LATEST_VERSION;