mirror of
https://github.com/ppy/osu.git
synced 2026-05-21 10:50:20 +08:00
Compare commits
115 Commits
+1
-1
@@ -52,7 +52,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1203.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1207.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Transitive Dependencies">
|
||||
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
|
||||
|
||||
@@ -174,7 +174,7 @@ namespace osu.Game.Rulesets.Catch.Tests
|
||||
|
||||
private Drawable setupSkinHierarchy(Drawable child, ISkin skin)
|
||||
{
|
||||
var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info));
|
||||
var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.CreateInfo()));
|
||||
var testSkinProvider = new SkinProvidingContainer(skin);
|
||||
var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Screens.Edit;
|
||||
@@ -55,13 +56,13 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
|
||||
[Test]
|
||||
public void TestDefaultSkin()
|
||||
{
|
||||
AddStep("set default skin", () => skins.CurrentSkinInfo.Value = SkinInfo.Default);
|
||||
AddStep("set default skin", () => skins.CurrentSkinInfo.Value = DefaultSkin.CreateInfo().ToLive());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestLegacySkin()
|
||||
{
|
||||
AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.Info);
|
||||
AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.CreateInfo().ToLive());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
|
||||
AddStep("create slider", () =>
|
||||
{
|
||||
var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.Info);
|
||||
var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo());
|
||||
tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1";
|
||||
|
||||
Child = new SkinProvidingContainer(tintingSkin)
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Replays;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Catch;
|
||||
using osu.Game.Rulesets.Mania;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
using osu.Game.Rulesets.Osu.Replays;
|
||||
using osu.Game.Rulesets.Osu.UI;
|
||||
using osu.Game.Rulesets.Replays;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Taiko;
|
||||
using osu.Game.Scoring;
|
||||
@@ -21,6 +27,14 @@ namespace osu.Game.Tests.Beatmaps.Formats
|
||||
[TestFixture]
|
||||
public class LegacyScoreDecoderTest
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
originalCulture = CultureInfo.CurrentCulture;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDecodeManiaReplay()
|
||||
{
|
||||
@@ -44,6 +58,59 @@ namespace osu.Game.Tests.Beatmaps.Formats
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestCultureInvariance()
|
||||
{
|
||||
var ruleset = new OsuRuleset().RulesetInfo;
|
||||
var scoreInfo = new TestScoreInfo(ruleset);
|
||||
var beatmap = new TestBeatmap(ruleset);
|
||||
var score = new Score
|
||||
{
|
||||
ScoreInfo = scoreInfo,
|
||||
Replay = new Replay
|
||||
{
|
||||
Frames = new List<ReplayFrame>
|
||||
{
|
||||
new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// the "se" culture is used here, as it encodes the negative number sign as U+2212 MINUS SIGN,
|
||||
// rather than the classic ASCII U+002D HYPHEN-MINUS.
|
||||
CultureInfo.CurrentCulture = new CultureInfo("se");
|
||||
|
||||
var encodeStream = new MemoryStream();
|
||||
|
||||
var encoder = new LegacyScoreEncoder(score, beatmap);
|
||||
encoder.Encode(encodeStream);
|
||||
|
||||
var decodeStream = new MemoryStream(encodeStream.GetBuffer());
|
||||
|
||||
var decoder = new TestLegacyScoreDecoder();
|
||||
var decodedAfterEncode = decoder.Parse(decodeStream);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(decodedAfterEncode, Is.Not.Null);
|
||||
|
||||
Assert.That(decodedAfterEncode.ScoreInfo.User.Username, Is.EqualTo(scoreInfo.User.Username));
|
||||
Assert.That(decodedAfterEncode.ScoreInfo.BeatmapInfoID, Is.EqualTo(scoreInfo.BeatmapInfoID));
|
||||
Assert.That(decodedAfterEncode.ScoreInfo.Ruleset, Is.EqualTo(scoreInfo.Ruleset));
|
||||
Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(scoreInfo.TotalScore));
|
||||
Assert.That(decodedAfterEncode.ScoreInfo.MaxCombo, Is.EqualTo(scoreInfo.MaxCombo));
|
||||
Assert.That(decodedAfterEncode.ScoreInfo.Date, Is.EqualTo(scoreInfo.Date));
|
||||
|
||||
Assert.That(decodedAfterEncode.Replay.Frames.Count, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
}
|
||||
|
||||
private class TestLegacyScoreDecoder : LegacyScoreDecoder
|
||||
{
|
||||
private static readonly Dictionary<int, Ruleset> rulesets = new Ruleset[]
|
||||
|
||||
@@ -45,9 +45,9 @@ namespace osu.Game.Tests.Database
|
||||
{
|
||||
var rulesets = new RealmRulesetStore(realmFactory, storage);
|
||||
|
||||
Assert.IsTrue((rulesets.AvailableRulesets.First() as RealmRuleset)?.IsManaged == false);
|
||||
Assert.IsTrue((rulesets.GetRuleset(0) as RealmRuleset)?.IsManaged == false);
|
||||
Assert.IsTrue((rulesets.GetRuleset("mania") as RealmRuleset)?.IsManaged == false);
|
||||
Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged);
|
||||
Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged);
|
||||
Assert.IsFalse(rulesets.GetRuleset("mania")?.IsManaged);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ namespace osu.Game.Tests.Gameplay
|
||||
private class TestSkin : LegacySkin
|
||||
{
|
||||
public TestSkin(string resourceName, IStorageResourceProvider resources)
|
||||
: base(DefaultLegacySkin.Info, new TestResourceStore(resourceName), resources, "skin.ini")
|
||||
: base(DefaultLegacySkin.CreateInfo(), new TestResourceStore(resourceName), resources, "skin.ini")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Skinning;
|
||||
@@ -163,32 +164,109 @@ namespace osu.Game.Tests.Skins.IO
|
||||
assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", osu);
|
||||
});
|
||||
|
||||
[Test]
|
||||
public Task TestExportThenImportDefaultSkin() => runSkinTest(osu =>
|
||||
{
|
||||
var skinManager = osu.Dependencies.Get<SkinManager>();
|
||||
|
||||
skinManager.EnsureMutableSkin();
|
||||
|
||||
MemoryStream exportStream = new MemoryStream();
|
||||
|
||||
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
|
||||
|
||||
skinManager.CurrentSkinInfo.Value.PerformRead(s =>
|
||||
{
|
||||
Assert.IsFalse(s.Protected);
|
||||
Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType());
|
||||
|
||||
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
|
||||
|
||||
Assert.Greater(exportStream.Length, 0);
|
||||
});
|
||||
|
||||
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk"));
|
||||
|
||||
imported.Result.PerformRead(s =>
|
||||
{
|
||||
Assert.IsFalse(s.Protected);
|
||||
Assert.AreNotEqual(originalSkinId, s.ID);
|
||||
Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType());
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
[Test]
|
||||
public Task TestExportThenImportClassicSkin() => runSkinTest(osu =>
|
||||
{
|
||||
var skinManager = osu.Dependencies.Get<SkinManager>();
|
||||
|
||||
skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo;
|
||||
|
||||
skinManager.EnsureMutableSkin();
|
||||
|
||||
MemoryStream exportStream = new MemoryStream();
|
||||
|
||||
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
|
||||
|
||||
skinManager.CurrentSkinInfo.Value.PerformRead(s =>
|
||||
{
|
||||
Assert.IsFalse(s.Protected);
|
||||
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
|
||||
|
||||
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
|
||||
|
||||
Assert.Greater(exportStream.Length, 0);
|
||||
});
|
||||
|
||||
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk"));
|
||||
|
||||
imported.Result.PerformRead(s =>
|
||||
{
|
||||
Assert.IsFalse(s.Protected);
|
||||
Assert.AreNotEqual(originalSkinId, s.ID);
|
||||
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
#endregion
|
||||
|
||||
private void assertCorrectMetadata(SkinInfo import1, string name, string creator, OsuGameBase osu)
|
||||
private void assertCorrectMetadata(ILive<SkinInfo> import1, string name, string creator, OsuGameBase osu)
|
||||
{
|
||||
Assert.That(import1.Name, Is.EqualTo(name));
|
||||
Assert.That(import1.Creator, Is.EqualTo(creator));
|
||||
import1.PerformRead(i =>
|
||||
{
|
||||
Assert.That(i.Name, Is.EqualTo(name));
|
||||
Assert.That(i.Creator, Is.EqualTo(creator));
|
||||
|
||||
// for extra safety let's reconstruct the skin, reading from the skin.ini.
|
||||
var instance = import1.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager)));
|
||||
// for extra safety let's reconstruct the skin, reading from the skin.ini.
|
||||
var instance = i.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager)));
|
||||
|
||||
Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name));
|
||||
Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator));
|
||||
Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name));
|
||||
Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator));
|
||||
});
|
||||
}
|
||||
|
||||
private void assertImportedBoth(SkinInfo import1, SkinInfo import2)
|
||||
private void assertImportedBoth(ILive<SkinInfo> import1, ILive<SkinInfo> import2)
|
||||
{
|
||||
Assert.That(import2.ID, Is.Not.EqualTo(import1.ID));
|
||||
Assert.That(import2.Hash, Is.Not.EqualTo(import1.Hash));
|
||||
Assert.That(import2.Files.Select(f => f.FileInfoID), Is.Not.EquivalentTo(import1.Files.Select(f => f.FileInfoID)));
|
||||
import1.PerformRead(i1 => import2.PerformRead(i2 =>
|
||||
{
|
||||
Assert.That(i2.ID, Is.Not.EqualTo(i1.ID));
|
||||
Assert.That(i2.Hash, Is.Not.EqualTo(i1.Hash));
|
||||
Assert.That(i2.Files.First(), Is.Not.EqualTo(i1.Files.First()));
|
||||
}));
|
||||
}
|
||||
|
||||
private void assertImportedOnce(SkinInfo import1, SkinInfo import2)
|
||||
private void assertImportedOnce(ILive<SkinInfo> import1, ILive<SkinInfo> import2)
|
||||
{
|
||||
Assert.That(import2.ID, Is.EqualTo(import1.ID));
|
||||
Assert.That(import2.Hash, Is.EqualTo(import1.Hash));
|
||||
Assert.That(import2.Files.Select(f => f.FileInfoID), Is.EquivalentTo(import1.Files.Select(f => f.FileInfoID)));
|
||||
import1.PerformRead(i1 => import2.PerformRead(i2 =>
|
||||
{
|
||||
Assert.That(i2.ID, Is.EqualTo(i1.ID));
|
||||
Assert.That(i2.Hash, Is.EqualTo(i1.Hash));
|
||||
Assert.That(i2.Files.First(), Is.EqualTo(i1.Files.First()));
|
||||
}));
|
||||
}
|
||||
|
||||
private MemoryStream createEmptyOsk()
|
||||
@@ -255,10 +333,10 @@ namespace osu.Game.Tests.Skins.IO
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SkinInfo> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null)
|
||||
private async Task<ILive<SkinInfo>> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null)
|
||||
{
|
||||
var skinManager = osu.Dependencies.Get<SkinManager>();
|
||||
return (await skinManager.Import(archive)).Value;
|
||||
return await skinManager.Import(archive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace osu.Game.Tests.Skins
|
||||
private void load()
|
||||
{
|
||||
var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result;
|
||||
skin = skins.GetSkin(imported.Value);
|
||||
skin = imported.PerformRead(skinInfo => skins.GetSkin(skinInfo));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -8,6 +8,7 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Screens;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Graphics.Backgrounds;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
@@ -122,7 +123,7 @@ namespace osu.Game.Tests.Visual.Background
|
||||
private void setCustomSkin()
|
||||
{
|
||||
// feign a skin switch. this doesn't do anything except force CurrentSkin to become a LegacySkin.
|
||||
AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo { ID = 5 });
|
||||
AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo().ToLive());
|
||||
}
|
||||
|
||||
private void setDefaultSkin() => AddStep("set default skin", () => skins.CurrentSkinInfo.SetDefault());
|
||||
|
||||
@@ -12,6 +12,7 @@ using osu.Framework.Testing;
|
||||
using osu.Framework.Timing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
@@ -41,7 +42,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
[Test]
|
||||
public void TestEmptyLegacyBeatmapSkinFallsBack()
|
||||
{
|
||||
CreateSkinTest(SkinInfo.Default, () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null));
|
||||
CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null));
|
||||
AddUntilStep("wait for hud load", () => Player.ChildrenOfType<SkinnableTargetContainer>().All(c => c.ComponentsLoaded));
|
||||
AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value));
|
||||
}
|
||||
@@ -52,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
AddStep("setup skins", () =>
|
||||
{
|
||||
skinManager.CurrentSkinInfo.Value = gameCurrentSkin;
|
||||
skinManager.CurrentSkinInfo.Value = gameCurrentSkin.ToLive();
|
||||
currentBeatmapSkin = getBeatmapSkin();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,83 +43,88 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
[Cached]
|
||||
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => Schedule(() =>
|
||||
[SetUpSteps]
|
||||
public void SetUpSteps()
|
||||
{
|
||||
replay = new Replay();
|
||||
AddStep("Reset recorder state", cleanUpState);
|
||||
|
||||
Add(new GridContainer
|
||||
AddStep("Setup containers", () =>
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Content = new[]
|
||||
replay = new Replay();
|
||||
|
||||
Add(new GridContainer
|
||||
{
|
||||
new Drawable[]
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Content = new[]
|
||||
{
|
||||
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
new Drawable[]
|
||||
{
|
||||
Recorder = recorder = new TestReplayRecorder(new Score
|
||||
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
{
|
||||
Replay = replay,
|
||||
ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo }
|
||||
})
|
||||
{
|
||||
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
Recorder = recorder = new TestReplayRecorder(new Score
|
||||
{
|
||||
new Box
|
||||
Replay = replay,
|
||||
ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo }
|
||||
})
|
||||
{
|
||||
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
Colour = Color4.Brown,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Recording",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
new Drawable[]
|
||||
{
|
||||
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.Brown,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Recording",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
new Drawable[]
|
||||
{
|
||||
ReplayInputHandler = new TestFramedReplayInputHandler(replay)
|
||||
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
{
|
||||
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
ReplayInputHandler = new TestFramedReplayInputHandler(replay)
|
||||
{
|
||||
new Box
|
||||
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
Colour = Color4.DarkBlue,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Playback",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.DarkBlue,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Playback",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestBasic()
|
||||
@@ -184,7 +189,14 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
[TearDownSteps]
|
||||
public void TearDown()
|
||||
{
|
||||
AddStep("stop recorder", () => recorder.Expire());
|
||||
AddStep("stop recorder", cleanUpState);
|
||||
}
|
||||
|
||||
private void cleanUpState()
|
||||
{
|
||||
// Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`.
|
||||
recorder?.RemoveAndDisposeImmediately();
|
||||
recorder = null;
|
||||
}
|
||||
|
||||
public class TestFramedReplayInputHandler : FramedReplayInputHandler<TestReplayFrame>
|
||||
|
||||
@@ -1,228 +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 osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Input.Bindings;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Framework.Input.StateChanges;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Replays;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
using osu.Game.Rulesets.Replays;
|
||||
using osu.Game.Rulesets.UI;
|
||||
using osu.Game.Scoring;
|
||||
using osu.Game.Screens.Play;
|
||||
using osu.Game.Tests.Visual.UserInterface;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
public class TestSceneReplayRecording : OsuTestScene
|
||||
{
|
||||
private readonly TestRulesetInputManager playbackManager;
|
||||
|
||||
private readonly TestRulesetInputManager recordingManager;
|
||||
|
||||
[Cached]
|
||||
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
|
||||
|
||||
public TestSceneReplayRecording()
|
||||
{
|
||||
Replay replay = new Replay();
|
||||
|
||||
Add(new GridContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Content = new[]
|
||||
{
|
||||
new Drawable[]
|
||||
{
|
||||
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
{
|
||||
Recorder = new TestReplayRecorder(new Score
|
||||
{
|
||||
Replay = replay,
|
||||
ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo }
|
||||
})
|
||||
{
|
||||
ScreenSpaceToGamefield = pos => recordingManager?.ToLocalSpace(pos) ?? Vector2.Zero,
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.Brown,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Recording",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestConsumer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
new Drawable[]
|
||||
{
|
||||
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
{
|
||||
ReplayInputHandler = new TestFramedReplayInputHandler(replay)
|
||||
{
|
||||
GamefieldToScreenSpace = pos => playbackManager?.ToScreenSpace(pos) ?? Vector2.Zero,
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.DarkBlue,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Playback",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestConsumer()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
playbackManager.ReplayInputHandler.SetFrameFromTime(Time.Current - 500);
|
||||
}
|
||||
}
|
||||
|
||||
public class TestFramedReplayInputHandler : FramedReplayInputHandler<TestReplayFrame>
|
||||
{
|
||||
public TestFramedReplayInputHandler(Replay replay)
|
||||
: base(replay)
|
||||
{
|
||||
}
|
||||
|
||||
public override void CollectPendingInputs(List<IInput> inputs)
|
||||
{
|
||||
inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) });
|
||||
inputs.Add(new ReplayState<TestAction> { PressedActions = CurrentFrame?.Actions ?? new List<TestAction>() });
|
||||
}
|
||||
}
|
||||
|
||||
public class TestConsumer : CompositeDrawable, IKeyBindingHandler<TestAction>
|
||||
{
|
||||
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos);
|
||||
|
||||
private readonly Box box;
|
||||
|
||||
public TestConsumer()
|
||||
{
|
||||
Size = new Vector2(30);
|
||||
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
box = new Box
|
||||
{
|
||||
Colour = Color4.Black,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected override bool OnMouseMove(MouseMoveEvent e)
|
||||
{
|
||||
Position = e.MousePosition;
|
||||
return base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
public bool OnPressed(KeyBindingPressEvent<TestAction> e)
|
||||
{
|
||||
if (e.Repeat)
|
||||
return false;
|
||||
|
||||
box.Colour = Color4.White;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnReleased(KeyBindingReleaseEvent<TestAction> e)
|
||||
{
|
||||
box.Colour = Color4.Black;
|
||||
}
|
||||
}
|
||||
|
||||
public class TestRulesetInputManager : RulesetInputManager<TestAction>
|
||||
{
|
||||
public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique)
|
||||
: base(ruleset, variant, unique)
|
||||
{
|
||||
}
|
||||
|
||||
protected override KeyBindingContainer<TestAction> CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique)
|
||||
=> new TestKeyBindingContainer();
|
||||
|
||||
internal class TestKeyBindingContainer : KeyBindingContainer<TestAction>
|
||||
{
|
||||
public override IEnumerable<IKeyBinding> DefaultKeyBindings => new[]
|
||||
{
|
||||
new KeyBinding(InputKey.MouseLeft, TestAction.Down),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class TestReplayFrame : ReplayFrame
|
||||
{
|
||||
public Vector2 Position;
|
||||
|
||||
public List<TestAction> Actions = new List<TestAction>();
|
||||
|
||||
public TestReplayFrame(double time, Vector2 position, params TestAction[] actions)
|
||||
: base(time)
|
||||
{
|
||||
Position = position;
|
||||
Actions.AddRange(actions);
|
||||
}
|
||||
}
|
||||
|
||||
public enum TestAction
|
||||
{
|
||||
Down,
|
||||
}
|
||||
|
||||
internal class TestReplayRecorder : ReplayRecorder<TestAction>
|
||||
{
|
||||
public TestReplayRecorder(Score target)
|
||||
: base(target)
|
||||
{
|
||||
}
|
||||
|
||||
protected override ReplayFrame HandleFrame(Vector2 mousePosition, List<TestAction> actions, ReplayFrame previousFrame) =>
|
||||
new TestReplayFrame(Time.Current, mousePosition, actions.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
|
||||
private TestReplayRecorder recorder;
|
||||
|
||||
private readonly ManualClock manualClock = new ManualClock();
|
||||
private ManualClock manualClock;
|
||||
|
||||
private OsuSpriteText latencyDisplay;
|
||||
|
||||
@@ -66,113 +66,121 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
[Cached]
|
||||
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => Schedule(() =>
|
||||
[SetUpSteps]
|
||||
public void SetUpSteps()
|
||||
{
|
||||
replay = new Replay();
|
||||
AddStep("Reset recorder state", cleanUpState);
|
||||
|
||||
users.BindTo(spectatorClient.PlayingUsers);
|
||||
users.BindCollectionChanged((obj, args) =>
|
||||
AddStep("Setup containers", () =>
|
||||
{
|
||||
switch (args.Action)
|
||||
replay = new Replay();
|
||||
manualClock = new ManualClock();
|
||||
|
||||
spectatorClient.OnNewFrames += onNewFrames;
|
||||
|
||||
users.BindTo(spectatorClient.PlayingUsers);
|
||||
users.BindCollectionChanged((obj, args) =>
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Debug.Assert(args.NewItems != null);
|
||||
|
||||
foreach (int user in args.NewItems)
|
||||
{
|
||||
if (user == api.LocalUser.Value.Id)
|
||||
spectatorClient.WatchUser(user);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Debug.Assert(args.OldItems != null);
|
||||
|
||||
foreach (int user in args.OldItems)
|
||||
{
|
||||
if (user == api.LocalUser.Value.Id)
|
||||
spectatorClient.StopWatchingUser(user);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}, true);
|
||||
|
||||
spectatorClient.OnNewFrames += onNewFrames;
|
||||
|
||||
Add(new GridContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Content = new[]
|
||||
{
|
||||
new Drawable[]
|
||||
switch (args.Action)
|
||||
{
|
||||
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Debug.Assert(args.NewItems != null);
|
||||
|
||||
foreach (int user in args.NewItems)
|
||||
{
|
||||
if (user == api.LocalUser.Value.Id)
|
||||
spectatorClient.WatchUser(user);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Debug.Assert(args.OldItems != null);
|
||||
|
||||
foreach (int user in args.OldItems)
|
||||
{
|
||||
if (user == api.LocalUser.Value.Id)
|
||||
spectatorClient.StopWatchingUser(user);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}, true);
|
||||
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new GridContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Content = new[]
|
||||
{
|
||||
Recorder = recorder = new TestReplayRecorder
|
||||
new Drawable[]
|
||||
{
|
||||
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
{
|
||||
new Box
|
||||
Recorder = recorder = new TestReplayRecorder
|
||||
{
|
||||
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
Colour = Color4.Brown,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.Brown,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Sending",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Sending",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
new Drawable[]
|
||||
{
|
||||
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
{
|
||||
Clock = new FramedClock(manualClock),
|
||||
ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay)
|
||||
{
|
||||
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.DarkBlue,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Receiving",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
new Drawable[]
|
||||
{
|
||||
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
|
||||
{
|
||||
Clock = new FramedClock(manualClock),
|
||||
ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay)
|
||||
{
|
||||
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.DarkBlue,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = "Receiving",
|
||||
Scale = new Vector2(3),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
new TestInputConsumer()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
latencyDisplay = new OsuSpriteText()
|
||||
};
|
||||
});
|
||||
|
||||
Add(latencyDisplay = new OsuSpriteText());
|
||||
});
|
||||
}
|
||||
|
||||
private void onNewFrames(int userId, FrameDataBundle frames)
|
||||
{
|
||||
@@ -189,6 +197,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
[Test]
|
||||
public void TestBasic()
|
||||
{
|
||||
AddStep("Wait for user input", () => { });
|
||||
}
|
||||
|
||||
private double latency = SpectatorClient.TIME_BETWEEN_SENDS;
|
||||
@@ -232,11 +241,15 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
[TearDownSteps]
|
||||
public void TearDown()
|
||||
{
|
||||
AddStep("stop recorder", () =>
|
||||
{
|
||||
recorder.Expire();
|
||||
spectatorClient.OnNewFrames -= onNewFrames;
|
||||
});
|
||||
AddStep("stop recorder", cleanUpState);
|
||||
}
|
||||
|
||||
private void cleanUpState()
|
||||
{
|
||||
// Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`.
|
||||
recorder?.RemoveAndDisposeImmediately();
|
||||
recorder = null;
|
||||
spectatorClient.OnNewFrames -= onNewFrames;
|
||||
}
|
||||
|
||||
public class TestFramedReplayInputHandler : FramedReplayInputHandler<TestReplayFrame>
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Menus
|
||||
private TestToolbar toolbar;
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => Schedule(() =>
|
||||
|
||||
@@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Online.Multiplayer;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Online.Rooms.RoomStatuses;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
using osu.Game.Screens.OnlinePlay.Lounge;
|
||||
using osu.Game.Screens.OnlinePlay.Lounge.Components;
|
||||
using osu.Game.Screens.OnlinePlay.Match;
|
||||
using osu.Game.Tests.Beatmaps;
|
||||
using osuTK;
|
||||
|
||||
@@ -172,6 +174,39 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType<DrawableRoom.PasswordProtectedIcon>().Single().Alpha));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestMultiplayerRooms()
|
||||
{
|
||||
AddStep("create rooms", () => Child = new FillFlowContainer
|
||||
{
|
||||
AutoSizeAxes = Axes.Y,
|
||||
RelativeSizeAxes = Axes.X,
|
||||
Direction = FillDirection.Vertical,
|
||||
Spacing = new Vector2(5),
|
||||
Children = new[]
|
||||
{
|
||||
new DrawableMatchRoom(new Room
|
||||
{
|
||||
Name = { Value = "A host-only room" },
|
||||
QueueMode = { Value = QueueMode.HostOnly },
|
||||
Type = { Value = MatchType.HeadToHead }
|
||||
}),
|
||||
new DrawableMatchRoom(new Room
|
||||
{
|
||||
Name = { Value = "An all-players, team-versus room" },
|
||||
QueueMode = { Value = QueueMode.AllPlayers },
|
||||
Type = { Value = MatchType.TeamVersus }
|
||||
}),
|
||||
new DrawableMatchRoom(new Room
|
||||
{
|
||||
Name = { Value = "A round-robin room" },
|
||||
QueueMode = { Value = QueueMode.AllPlayersRoundRobin },
|
||||
Type = { Value = MatchType.HeadToHead }
|
||||
}),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private DrawableRoom createLoungeRoom(Room room)
|
||||
{
|
||||
room.Host.Value ??= new APIUser { Username = "peppy", Id = 2 };
|
||||
|
||||
@@ -397,6 +397,44 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
AddUntilStep("user state is idle", () => client.LocalUser?.State == MultiplayerUserState.Idle);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestPlayStartsWithCorrectBeatmapWhileAtSongSelect()
|
||||
{
|
||||
createRoom(() => new Room
|
||||
{
|
||||
Name = { Value = "Test Room" },
|
||||
Playlist =
|
||||
{
|
||||
new PlaylistItem
|
||||
{
|
||||
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
|
||||
Ruleset = { Value = new OsuRuleset().RulesetInfo },
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
AddStep("Enter song select", () =>
|
||||
{
|
||||
var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerScreenStack.CurrentScreen).CurrentSubScreen;
|
||||
|
||||
((MultiplayerMatchSubScreen)currentSubScreen).SelectBeatmap();
|
||||
});
|
||||
|
||||
AddUntilStep("wait for song select", () => this.ChildrenOfType<MultiplayerMatchSongSelect>().FirstOrDefault()?.BeatmapSetsLoaded == true);
|
||||
|
||||
AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == client.Room?.Playlist.First().BeatmapID);
|
||||
|
||||
AddStep("Select next beatmap", () => InputManager.Key(Key.Down));
|
||||
|
||||
AddUntilStep("Beatmap doesn't match current item", () => Beatmap.Value.BeatmapInfo.OnlineID != client.Room?.Playlist.First().BeatmapID);
|
||||
|
||||
AddStep("start match externally", () => client.StartMatch());
|
||||
|
||||
AddUntilStep("play started", () => multiplayerScreenStack.CurrentScreen is Player);
|
||||
|
||||
AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == client.Room?.Playlist.First().BeatmapID);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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.Linq;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Overlays.Settings.Sections;
|
||||
using osu.Game.Skinning;
|
||||
using osu.Game.Skinning.Editor;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Navigation
|
||||
{
|
||||
public class TestSceneEditDefaultSkin : OsuGameTestScene
|
||||
{
|
||||
private SkinManager skinManager => Game.Dependencies.Get<SkinManager>();
|
||||
private SkinEditorOverlay skinEditor => Game.Dependencies.Get<SkinEditorOverlay>();
|
||||
|
||||
[Test]
|
||||
public void TestEditDefaultSkin()
|
||||
{
|
||||
AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.DEFAULT_SKIN);
|
||||
|
||||
AddStep("open settings", () => { Game.Settings.Show(); });
|
||||
|
||||
// Until step requires as settings has a delayed load.
|
||||
AddUntilStep("export button disabled", () => Game.Settings.ChildrenOfType<SkinSection.ExportSkinButton>().SingleOrDefault()?.Enabled.Value == false);
|
||||
|
||||
// Will create a mutable skin.
|
||||
AddStep("open skin editor", () => skinEditor.Show());
|
||||
|
||||
// Until step required as the skin editor may take time to load (and an extra scheduled frame for the mutable part).
|
||||
AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.DEFAULT_SKIN);
|
||||
AddAssert("is not protected", () => skinManager.CurrentSkinInfo.Value.PerformRead(s => !s.Protected));
|
||||
|
||||
AddUntilStep("export button enabled", () => Game.Settings.ChildrenOfType<SkinSection.ExportSkinButton>().SingleOrDefault()?.Enabled.Value == true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
}
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
[Test]
|
||||
public void TestMultipleRulesetsBeatmapSet()
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
}
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
[Test]
|
||||
public void TestLoading()
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
private TestUserListPanel evast;
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesetStore { get; set; }
|
||||
private IRulesetStore rulesetStore { get; set; }
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => Schedule(() =>
|
||||
|
||||
@@ -135,6 +135,35 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60));
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public void TestEarlyActivationEffectPoint(bool earlyActivating)
|
||||
{
|
||||
double earlyActivationMilliseconds = earlyActivating ? 100 : 0;
|
||||
ControlPoint actualEffectPoint = null;
|
||||
|
||||
AddStep($"set early activation to {earlyActivationMilliseconds}", () => beatContainer.EarlyActivationMilliseconds = earlyActivationMilliseconds);
|
||||
|
||||
AddStep("seek before kiai effect point", () =>
|
||||
{
|
||||
ControlPoint expectedEffectPoint = Beatmap.Value.Beatmap.ControlPointInfo.EffectPoints.First(ep => ep.KiaiMode);
|
||||
actualEffectPoint = null;
|
||||
beatContainer.AllowMistimedEventFiring = false;
|
||||
|
||||
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) =>
|
||||
{
|
||||
if (Precision.AlmostEquals(gameplayClockContainer.CurrentTime + earlyActivationMilliseconds, expectedEffectPoint.Time, BeatSyncedContainer.MISTIMED_ALLOWANCE))
|
||||
actualEffectPoint = effectControlPoint;
|
||||
};
|
||||
|
||||
gameplayClockContainer.Seek(expectedEffectPoint.Time - earlyActivationMilliseconds);
|
||||
});
|
||||
|
||||
AddUntilStep("wait for effect point", () => actualEffectPoint != null);
|
||||
|
||||
AddAssert("effect has kiai", () => actualEffectPoint != null && ((EffectControlPoint)actualEffectPoint).KiaiMode);
|
||||
}
|
||||
|
||||
private class TestBeatSyncedContainer : BeatSyncedContainer
|
||||
{
|
||||
private const int flash_layer_height = 150;
|
||||
@@ -145,6 +174,12 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
set => base.AllowMistimedEventFiring = value;
|
||||
}
|
||||
|
||||
public new double EarlyActivationMilliseconds
|
||||
{
|
||||
get => base.EarlyActivationMilliseconds;
|
||||
set => base.EarlyActivationMilliseconds = value;
|
||||
}
|
||||
|
||||
private readonly InfoString timingPointCount;
|
||||
private readonly InfoString currentTimingPoint;
|
||||
private readonly InfoString beatCount;
|
||||
|
||||
@@ -6,12 +6,17 @@ using osu.Framework.Graphics;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Tests.Visual;
|
||||
using osu.Game.Tournament.Components;
|
||||
|
||||
namespace osu.Game.Tournament.Tests.Components
|
||||
{
|
||||
public class TestSceneTournamentBeatmapPanel : TournamentTestScene
|
||||
{
|
||||
/// <remarks>
|
||||
/// Warning: the below API instance is actually the online API, rather than the dummy API provided by the test.
|
||||
/// It cannot be trivially replaced because setting <see cref="OsuTestScene.UseOnlineAPI"/> to <see langword="true"/> causes <see cref="OsuTestScene.API"/> to no longer be usable.
|
||||
/// </remarks>
|
||||
[Resolved]
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace osu.Game.Tournament.Tests.Components
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
private FillFlowContainer<TournamentBeatmapPanel> fillFlow;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace osu.Game.Tournament.Components
|
||||
private readonly string modAcronym;
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
public TournamentModIcon(string modAcronym)
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace osu.Game.Tournament.IPC
|
||||
protected IAPIProvider API { get; private set; }
|
||||
|
||||
[Resolved]
|
||||
protected RulesetStore Rulesets { get; private set; }
|
||||
protected IRulesetStore Rulesets { get; private set; }
|
||||
|
||||
[Resolved]
|
||||
private GameHost host { get; set; }
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private Bindable<RulesetInfo> ruleset { get; set; }
|
||||
@@ -35,7 +35,7 @@ namespace osu.Game.Beatmaps
|
||||
/// </summary>
|
||||
private int? requestedUserId;
|
||||
|
||||
private readonly Dictionary<RulesetInfo, double> recommendedDifficultyMapping = new Dictionary<RulesetInfo, double>();
|
||||
private readonly Dictionary<IRulesetInfo, double> recommendedDifficultyMapping = new Dictionary<IRulesetInfo, double>();
|
||||
|
||||
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
|
||||
|
||||
@@ -101,7 +101,7 @@ namespace osu.Game.Beatmaps
|
||||
/// Rulesets ordered descending by their respective recommended difficulties.
|
||||
/// The currently selected ruleset will always be first.
|
||||
/// </returns>
|
||||
private IEnumerable<RulesetInfo> orderedRulesets
|
||||
private IEnumerable<IRulesetInfo> orderedRulesets
|
||||
{
|
||||
get
|
||||
{
|
||||
|
||||
@@ -86,7 +86,7 @@ namespace osu.Game.Beatmaps.Drawables
|
||||
}
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
|
||||
@@ -17,6 +17,7 @@ using osu.Game.Overlays;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Screens.Select;
|
||||
using osu.Game.Screens.Select.Filter;
|
||||
using osu.Game.Skinning;
|
||||
|
||||
namespace osu.Game.Configuration
|
||||
{
|
||||
@@ -27,7 +28,7 @@ namespace osu.Game.Configuration
|
||||
{
|
||||
// UI/selection defaults
|
||||
SetDefault(OsuSetting.Ruleset, string.Empty);
|
||||
SetDefault(OsuSetting.Skin, 0, -1, int.MaxValue);
|
||||
SetDefault(OsuSetting.Skin, SkinInfo.DEFAULT_SKIN.ToString());
|
||||
|
||||
SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details);
|
||||
SetDefault(OsuSetting.BeatmapDetailModsFilter, false);
|
||||
@@ -210,9 +211,12 @@ namespace osu.Game.Configuration
|
||||
value: scalingMode.GetLocalisableDescription()
|
||||
)
|
||||
),
|
||||
new TrackedSetting<int>(OsuSetting.Skin, skin =>
|
||||
new TrackedSetting<string>(OsuSetting.Skin, skin =>
|
||||
{
|
||||
string skinName = LookupSkinName(skin) ?? string.Empty;
|
||||
string skinName = string.Empty;
|
||||
|
||||
if (Guid.TryParse(skin, out var id))
|
||||
skinName = LookupSkinName(id) ?? string.Empty;
|
||||
|
||||
return new SettingDescription(
|
||||
rawValue: skinName,
|
||||
@@ -233,7 +237,7 @@ namespace osu.Game.Configuration
|
||||
};
|
||||
}
|
||||
|
||||
public Func<int, string> LookupSkinName { private get; set; }
|
||||
public Func<Guid, string> LookupSkinName { private get; set; }
|
||||
|
||||
public Func<GlobalAction, LocalisableString> LookupKeyBindings { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Models;
|
||||
using osu.Game.Skinning;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace osu.Game.Database
|
||||
{
|
||||
internal class EFToRealmMigrator
|
||||
{
|
||||
private readonly DatabaseContextFactory efContextFactory;
|
||||
private readonly RealmContextFactory realmContextFactory;
|
||||
private readonly OsuConfigManager config;
|
||||
|
||||
public EFToRealmMigrator(DatabaseContextFactory efContextFactory, RealmContextFactory realmContextFactory, OsuConfigManager config)
|
||||
{
|
||||
this.efContextFactory = efContextFactory;
|
||||
this.realmContextFactory = realmContextFactory;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
using (var db = efContextFactory.GetForWrite())
|
||||
{
|
||||
migrateSettings(db);
|
||||
migrateSkins(db);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateSkins(DatabaseWriteUsage db)
|
||||
{
|
||||
// can be removed 20220530.
|
||||
var existingSkins = db.Context.SkinInfo
|
||||
.Include(s => s.Files)
|
||||
.ThenInclude(f => f.FileInfo)
|
||||
.ToList();
|
||||
|
||||
// previous entries in EF are removed post migration.
|
||||
if (!existingSkins.Any())
|
||||
return;
|
||||
|
||||
var userSkinChoice = config.GetBindable<string>(OsuSetting.Skin);
|
||||
int.TryParse(userSkinChoice.Value, out int userSkinInt);
|
||||
|
||||
switch (userSkinInt)
|
||||
{
|
||||
case EFSkinInfo.DEFAULT_SKIN:
|
||||
userSkinChoice.Value = SkinInfo.DEFAULT_SKIN.ToString();
|
||||
break;
|
||||
|
||||
case EFSkinInfo.CLASSIC_SKIN:
|
||||
userSkinChoice.Value = SkinInfo.CLASSIC_SKIN.ToString();
|
||||
break;
|
||||
}
|
||||
|
||||
using (var realm = realmContextFactory.CreateContext())
|
||||
using (var transaction = realm.BeginWrite())
|
||||
{
|
||||
// only migrate data if the realm database is empty.
|
||||
// note that this cannot be written as: `realm.All<SkinInfo>().All(s => s.Protected)`, because realm does not support `.All()`.
|
||||
if (!realm.All<SkinInfo>().Any(s => !s.Protected))
|
||||
{
|
||||
foreach (var skin in existingSkins)
|
||||
{
|
||||
var realmSkin = new SkinInfo
|
||||
{
|
||||
Name = skin.Name,
|
||||
Creator = skin.Creator,
|
||||
Hash = skin.Hash,
|
||||
Protected = false,
|
||||
InstantiationInfo = skin.InstantiationInfo,
|
||||
};
|
||||
|
||||
foreach (var file in skin.Files)
|
||||
{
|
||||
var realmFile = realm.Find<RealmFile>(file.FileInfo.Hash);
|
||||
|
||||
if (realmFile == null)
|
||||
realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash });
|
||||
|
||||
realmSkin.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename));
|
||||
}
|
||||
|
||||
realm.Add(realmSkin);
|
||||
|
||||
if (skin.ID == userSkinInt)
|
||||
userSkinChoice.Value = realmSkin.ID.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
db.Context.RemoveRange(existingSkins);
|
||||
// Intentionally don't clean up the files, so they don't get purged by EF.
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateSettings(DatabaseWriteUsage db)
|
||||
{
|
||||
// migrate ruleset settings. can be removed 20220315.
|
||||
var existingSettings = db.Context.DatabasedSetting;
|
||||
|
||||
// previous entries in EF are removed post migration.
|
||||
if (!existingSettings.Any())
|
||||
return;
|
||||
|
||||
using (var realm = realmContextFactory.CreateContext())
|
||||
using (var transaction = realm.BeginWrite())
|
||||
{
|
||||
// only migrate data if the realm database is empty.
|
||||
if (!realm.All<RealmRulesetSetting>().Any())
|
||||
{
|
||||
foreach (var dkb in existingSettings)
|
||||
{
|
||||
if (dkb.RulesetID == null)
|
||||
continue;
|
||||
|
||||
string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value);
|
||||
|
||||
if (string.IsNullOrEmpty(shortName))
|
||||
continue;
|
||||
|
||||
realm.Add(new RealmRulesetSetting
|
||||
{
|
||||
Key = dkb.Key,
|
||||
Value = dkb.StringValue,
|
||||
RulesetName = shortName,
|
||||
Variant = dkb.Variant ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.Context.RemoveRange(existingSettings);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
private string? getRulesetShortNameFromLegacyID(long rulesetId) =>
|
||||
efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ namespace osu.Game.Database
|
||||
void DeleteFile(TModel model, TFileModel file);
|
||||
|
||||
/// <summary>
|
||||
/// Add a new file.
|
||||
/// Add a new file. If the file already exists, it is overwritten.
|
||||
/// </summary>
|
||||
/// <param name="model">The item to operate on.</param>
|
||||
/// <param name="contents">The new file contents.</param>
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace osu.Game.Database
|
||||
public DbSet<BeatmapSetInfo> BeatmapSetInfo { get; set; }
|
||||
public DbSet<FileInfo> FileInfo { get; set; }
|
||||
public DbSet<RulesetInfo> RulesetInfo { get; set; }
|
||||
public DbSet<SkinInfo> SkinInfo { get; set; }
|
||||
public DbSet<EFSkinInfo> SkinInfo { get; set; }
|
||||
public DbSet<ScoreInfo> ScoreInfo { get; set; }
|
||||
|
||||
// migrated to realm
|
||||
@@ -133,8 +133,9 @@ namespace osu.Game.Database
|
||||
modelBuilder.Entity<BeatmapSetInfo>().HasIndex(b => b.DeletePending);
|
||||
modelBuilder.Entity<BeatmapSetInfo>().HasIndex(b => b.Hash).IsUnique();
|
||||
|
||||
modelBuilder.Entity<SkinInfo>().HasIndex(b => b.Hash).IsUnique();
|
||||
modelBuilder.Entity<SkinInfo>().HasIndex(b => b.DeletePending);
|
||||
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.Hash).IsUnique();
|
||||
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.DeletePending);
|
||||
modelBuilder.Entity<EFSkinInfo>().HasMany(s => s.Files).WithOne(f => f.SkinInfo);
|
||||
|
||||
modelBuilder.Entity<DatabasedSetting>().HasIndex(b => new { b.RulesetID, b.Variant });
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ using osu.Framework.Statistics;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Input.Bindings;
|
||||
using osu.Game.Models;
|
||||
using osu.Game.Skinning;
|
||||
using osu.Game.Stores;
|
||||
using Realms;
|
||||
|
||||
@@ -101,10 +102,6 @@ namespace osu.Game.Database
|
||||
|
||||
// This method triggers the first `CreateContext` call, which will implicitly run realm migrations and bring the schema up-to-date.
|
||||
cleanupPendingDeletions();
|
||||
|
||||
// Data migration is handled separately from schema migrations.
|
||||
// This is required as the user may be initialising realm for the first time ever, which would result in no schema migrations running.
|
||||
migrateDataFromEF();
|
||||
}
|
||||
|
||||
private void cleanupPendingDeletions()
|
||||
@@ -122,6 +119,11 @@ namespace osu.Game.Database
|
||||
realm.Remove(s);
|
||||
}
|
||||
|
||||
var pendingDeleteSkins = realm.All<SkinInfo>().Where(s => s.DeletePending);
|
||||
|
||||
foreach (var s in pendingDeleteSkins)
|
||||
realm.Remove(s);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
@@ -193,53 +195,6 @@ namespace osu.Game.Database
|
||||
};
|
||||
}
|
||||
|
||||
private void migrateDataFromEF()
|
||||
{
|
||||
if (efContextFactory == null)
|
||||
return;
|
||||
|
||||
using (var db = efContextFactory.GetForWrite())
|
||||
{
|
||||
// migrate ruleset settings. can be removed 20220315.
|
||||
var existingSettings = db.Context.DatabasedSetting;
|
||||
|
||||
// previous entries in EF are removed post migration.
|
||||
if (!existingSettings.Any())
|
||||
return;
|
||||
|
||||
using (var realm = CreateContext())
|
||||
using (var transaction = realm.BeginWrite())
|
||||
{
|
||||
// only migrate data if the realm database is empty.
|
||||
if (!realm.All<RealmRulesetSetting>().Any())
|
||||
{
|
||||
foreach (var dkb in existingSettings)
|
||||
{
|
||||
if (dkb.RulesetID == null)
|
||||
continue;
|
||||
|
||||
string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value);
|
||||
|
||||
if (string.IsNullOrEmpty(shortName))
|
||||
continue;
|
||||
|
||||
realm.Add(new RealmRulesetSetting
|
||||
{
|
||||
Key = dkb.Key,
|
||||
Value = dkb.StringValue,
|
||||
RulesetName = shortName,
|
||||
Variant = dkb.Variant ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.Context.RemoveRange(existingSettings);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onMigration(Migration migration, ulong lastSchemaVersion)
|
||||
{
|
||||
for (ulong i = lastSchemaVersion + 1; i <= schema_version; i++)
|
||||
|
||||
@@ -103,5 +103,7 @@ namespace osu.Game.Database
|
||||
}
|
||||
|
||||
public bool Equals(ILive<T>? other) => ID == other?.ID;
|
||||
|
||||
public override string ToString() => PerformRead(i => i.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ namespace osu.Game.Graphics.Containers
|
||||
if (clock == null)
|
||||
return;
|
||||
|
||||
double currentTrackTime = clock.CurrentTime;
|
||||
double currentTrackTime = clock.CurrentTime + EarlyActivationMilliseconds;
|
||||
|
||||
if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded)
|
||||
{
|
||||
@@ -132,13 +132,11 @@ namespace osu.Game.Graphics.Containers
|
||||
{
|
||||
// this may be the case where the beat syncing clock has been paused.
|
||||
// we still want to show an idle animation, so use this container's time instead.
|
||||
currentTrackTime = Clock.CurrentTime;
|
||||
currentTrackTime = Clock.CurrentTime + EarlyActivationMilliseconds;
|
||||
timingPoint = TimingControlPoint.DEFAULT;
|
||||
effectPoint = EffectControlPoint.DEFAULT;
|
||||
}
|
||||
|
||||
currentTrackTime += EarlyActivationMilliseconds;
|
||||
|
||||
double beatLength = timingPoint.BeatLength / Divisor;
|
||||
|
||||
while (beatLength < MinimumBeatLength)
|
||||
|
||||
@@ -50,6 +50,8 @@ namespace osu.Game.Models
|
||||
|
||||
public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo;
|
||||
|
||||
public bool Equals(IRulesetInfo? other) => other is RealmRuleset b && Equals(b);
|
||||
|
||||
public override string ToString() => Name;
|
||||
|
||||
public RealmRuleset Clone() => new RealmRuleset
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace osu.Game.Online.API
|
||||
if (WebRequest != null)
|
||||
{
|
||||
Response = ((OsuJsonWebRequest<T>)WebRequest).ResponseObject;
|
||||
Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes");
|
||||
Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes", LoggingTarget.Network);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests
|
||||
public class GetUserRequest : APIRequest<APIUser>
|
||||
{
|
||||
public readonly string Lookup;
|
||||
public readonly RulesetInfo Ruleset;
|
||||
public readonly IRulesetInfo Ruleset;
|
||||
private readonly LookupType lookupType;
|
||||
|
||||
/// <summary>
|
||||
@@ -24,7 +24,7 @@ namespace osu.Game.Online.API.Requests
|
||||
/// </summary>
|
||||
/// <param name="userId">The user to get.</param>
|
||||
/// <param name="ruleset">The ruleset to get the user's info for.</param>
|
||||
public GetUserRequest(long? userId = null, RulesetInfo ruleset = null)
|
||||
public GetUserRequest(long? userId = null, IRulesetInfo ruleset = null)
|
||||
{
|
||||
Lookup = userId.ToString();
|
||||
lookupType = LookupType.Id;
|
||||
@@ -36,7 +36,7 @@ namespace osu.Game.Online.API.Requests
|
||||
/// </summary>
|
||||
/// <param name="username">The user to get.</param>
|
||||
/// <param name="ruleset">The ruleset to get the user's info for.</param>
|
||||
public GetUserRequest(string username = null, RulesetInfo ruleset = null)
|
||||
public GetUserRequest(string username = null, IRulesetInfo ruleset = null)
|
||||
{
|
||||
Lookup = username;
|
||||
lookupType = LookupType.Username;
|
||||
|
||||
@@ -118,7 +118,7 @@ namespace osu.Game.Online.Multiplayer
|
||||
protected IAPIProvider API { get; private set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
protected RulesetStore Rulesets { get; private set; } = null!;
|
||||
protected IRulesetStore Rulesets { get; private set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private UserLookupCache userLookupCache { get; set; } = null!;
|
||||
@@ -733,6 +733,9 @@ namespace osu.Game.Online.Multiplayer
|
||||
var apiBeatmap = await GetAPIBeatmap(item.BeatmapID).ConfigureAwait(false);
|
||||
|
||||
var ruleset = Rulesets.GetRuleset(item.RulesetID);
|
||||
|
||||
Debug.Assert(ruleset != null);
|
||||
|
||||
var rulesetInstance = ruleset.CreateInstance();
|
||||
|
||||
var playlistItem = new PlaylistItem
|
||||
|
||||
@@ -40,8 +40,12 @@ namespace osu.Game.Online.Rooms
|
||||
public bool Expired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The order in which this <see cref="MultiplayerPlaylistItem"/> will be played, starting from 0 and increasing for items which will be played later.
|
||||
/// The order in which this <see cref="MultiplayerPlaylistItem"/> will be played relative to others.
|
||||
/// Playlist items should be played in increasing order (lower values are played first).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is only valid for items which are not <see cref="Expired"/>. The value for expired items is undefined and should not be used.
|
||||
/// </remarks>
|
||||
[Key(8)]
|
||||
public ushort PlaylistOrder { get; set; }
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using osu.Framework.Bindables;
|
||||
@@ -85,11 +86,13 @@ namespace osu.Game.Online.Rooms
|
||||
|
||||
public void MarkInvalid() => valid.Value = false;
|
||||
|
||||
public void MapObjects(RulesetStore rulesets)
|
||||
public void MapObjects(IRulesetStore rulesets)
|
||||
{
|
||||
Beatmap.Value ??= apiBeatmap;
|
||||
Ruleset.Value ??= rulesets.GetRuleset(RulesetID);
|
||||
|
||||
Debug.Assert(Ruleset.Value != null);
|
||||
|
||||
Ruleset rulesetInstance = Ruleset.Value.CreateInstance();
|
||||
|
||||
if (allowedModsBacking != null)
|
||||
|
||||
@@ -136,30 +136,32 @@ namespace osu.Game.Online.Spectator
|
||||
|
||||
public void BeginPlaying(GameplayState state, Score score)
|
||||
{
|
||||
Debug.Assert(ThreadSafety.IsUpdateThread);
|
||||
// This schedule is only here to match the one below in `EndPlaying`.
|
||||
Schedule(() =>
|
||||
{
|
||||
if (IsPlaying)
|
||||
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
|
||||
|
||||
if (IsPlaying)
|
||||
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
|
||||
IsPlaying = true;
|
||||
|
||||
IsPlaying = true;
|
||||
// transfer state at point of beginning play
|
||||
currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID;
|
||||
currentState.RulesetID = score.ScoreInfo.RulesetID;
|
||||
currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray();
|
||||
|
||||
// transfer state at point of beginning play
|
||||
currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID;
|
||||
currentState.RulesetID = score.ScoreInfo.RulesetID;
|
||||
currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray();
|
||||
currentBeatmap = state.Beatmap;
|
||||
currentScore = score;
|
||||
|
||||
currentBeatmap = state.Beatmap;
|
||||
currentScore = score;
|
||||
|
||||
BeginPlayingInternal(currentState);
|
||||
BeginPlayingInternal(currentState);
|
||||
});
|
||||
}
|
||||
|
||||
public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data);
|
||||
|
||||
public void EndPlaying()
|
||||
{
|
||||
// This method is most commonly called via Dispose(), which is asynchronous.
|
||||
// Todo: This should not be a thing, but requires framework changes.
|
||||
// This method is most commonly called via Dispose(), which is can be asynchronous (via the AsyncDisposalQueue).
|
||||
// We probably need to find a better way to handle this...
|
||||
Schedule(() =>
|
||||
{
|
||||
if (!IsPlaying)
|
||||
|
||||
+11
-16
@@ -161,7 +161,7 @@ namespace osu.Game
|
||||
|
||||
private Bindable<float> uiScale;
|
||||
|
||||
private Bindable<int> configSkin;
|
||||
private Bindable<string> configSkin;
|
||||
|
||||
private readonly string[] args;
|
||||
|
||||
@@ -243,27 +243,22 @@ namespace osu.Game
|
||||
Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName;
|
||||
|
||||
// bind config int to database SkinInfo
|
||||
configSkin = LocalConfig.GetBindable<int>(OsuSetting.Skin);
|
||||
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID;
|
||||
configSkin = LocalConfig.GetBindable<string>(OsuSetting.Skin);
|
||||
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString();
|
||||
configSkin.ValueChanged += skinId =>
|
||||
{
|
||||
var skinInfo = SkinManager.Query(s => s.ID == skinId.NewValue);
|
||||
ILive<SkinInfo> skinInfo = null;
|
||||
|
||||
if (Guid.TryParse(skinId.NewValue, out var guid))
|
||||
skinInfo = SkinManager.Query(s => s.ID == guid);
|
||||
|
||||
if (skinInfo == null)
|
||||
{
|
||||
switch (skinId.NewValue)
|
||||
{
|
||||
case -1:
|
||||
skinInfo = DefaultLegacySkin.Info;
|
||||
break;
|
||||
|
||||
default:
|
||||
skinInfo = SkinInfo.Default;
|
||||
break;
|
||||
}
|
||||
if (guid == SkinInfo.CLASSIC_SKIN)
|
||||
skinInfo = DefaultLegacySkin.CreateInfo().ToLive();
|
||||
}
|
||||
|
||||
SkinManager.CurrentSkinInfo.Value = skinInfo;
|
||||
SkinManager.CurrentSkinInfo.Value = skinInfo ?? DefaultSkin.CreateInfo().ToLive();
|
||||
};
|
||||
configSkin.TriggerChange();
|
||||
|
||||
@@ -664,7 +659,7 @@ namespace osu.Game
|
||||
|
||||
// make config aware of how to lookup skins for on-screen display purposes.
|
||||
// if this becomes a more common thing, tracked settings should be reconsidered to allow local DI.
|
||||
LocalConfig.LookupSkinName = id => SkinManager.GetAllUsableSkins().FirstOrDefault(s => s.ID == id)?.ToString() ?? "Unknown";
|
||||
LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown";
|
||||
|
||||
LocalConfig.LookupKeyBindings = l =>
|
||||
{
|
||||
|
||||
@@ -196,9 +196,12 @@ namespace osu.Game
|
||||
runMigrations();
|
||||
|
||||
dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage));
|
||||
dependencies.CacheAs<IRulesetStore>(RulesetStore);
|
||||
|
||||
dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client", contextFactory));
|
||||
|
||||
new EFToRealmMigrator(contextFactory, realmFactory, LocalConfig).Run();
|
||||
|
||||
dependencies.CacheAs(Storage);
|
||||
|
||||
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
|
||||
@@ -212,17 +215,9 @@ namespace osu.Game
|
||||
|
||||
Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY;
|
||||
|
||||
dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio));
|
||||
dependencies.Cache(SkinManager = new SkinManager(Storage, realmFactory, Host, Resources, Audio, Scheduler));
|
||||
dependencies.CacheAs<ISkinSource>(SkinManager);
|
||||
|
||||
// needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo.
|
||||
SkinManager.ItemRemoved += item => Schedule(() =>
|
||||
{
|
||||
// check the removed skin is not the current user choice. if it is, switch back to default.
|
||||
if (item.Equals(SkinManager.CurrentSkinInfo.Value))
|
||||
SkinManager.CurrentSkinInfo.Value = SkinInfo.Default;
|
||||
});
|
||||
|
||||
EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration();
|
||||
|
||||
MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl;
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet
|
||||
}
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
private void onRulesetChanged(ValueChangedEvent<IRulesetInfo> ruleset)
|
||||
{
|
||||
@@ -57,8 +57,13 @@ namespace osu.Game.Overlays.BeatmapSet
|
||||
if (ruleset.NewValue == null)
|
||||
return;
|
||||
|
||||
var rulesetInstance = rulesets.GetRuleset(ruleset.NewValue.OnlineID)?.CreateInstance();
|
||||
|
||||
if (rulesetInstance == null)
|
||||
return;
|
||||
|
||||
modsContainer.Add(new ModButton(new ModNoMod()));
|
||||
modsContainer.AddRange(rulesets.GetRuleset(ruleset.NewValue.OnlineID).CreateInstance().AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m)));
|
||||
modsContainer.AddRange(rulesetInstance.AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m)));
|
||||
|
||||
modsContainer.ForEach(button =>
|
||||
{
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
// 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 osuTK;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Online.API;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Overlays.Profile.Sections
|
||||
{
|
||||
@@ -24,9 +23,6 @@ namespace osu.Game.Overlays.Profile.Sections
|
||||
[Resolved]
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[Resolved]
|
||||
protected RulesetStore Rulesets { get; private set; }
|
||||
|
||||
protected int VisiblePages;
|
||||
protected int ItemsPerPage;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
private readonly APIRecentActivity activity;
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// 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 osu.Framework.Graphics;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osuTK;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Overlays.Rankings.Tables;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Beatmaps.Drawables.Cards;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Overlays.Rankings.Tables;
|
||||
using osu.Game.Rulesets;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Overlays.Rankings
|
||||
{
|
||||
|
||||
@@ -106,7 +106,10 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
|
||||
dialogOverlay?.Push(new MassDeleteConfirmationDialog(() =>
|
||||
{
|
||||
deleteSkinsButton.Enabled.Value = false;
|
||||
Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
|
||||
Task.Run(() =>
|
||||
{
|
||||
skins.Delete();
|
||||
}).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,32 +32,26 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
Icon = FontAwesome.Solid.PaintBrush
|
||||
};
|
||||
|
||||
private readonly Bindable<SkinInfo> dropdownBindable = new Bindable<SkinInfo> { Default = SkinInfo.Default };
|
||||
private readonly Bindable<int> configBindable = new Bindable<int>();
|
||||
private readonly Bindable<ILive<SkinInfo>> dropdownBindable = new Bindable<ILive<SkinInfo>> { Default = DefaultSkin.CreateInfo().ToLive() };
|
||||
private readonly Bindable<string> configBindable = new Bindable<string>();
|
||||
|
||||
private static readonly SkinInfo random_skin_info = new SkinInfo
|
||||
private static readonly ILive<SkinInfo> random_skin_info = new SkinInfo
|
||||
{
|
||||
ID = SkinInfo.RANDOM_SKIN,
|
||||
Name = "<Random Skin>",
|
||||
};
|
||||
}.ToLive();
|
||||
|
||||
private List<SkinInfo> skinItems;
|
||||
|
||||
private int firstNonDefaultSkinIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
int index = skinItems.FindIndex(s => s.ID > 0);
|
||||
if (index < 0)
|
||||
index = skinItems.Count;
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
private List<ILive<SkinInfo>> skinItems;
|
||||
|
||||
[Resolved]
|
||||
private SkinManager skins { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private RealmContextFactory realmFactory { get; set; }
|
||||
|
||||
private IDisposable realmSubscription;
|
||||
private IQueryable<SkinInfo> realmSkins;
|
||||
|
||||
[BackgroundDependencyLoader(permitNulls: true)]
|
||||
private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor)
|
||||
{
|
||||
@@ -75,96 +69,95 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
new ExportSkinButton(),
|
||||
};
|
||||
|
||||
skins.ItemUpdated += itemUpdated;
|
||||
skins.ItemRemoved += itemRemoved;
|
||||
|
||||
config.BindWith(OsuSetting.Skin, configBindable);
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
skinDropdown.Current = dropdownBindable;
|
||||
|
||||
realmSkins = realmFactory.Context.All<SkinInfo>()
|
||||
.Where(s => !s.DeletePending)
|
||||
.OrderByDescending(s => s.Protected) // protected skins should be at the top.
|
||||
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
realmSubscription = realmSkins
|
||||
.QueryAsyncWithNotifications((sender, changes, error) =>
|
||||
{
|
||||
if (changes == null)
|
||||
return;
|
||||
|
||||
// Eventually this should be handling the individual changes rather than refreshing the whole dropdown.
|
||||
updateItems();
|
||||
});
|
||||
|
||||
updateItems();
|
||||
|
||||
// Todo: This should not be necessary when OsuConfigManager is databased
|
||||
if (skinDropdown.Items.All(s => s.ID != configBindable.Value))
|
||||
configBindable.Value = 0;
|
||||
configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig));
|
||||
updateSelectedSkinFromConfig();
|
||||
|
||||
configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig), true);
|
||||
dropdownBindable.BindValueChanged(skin =>
|
||||
{
|
||||
if (skin.NewValue == random_skin_info)
|
||||
if (skin.NewValue.Equals(random_skin_info))
|
||||
{
|
||||
var skinBefore = skins.CurrentSkinInfo.Value;
|
||||
|
||||
skins.SelectRandomSkin();
|
||||
|
||||
if (skinBefore == skins.CurrentSkinInfo.Value)
|
||||
{
|
||||
// the random selection didn't change the skin, so we should manually update the dropdown to match.
|
||||
dropdownBindable.Value = skins.CurrentSkinInfo.Value;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
configBindable.Value = skin.NewValue.ID;
|
||||
configBindable.Value = skin.NewValue.ID.ToString();
|
||||
});
|
||||
}
|
||||
|
||||
private void updateSelectedSkinFromConfig()
|
||||
{
|
||||
int id = configBindable.Value;
|
||||
ILive<SkinInfo> skin = null;
|
||||
|
||||
var skin = skinDropdown.Items.FirstOrDefault(s => s.ID == id);
|
||||
if (Guid.TryParse(configBindable.Value, out var configId))
|
||||
skin = skinDropdown.Items.FirstOrDefault(s => s.ID == configId);
|
||||
|
||||
if (skin == null)
|
||||
{
|
||||
// there may be a thread race condition where an item is selected that hasn't yet been added to the dropdown.
|
||||
// to avoid adding complexity, let's just ensure the item is added so we can perform the selection.
|
||||
skin = skins.Query(s => s.ID == id);
|
||||
addItem(skin);
|
||||
}
|
||||
|
||||
dropdownBindable.Value = skin;
|
||||
dropdownBindable.Value = skin ?? skinDropdown.Items.First();
|
||||
}
|
||||
|
||||
private void updateItems()
|
||||
{
|
||||
skinItems = skins.GetAllUsableSkins();
|
||||
skinItems.Insert(firstNonDefaultSkinIndex, random_skin_info);
|
||||
sortUserSkins(skinItems);
|
||||
int protectedCount = realmSkins.Count(s => s.Protected);
|
||||
|
||||
skinItems = realmSkins.ToLive();
|
||||
|
||||
skinItems.Insert(protectedCount, random_skin_info);
|
||||
|
||||
skinDropdown.Items = skinItems;
|
||||
}
|
||||
|
||||
private void itemUpdated(SkinInfo item) => Schedule(() => addItem(item));
|
||||
|
||||
private void addItem(SkinInfo item)
|
||||
{
|
||||
List<SkinInfo> newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList();
|
||||
sortUserSkins(newDropdownItems);
|
||||
skinDropdown.Items = newDropdownItems;
|
||||
}
|
||||
|
||||
private void itemRemoved(SkinInfo item) => Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => !i.Equals(item)).ToArray());
|
||||
|
||||
private void sortUserSkins(List<SkinInfo> skinsList)
|
||||
{
|
||||
// Sort user skins separately from built-in skins
|
||||
skinsList.Sort(firstNonDefaultSkinIndex, skinsList.Count - firstNonDefaultSkinIndex,
|
||||
Comparer<SkinInfo>.Create((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
|
||||
if (skins != null)
|
||||
{
|
||||
skins.ItemUpdated -= itemUpdated;
|
||||
skins.ItemRemoved -= itemRemoved;
|
||||
}
|
||||
realmSubscription?.Dispose();
|
||||
}
|
||||
|
||||
private class SkinSettingsDropdown : SettingsDropdown<SkinInfo>
|
||||
private class SkinSettingsDropdown : SettingsDropdown<ILive<SkinInfo>>
|
||||
{
|
||||
protected override OsuDropdown<SkinInfo> CreateDropdown() => new SkinDropdownControl();
|
||||
protected override OsuDropdown<ILive<SkinInfo>> CreateDropdown() => new SkinDropdownControl();
|
||||
|
||||
private class SkinDropdownControl : DropdownControl
|
||||
{
|
||||
protected override LocalisableString GenerateItemText(SkinInfo item) => item.ToString();
|
||||
protected override LocalisableString GenerateItemText(ILive<SkinInfo> item) => item.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private class ExportSkinButton : SettingsButton
|
||||
public class ExportSkinButton : SettingsButton
|
||||
{
|
||||
[Resolved]
|
||||
private SkinManager skins { get; set; }
|
||||
@@ -179,16 +172,21 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
{
|
||||
Text = SkinSettingsStrings.ExportSkinButton;
|
||||
Action = export;
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
currentSkin = skins.CurrentSkin.GetBoundCopy();
|
||||
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.ID > 0, true);
|
||||
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
|
||||
}
|
||||
|
||||
private void export()
|
||||
{
|
||||
try
|
||||
{
|
||||
new LegacySkinExporter(storage).Export(currentSkin.Value.SkinInfo);
|
||||
currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@@ -120,14 +120,14 @@ namespace osu.Game.Rulesets.Difficulty
|
||||
/// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
|
||||
/// </summary>
|
||||
/// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
|
||||
public IEnumerable<DifficultyAttributes> CalculateAll()
|
||||
public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var combination in CreateDifficultyAdjustmentModCombinations())
|
||||
{
|
||||
if (combination is MultiMod multi)
|
||||
yield return Calculate(multi.Mods);
|
||||
yield return Calculate(multi.Mods, cancellationToken);
|
||||
else
|
||||
yield return Calculate(combination.Yield());
|
||||
yield return Calculate(combination.Yield(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,11 @@ namespace osu.Game.Rulesets.Difficulty
|
||||
{
|
||||
playableMods = mods.Select(m => m.DeepClone()).ToArray();
|
||||
|
||||
Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken);
|
||||
// Only pass through the cancellation token if it's non-default.
|
||||
// This allows for the default timeout to be applied for playable beatmap construction.
|
||||
Beatmap = cancellationToken == default
|
||||
? beatmap.GetPlayableBeatmap(ruleset, playableMods)
|
||||
: beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken);
|
||||
|
||||
var track = new TrackVirtual(10000);
|
||||
playableMods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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 osu.Game.Database;
|
||||
|
||||
#nullable enable
|
||||
@@ -10,7 +11,7 @@ namespace osu.Game.Rulesets
|
||||
/// <summary>
|
||||
/// A representation of a ruleset's metadata.
|
||||
/// </summary>
|
||||
public interface IRulesetInfo : IHasOnlineID<int>
|
||||
public interface IRulesetInfo : IHasOnlineID<int>, IEquatable<IRulesetInfo>
|
||||
{
|
||||
/// <summary>
|
||||
/// The user-exposed name of this ruleset.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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.Collections.Generic;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace osu.Game.Rulesets
|
||||
{
|
||||
public interface IRulesetStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve a ruleset using a known ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ruleset's internal ID.</param>
|
||||
/// <returns>A ruleset, if available, else null.</returns>
|
||||
IRulesetInfo? GetRuleset(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a ruleset using a known short name.
|
||||
/// </summary>
|
||||
/// <param name="shortName">The ruleset's short name.</param>
|
||||
/// <returns>A ruleset, if available, else null.</returns>
|
||||
IRulesetInfo? GetRuleset(string shortName);
|
||||
|
||||
/// <summary>
|
||||
/// All available rulesets.
|
||||
/// </summary>
|
||||
IEnumerable<IRulesetInfo> AvailableRulesets { get; }
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ namespace osu.Game.Rulesets
|
||||
|
||||
public override bool Equals(object obj) => obj is RulesetInfo rulesetInfo && Equals(rulesetInfo);
|
||||
|
||||
public bool Equals(IRulesetInfo other) => other is RulesetInfo b && Equals(b);
|
||||
|
||||
[SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")]
|
||||
public override int GetHashCode()
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ using osu.Game.Database;
|
||||
|
||||
namespace osu.Game.Rulesets
|
||||
{
|
||||
public class RulesetStore : DatabaseBackedStore, IDisposable
|
||||
public class RulesetStore : DatabaseBackedStore, IRulesetStore, IDisposable
|
||||
{
|
||||
private const string ruleset_library_prefix = "osu.Game.Rulesets";
|
||||
|
||||
@@ -236,5 +236,13 @@ namespace osu.Game.Rulesets
|
||||
{
|
||||
AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly;
|
||||
}
|
||||
|
||||
#region Implementation of IRulesetStore
|
||||
|
||||
IRulesetInfo IRulesetStore.GetRuleset(int id) => GetRuleset(id);
|
||||
IRulesetInfo IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName);
|
||||
IEnumerable<IRulesetInfo> IRulesetStore.AvailableRulesets => AvailableRulesets;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace osu.Game.Scoring.Legacy
|
||||
sw.Write(LATEST_VERSION);
|
||||
sw.Write(score.ScoreInfo.BeatmapInfo.MD5Hash);
|
||||
sw.Write(score.ScoreInfo.UserString);
|
||||
sw.Write($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}".ComputeMD5Hash());
|
||||
sw.Write(FormattableString.Invariant($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}").ComputeMD5Hash());
|
||||
sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0));
|
||||
sw.Write((ushort)(score.ScoreInfo.GetCount100() ?? 0));
|
||||
sw.Write((ushort)(score.ScoreInfo.GetCount50() ?? 0));
|
||||
@@ -110,7 +110,9 @@ namespace osu.Game.Scoring.Legacy
|
||||
}
|
||||
}
|
||||
|
||||
replayData.AppendFormat(@"{0}|{1}|{2}|{3},", -12345, 0, 0, 0);
|
||||
// Warning: this is purposefully hardcoded as a string rather than interpolating, as in some cultures the minus sign is not encoded as the standard ASCII U+00C2 codepoint,
|
||||
// which then would break decoding.
|
||||
replayData.Append(@"-12345|0|0|0");
|
||||
return replayData.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
|
||||
private readonly Bindable<Room> joinedRoom = new Bindable<Room>();
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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.Collections.Generic;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.Color4Extensions;
|
||||
@@ -184,20 +185,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Direction = FillDirection.Horizontal,
|
||||
Spacing = new Vector2(5),
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new PlaylistCountPill
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
},
|
||||
new StarRatingRangeDisplay
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Scale = new Vector2(0.8f)
|
||||
}
|
||||
}
|
||||
ChildrenEnumerable = CreateBottomDetails()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -287,6 +275,37 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
|
||||
|
||||
protected virtual Drawable CreateBackground() => new OnlinePlayBackgroundSprite();
|
||||
|
||||
protected virtual IEnumerable<Drawable> CreateBottomDetails()
|
||||
{
|
||||
var pills = new List<Drawable>();
|
||||
|
||||
if (Room.Type.Value != MatchType.Playlists)
|
||||
{
|
||||
pills.AddRange(new OnlinePlayComposite[]
|
||||
{
|
||||
new MatchTypePill(),
|
||||
new QueueModePill(),
|
||||
});
|
||||
}
|
||||
|
||||
pills.AddRange(new Drawable[]
|
||||
{
|
||||
new PlaylistCountPill
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
},
|
||||
new StarRatingRangeDisplay
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Scale = new Vector2(0.8f)
|
||||
}
|
||||
});
|
||||
|
||||
return pills;
|
||||
}
|
||||
|
||||
private class RoomNameText : OsuSpriteText
|
||||
{
|
||||
[Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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 osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Online.Rooms;
|
||||
|
||||
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
|
||||
{
|
||||
public class MatchTypePill : OnlinePlayComposite
|
||||
{
|
||||
private OsuTextFlowContainer textFlow;
|
||||
|
||||
public MatchTypePill()
|
||||
{
|
||||
AutoSizeAxes = Axes.Both;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
InternalChild = new PillContainer
|
||||
{
|
||||
Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
AutoSizeAxes = Axes.Both,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
Type.BindValueChanged(onMatchTypeChanged, true);
|
||||
}
|
||||
|
||||
private void onMatchTypeChanged(ValueChangedEvent<MatchType> type)
|
||||
{
|
||||
textFlow.Clear();
|
||||
textFlow.AddText(type.NewValue.GetLocalisableDescription());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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 osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Online.Multiplayer;
|
||||
|
||||
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
|
||||
{
|
||||
public class QueueModePill : OnlinePlayComposite
|
||||
{
|
||||
private OsuTextFlowContainer textFlow;
|
||||
|
||||
public QueueModePill()
|
||||
{
|
||||
AutoSizeAxes = Axes.Both;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
InternalChild = new PillContainer
|
||||
{
|
||||
Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
AutoSizeAxes = Axes.Both,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
QueueMode.BindValueChanged(onQueueModeChanged, true);
|
||||
}
|
||||
|
||||
private void onQueueModeChanged(ValueChangedEvent<QueueMode> mode)
|
||||
{
|
||||
textFlow.Clear();
|
||||
textFlow.AddText(mode.NewValue.GetLocalisableDescription());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,16 @@ namespace osu.Game.Screens.OnlinePlay.Match
|
||||
|
||||
protected void StartPlay()
|
||||
{
|
||||
// User may be at song select or otherwise when the host starts gameplay.
|
||||
// Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state.
|
||||
if (!this.IsCurrentScreen())
|
||||
{
|
||||
this.MakeCurrent();
|
||||
|
||||
Schedule(StartPlay);
|
||||
return;
|
||||
}
|
||||
|
||||
sampleStart?.Play();
|
||||
|
||||
// fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes).
|
||||
|
||||
@@ -138,11 +138,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
Height = 40,
|
||||
Action = () =>
|
||||
{
|
||||
if (this.IsCurrentScreen())
|
||||
this.Push(new MultiplayerMatchSongSelect(Room));
|
||||
},
|
||||
Action = SelectBeatmap,
|
||||
Alpha = 0
|
||||
},
|
||||
},
|
||||
@@ -224,6 +220,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
}
|
||||
};
|
||||
|
||||
internal void SelectBeatmap()
|
||||
{
|
||||
if (!this.IsCurrentScreen())
|
||||
return;
|
||||
|
||||
this.Push(new MultiplayerMatchSongSelect(Room));
|
||||
}
|
||||
|
||||
protected override Drawable CreateFooter() => new MultiplayerMatchFooter
|
||||
{
|
||||
OnReadyClick = onReadyClick,
|
||||
|
||||
@@ -32,9 +32,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
}
|
||||
|
||||
private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated);
|
||||
private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.AddOnce(UserJoined, user);
|
||||
private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(UserKicked, user);
|
||||
private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.AddOnce(UserLeft, user);
|
||||
private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => UserJoined(user));
|
||||
private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.Add(() => UserKicked(user));
|
||||
private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => UserLeft(user));
|
||||
private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item));
|
||||
private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item));
|
||||
private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item));
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private RulesetStore rulesets { get; set; }
|
||||
private IRulesetStore rulesets { get; set; }
|
||||
|
||||
private SpriteIcon crown;
|
||||
|
||||
@@ -185,9 +185,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
|
||||
const double fade_time = 50;
|
||||
|
||||
// Todo: Should use the room's selected item to determine ruleset.
|
||||
var ruleset = rulesets.GetRuleset(0).CreateInstance();
|
||||
var ruleset = rulesets.GetRuleset(0)?.CreateInstance();
|
||||
|
||||
int? currentModeRank = User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank;
|
||||
int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null;
|
||||
userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty;
|
||||
|
||||
userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability);
|
||||
|
||||
@@ -77,7 +77,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
|
||||
else
|
||||
{
|
||||
// Remove panels for users no longer in the room.
|
||||
panels.RemoveAll(p => !Room.Users.Contains(p.User));
|
||||
foreach (var p in panels)
|
||||
{
|
||||
// Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run.
|
||||
if (Room.Users.All(u => !ReferenceEquals(p.User, u)))
|
||||
p.Expire();
|
||||
}
|
||||
|
||||
// Add panels for all users new to the room.
|
||||
foreach (var user in Room.Users.Except(panels.Select(p => p.User)))
|
||||
|
||||
@@ -27,7 +27,6 @@ using osu.Game.Screens.OnlinePlay.Match.Components;
|
||||
using osu.Game.Screens.Spectate;
|
||||
using osu.Game.Users;
|
||||
using osuTK;
|
||||
using APIUser = osu.Game.Online.API.Requests.Responses.APIUser;
|
||||
|
||||
namespace osu.Game.Screens.Play
|
||||
{
|
||||
|
||||
@@ -12,8 +12,17 @@ namespace osu.Game.Skinning
|
||||
{
|
||||
public class DefaultLegacySkin : LegacySkin
|
||||
{
|
||||
public static SkinInfo CreateInfo() => new SkinInfo
|
||||
{
|
||||
ID = Skinning.SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon.
|
||||
Name = "osu!classic",
|
||||
Creator = "team osu!",
|
||||
Protected = true,
|
||||
InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo()
|
||||
};
|
||||
|
||||
public DefaultLegacySkin(IStorageResourceProvider resources)
|
||||
: this(Info, resources)
|
||||
: this(CreateInfo(), resources)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -25,7 +34,7 @@ namespace osu.Game.Skinning
|
||||
resources,
|
||||
// A default legacy skin may still have a skin.ini if it is modified by the user.
|
||||
// We must specify the stream directly as we are redirecting storage to the osu-resources location for other files.
|
||||
new LegacySkinResourceStore<SkinFileInfo>(skin, resources.Files).GetStream("skin.ini")
|
||||
new LegacyDatabasedSkinResourceStore(skin, resources.Files).GetStream("skin.ini")
|
||||
)
|
||||
{
|
||||
Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255);
|
||||
@@ -39,13 +48,5 @@ namespace osu.Game.Skinning
|
||||
|
||||
Configuration.LegacyVersion = 2.7m;
|
||||
}
|
||||
|
||||
public static SkinInfo Info { get; } = new SkinInfo
|
||||
{
|
||||
ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon.
|
||||
Name = "osu!classic",
|
||||
Creator = "team osu!",
|
||||
InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,19 @@ namespace osu.Game.Skinning
|
||||
{
|
||||
public class DefaultSkin : Skin
|
||||
{
|
||||
public static SkinInfo CreateInfo() => new SkinInfo
|
||||
{
|
||||
ID = osu.Game.Skinning.SkinInfo.DEFAULT_SKIN,
|
||||
Name = "osu! (triangles)",
|
||||
Creator = "team osu!",
|
||||
Protected = true,
|
||||
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
|
||||
};
|
||||
|
||||
private readonly IStorageResourceProvider resources;
|
||||
|
||||
public DefaultSkin(IStorageResourceProvider resources)
|
||||
: this(SkinInfo.Default, resources)
|
||||
: this(CreateInfo(), resources)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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.ComponentModel.DataAnnotations.Schema;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.IO;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
[Table(@"SkinInfo")]
|
||||
public class EFSkinInfo : IHasFiles<SkinFileInfo>, IEquatable<EFSkinInfo>, IHasPrimaryKey, ISoftDelete
|
||||
{
|
||||
internal const int DEFAULT_SKIN = 0;
|
||||
internal const int CLASSIC_SKIN = -1;
|
||||
internal const int RANDOM_SKIN = -2;
|
||||
|
||||
public int ID { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Creator { get; set; } = string.Empty;
|
||||
|
||||
public string Hash { get; set; }
|
||||
|
||||
public string InstantiationInfo { get; set; }
|
||||
|
||||
public virtual Skin CreateInstance(IStorageResourceProvider resources)
|
||||
{
|
||||
var type = string.IsNullOrEmpty(InstantiationInfo)
|
||||
// handle the case of skins imported before InstantiationInfo was added.
|
||||
? typeof(LegacySkin)
|
||||
: Type.GetType(InstantiationInfo).AsNonNull();
|
||||
|
||||
return (Skin)Activator.CreateInstance(type, this, resources);
|
||||
}
|
||||
|
||||
public List<SkinFileInfo> Files { get; set; } = new List<SkinFileInfo>();
|
||||
|
||||
public bool DeletePending { get; set; }
|
||||
|
||||
public static EFSkinInfo Default { get; } = new EFSkinInfo
|
||||
{
|
||||
ID = DEFAULT_SKIN,
|
||||
Name = "osu! (triangles)",
|
||||
Creator = "team osu!",
|
||||
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
|
||||
};
|
||||
|
||||
public bool Equals(EFSkinInfo other) => other != null && ID == other.ID;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
string author = Creator == null ? string.Empty : $"({Creator})";
|
||||
return $"{Name} {author}".Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ namespace osu.Game.Skinning
|
||||
protected override bool UseCustomSampleBanks => true;
|
||||
|
||||
public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, IResourceStore<byte[]> storage, IStorageResourceProvider resources)
|
||||
: base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore<BeatmapSetFileInfo>(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path)
|
||||
: base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path)
|
||||
{
|
||||
// Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer)
|
||||
Configuration.AllowDefaultComboColoursFallback = false;
|
||||
@@ -77,6 +77,6 @@ namespace osu.Game.Skinning
|
||||
}
|
||||
|
||||
private static SkinInfo createSkinInfo(BeatmapInfo beatmapInfo) =>
|
||||
new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username };
|
||||
new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username ?? string.Empty };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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.Collections.Generic;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Framework.IO.Stores;
|
||||
using osu.Game.Extensions;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
public class LegacyDatabasedSkinResourceStore : ResourceStore<byte[]>
|
||||
{
|
||||
private readonly Dictionary<string, string> fileToStoragePathMapping = new Dictionary<string, string>();
|
||||
|
||||
public LegacyDatabasedSkinResourceStore(SkinInfo source, IResourceStore<byte[]> underlyingStore)
|
||||
: base(underlyingStore)
|
||||
{
|
||||
initialiseFileCache(source);
|
||||
}
|
||||
|
||||
private void initialiseFileCache(SkinInfo source)
|
||||
{
|
||||
fileToStoragePathMapping.Clear();
|
||||
foreach (var f in source.Files)
|
||||
fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
|
||||
}
|
||||
|
||||
protected override IEnumerable<string> GetFilenames(string name)
|
||||
{
|
||||
foreach (string filename in base.GetFilenames(name))
|
||||
{
|
||||
string path = getPathForFile(filename.ToStandardisedPath());
|
||||
if (path != null)
|
||||
yield return path;
|
||||
}
|
||||
}
|
||||
|
||||
private string getPathForFile(string filename) =>
|
||||
fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null;
|
||||
|
||||
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Keys;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ namespace osu.Game.Skinning
|
||||
|
||||
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
|
||||
public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
|
||||
: this(skin, new LegacySkinResourceStore<SkinFileInfo>(skin, resources.Files), resources, "skin.ini")
|
||||
: this(skin, new LegacyDatabasedSkinResourceStore(skin, resources.Files), resources, "skin.ini")
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,11 @@ using osu.Game.Extensions;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
public class LegacySkinResourceStore<T> : ResourceStore<byte[]>
|
||||
where T : INamedFileInfo
|
||||
public class LegacySkinResourceStore : ResourceStore<byte[]>
|
||||
{
|
||||
private readonly IHasFiles<T> source;
|
||||
private readonly IHasNamedFiles source;
|
||||
|
||||
public LegacySkinResourceStore(IHasFiles<T> source, IResourceStore<byte[]> underlyingStore)
|
||||
public LegacySkinResourceStore(IHasNamedFiles source, IResourceStore<byte[]> underlyingStore)
|
||||
: base(underlyingStore)
|
||||
{
|
||||
this.source = source;
|
||||
@@ -33,7 +32,7 @@ namespace osu.Game.Skinning
|
||||
}
|
||||
|
||||
private string getPathForFile(string filename) =>
|
||||
source.Files.Find(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath();
|
||||
source.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
|
||||
|
||||
public override IEnumerable<string> GetAvailableResources() => source.Files.Select(f => f.Filename);
|
||||
}
|
||||
|
||||
+33
-28
@@ -15,6 +15,7 @@ using osu.Framework.Graphics.OpenGL.Textures;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.Screens.Play.HUD;
|
||||
@@ -23,7 +24,7 @@ namespace osu.Game.Skinning
|
||||
{
|
||||
public abstract class Skin : IDisposable, ISkin
|
||||
{
|
||||
public readonly SkinInfo SkinInfo;
|
||||
public readonly ILive<SkinInfo> SkinInfo;
|
||||
private readonly IStorageResourceProvider resources;
|
||||
|
||||
public SkinConfiguration Configuration { get; set; }
|
||||
@@ -42,7 +43,7 @@ namespace osu.Game.Skinning
|
||||
|
||||
protected Skin(SkinInfo skin, IStorageResourceProvider resources, [CanBeNull] Stream configurationStream = null)
|
||||
{
|
||||
SkinInfo = skin;
|
||||
SkinInfo = skin.ToLive();
|
||||
this.resources = resources;
|
||||
|
||||
configurationStream ??= getConfigurationStream();
|
||||
@@ -53,37 +54,41 @@ namespace osu.Game.Skinning
|
||||
else
|
||||
Configuration = new SkinConfiguration();
|
||||
|
||||
// we may want to move this to some kind of async operation in the future.
|
||||
foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
|
||||
// skininfo files may be null for default skin.
|
||||
SkinInfo.PerformRead(s =>
|
||||
{
|
||||
string filename = $"{skinnableTarget}.json";
|
||||
|
||||
// skininfo files may be null for default skin.
|
||||
var fileInfo = SkinInfo.Files.FirstOrDefault(f => f.Filename == filename);
|
||||
|
||||
if (fileInfo == null)
|
||||
continue;
|
||||
|
||||
byte[] bytes = resources?.Files.Get(fileInfo.FileInfo.GetStoragePath());
|
||||
|
||||
if (bytes == null)
|
||||
continue;
|
||||
|
||||
try
|
||||
// we may want to move this to some kind of async operation in the future.
|
||||
foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
|
||||
{
|
||||
string jsonContent = Encoding.UTF8.GetString(bytes);
|
||||
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
|
||||
string filename = $"{skinnableTarget}.json";
|
||||
|
||||
if (deserializedContent == null)
|
||||
// skininfo files may be null for default skin.
|
||||
var fileInfo = s.Files.FirstOrDefault(f => f.Filename == filename);
|
||||
|
||||
if (fileInfo == null)
|
||||
continue;
|
||||
|
||||
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
|
||||
byte[] bytes = resources?.Files.Get(fileInfo.File.GetStoragePath());
|
||||
|
||||
if (bytes == null)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
string jsonContent = Encoding.UTF8.GetString(bytes);
|
||||
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
|
||||
|
||||
if (deserializedContent == null)
|
||||
continue;
|
||||
|
||||
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to load skin configuration.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to load skin configuration.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected virtual void ParseConfigurationStream(Stream stream)
|
||||
@@ -94,7 +99,7 @@ namespace osu.Game.Skinning
|
||||
|
||||
private Stream getConfigurationStream()
|
||||
{
|
||||
string path = SkinInfo.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath();
|
||||
string path = SkinInfo.PerformRead(s => s.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath());
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return null;
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace osu.Game.Skinning
|
||||
|
||||
public int SkinInfoID { get; set; }
|
||||
|
||||
public EFSkinInfo SkinInfo { get; set; }
|
||||
|
||||
public int FileInfoID { get; set; }
|
||||
|
||||
public FileInfo FileInfo { get; set; }
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
// 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.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.Models;
|
||||
using Realms;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
public class SkinInfo : IHasFiles<SkinFileInfo>, IEquatable<SkinInfo>, IHasPrimaryKey, ISoftDelete, IHasNamedFiles
|
||||
[ExcludeFromDynamicCompile]
|
||||
[MapTo("Skin")]
|
||||
[JsonObject(MemberSerialization.OptIn)]
|
||||
public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable<SkinInfo>, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles
|
||||
{
|
||||
internal const int DEFAULT_SKIN = 0;
|
||||
internal const int CLASSIC_SKIN = -1;
|
||||
internal const int RANDOM_SKIN = -2;
|
||||
internal static readonly Guid DEFAULT_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD");
|
||||
internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187");
|
||||
internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908");
|
||||
|
||||
public int ID { get; set; }
|
||||
[PrimaryKey]
|
||||
[JsonProperty]
|
||||
public Guid ID { get; set; } = Guid.NewGuid();
|
||||
|
||||
[JsonProperty]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty]
|
||||
public string Creator { get; set; } = string.Empty;
|
||||
|
||||
public string Hash { get; set; }
|
||||
[JsonProperty]
|
||||
public string InstantiationInfo { get; set; } = string.Empty;
|
||||
|
||||
public string InstantiationInfo { get; set; }
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
public bool Protected { get; set; }
|
||||
|
||||
public virtual Skin CreateInstance(IStorageResourceProvider resources)
|
||||
{
|
||||
@@ -36,23 +51,21 @@ namespace osu.Game.Skinning
|
||||
return (Skin)Activator.CreateInstance(type, this, resources);
|
||||
}
|
||||
|
||||
public List<SkinFileInfo> Files { get; } = new List<SkinFileInfo>();
|
||||
public IList<RealmNamedFileUsage> Files { get; } = null!;
|
||||
|
||||
public bool DeletePending { get; set; }
|
||||
|
||||
public static SkinInfo Default { get; } = new SkinInfo
|
||||
public bool Equals(SkinInfo? other)
|
||||
{
|
||||
ID = DEFAULT_SKIN,
|
||||
Name = "osu! (triangles)",
|
||||
Creator = "team osu!",
|
||||
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
|
||||
};
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
if (other == null) return false;
|
||||
|
||||
public bool Equals(SkinInfo other) => other != null && ID == other.ID;
|
||||
return ID == other.ID;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
string author = Creator == null ? string.Empty : $"({Creator})";
|
||||
string author = string.IsNullOrEmpty(Creator) ? string.Empty : $"({Creator})";
|
||||
return $"{Name} {author}".Trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using JetBrains.Annotations;
|
||||
using osu.Framework.Audio;
|
||||
using osu.Framework.Audio.Sample;
|
||||
using osu.Framework.Bindables;
|
||||
@@ -20,6 +17,7 @@ using osu.Framework.Graphics.Textures;
|
||||
using osu.Framework.IO.Stores;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Threading;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Database;
|
||||
@@ -37,20 +35,25 @@ namespace osu.Game.Skinning
|
||||
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
|
||||
/// </remarks>
|
||||
[ExcludeFromDynamicCompile]
|
||||
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>, IModelManager<SkinInfo>
|
||||
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
|
||||
{
|
||||
private readonly AudioManager audio;
|
||||
|
||||
private readonly Scheduler scheduler;
|
||||
|
||||
private readonly GameHost host;
|
||||
|
||||
private readonly IResourceStore<byte[]> resources;
|
||||
|
||||
public readonly Bindable<Skin> CurrentSkin = new Bindable<Skin>();
|
||||
public readonly Bindable<SkinInfo> CurrentSkinInfo = new Bindable<SkinInfo>(SkinInfo.Default) { Default = SkinInfo.Default };
|
||||
|
||||
public readonly Bindable<ILive<SkinInfo>> CurrentSkinInfo = new Bindable<ILive<SkinInfo>>(Skinning.DefaultSkin.CreateInfo().ToLive())
|
||||
{
|
||||
Default = Skinning.DefaultSkin.CreateInfo().ToLive()
|
||||
};
|
||||
|
||||
private readonly SkinModelManager skinModelManager;
|
||||
|
||||
private readonly SkinStore skinStore;
|
||||
private readonly RealmContextFactory contextFactory;
|
||||
|
||||
private readonly IResourceStore<byte[]> userFiles;
|
||||
|
||||
@@ -64,69 +67,66 @@ namespace osu.Game.Skinning
|
||||
/// </summary>
|
||||
public Skin DefaultLegacySkin { get; }
|
||||
|
||||
public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore<byte[]> resources, AudioManager audio)
|
||||
public SkinManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IResourceStore<byte[]> resources, AudioManager audio, Scheduler scheduler)
|
||||
{
|
||||
this.contextFactory = contextFactory;
|
||||
this.audio = audio;
|
||||
this.scheduler = scheduler;
|
||||
this.host = host;
|
||||
this.resources = resources;
|
||||
|
||||
skinStore = new SkinStore(contextFactory, storage);
|
||||
userFiles = new FileStore(contextFactory, storage).Store;
|
||||
userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files"));
|
||||
|
||||
skinModelManager = new SkinModelManager(storage, contextFactory, skinStore, host, this);
|
||||
skinModelManager = new SkinModelManager(storage, contextFactory, host, this);
|
||||
|
||||
DefaultLegacySkin = new DefaultLegacySkin(this);
|
||||
DefaultSkin = new DefaultSkin(this);
|
||||
var defaultSkins = new[]
|
||||
{
|
||||
DefaultLegacySkin = new DefaultLegacySkin(this),
|
||||
DefaultSkin = new DefaultSkin(this),
|
||||
};
|
||||
|
||||
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue);
|
||||
// Ensure the default entries are present.
|
||||
using (var context = contextFactory.CreateContext())
|
||||
using (var transaction = context.BeginWrite())
|
||||
{
|
||||
foreach (var skin in defaultSkins)
|
||||
{
|
||||
if (context.Find<SkinInfo>(skin.SkinInfo.ID) == null)
|
||||
context.Add(skin.SkinInfo.Value);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
|
||||
|
||||
CurrentSkin.Value = DefaultSkin;
|
||||
CurrentSkin.ValueChanged += skin =>
|
||||
{
|
||||
if (skin.NewValue.SkinInfo != CurrentSkinInfo.Value)
|
||||
if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value))
|
||||
throw new InvalidOperationException($"Setting {nameof(CurrentSkin)}'s value directly is not supported. Use {nameof(CurrentSkinInfo)} instead.");
|
||||
|
||||
SourceChanged?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all usable <see cref="SkinInfo"/>s. Includes the special default skin plus all skins from <see cref="GetAllUserSkins"/>.
|
||||
/// </summary>
|
||||
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
|
||||
public List<SkinInfo> GetAllUsableSkins()
|
||||
{
|
||||
var userSkins = GetAllUserSkins();
|
||||
userSkins.Insert(0, DefaultSkin.SkinInfo);
|
||||
userSkins.Insert(1, DefaultLegacySkin.SkinInfo);
|
||||
return userSkins;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all usable <see cref="SkinInfo"/>s that have been loaded by the user.
|
||||
/// </summary>
|
||||
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
|
||||
public List<SkinInfo> GetAllUserSkins(bool includeFiles = false)
|
||||
{
|
||||
if (includeFiles)
|
||||
return skinStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
|
||||
|
||||
return skinStore.Items.Where(s => !s.DeletePending).ToList();
|
||||
}
|
||||
|
||||
public void SelectRandomSkin()
|
||||
{
|
||||
// choose from only user skins, removing the current selection to ensure a new one is chosen.
|
||||
var randomChoices = skinStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray();
|
||||
|
||||
if (randomChoices.Length == 0)
|
||||
using (var context = contextFactory.CreateContext())
|
||||
{
|
||||
CurrentSkinInfo.Value = SkinInfo.Default;
|
||||
return;
|
||||
}
|
||||
// choose from only user skins, removing the current selection to ensure a new one is chosen.
|
||||
var randomChoices = context.All<SkinInfo>().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray();
|
||||
|
||||
var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length));
|
||||
CurrentSkinInfo.Value = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID);
|
||||
if (randomChoices.Length == 0)
|
||||
{
|
||||
CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive();
|
||||
return;
|
||||
}
|
||||
|
||||
var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length));
|
||||
|
||||
CurrentSkinInfo.Value = chosen.ToLive();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -142,40 +142,36 @@ namespace osu.Game.Skinning
|
||||
/// </summary>
|
||||
public void EnsureMutableSkin()
|
||||
{
|
||||
if (CurrentSkinInfo.Value.ID >= 1) return;
|
||||
|
||||
var skin = CurrentSkin.Value;
|
||||
|
||||
// if the user is attempting to save one of the default skin implementations, create a copy first.
|
||||
CurrentSkinInfo.Value = skinModelManager.Import(new SkinInfo
|
||||
CurrentSkinInfo.Value.PerformRead(s =>
|
||||
{
|
||||
Name = skin.SkinInfo.Name + @" (modified)",
|
||||
Creator = skin.SkinInfo.Creator,
|
||||
InstantiationInfo = skin.SkinInfo.InstantiationInfo,
|
||||
}).Result.Value;
|
||||
if (!s.Protected)
|
||||
return;
|
||||
|
||||
// if the user is attempting to save one of the default skin implementations, create a copy first.
|
||||
var result = skinModelManager.Import(new SkinInfo
|
||||
{
|
||||
Name = s.Name + @" (modified)",
|
||||
Creator = s.Creator,
|
||||
InstantiationInfo = s.InstantiationInfo,
|
||||
}).Result;
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// save once to ensure the required json content is populated.
|
||||
// currently this only happens on save.
|
||||
result.PerformRead(skin => Save(skin.CreateInstance(this)));
|
||||
|
||||
CurrentSkinInfo.Value = result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Save(Skin skin)
|
||||
{
|
||||
if (skin.SkinInfo.ID <= 0)
|
||||
if (!skin.SkinInfo.IsManaged)
|
||||
throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first.");
|
||||
|
||||
foreach (var drawableInfo in skin.DrawableComponentInfo)
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented });
|
||||
|
||||
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json)))
|
||||
{
|
||||
string filename = @$"{drawableInfo.Key}.json";
|
||||
|
||||
var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename);
|
||||
|
||||
if (oldFile != null)
|
||||
skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent);
|
||||
else
|
||||
skinModelManager.AddFile(skin.SkinInfo, streamContent, filename);
|
||||
}
|
||||
}
|
||||
skinModelManager.Save(skin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,7 +179,11 @@ namespace osu.Game.Skinning
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>The first result for the provided query, or null if no results were found.</returns>
|
||||
public SkinInfo Query(Expression<Func<SkinInfo, bool>> query) => skinStore.ConsumableItems.AsNoTracking().FirstOrDefault(query);
|
||||
public ILive<SkinInfo> Query(Expression<Func<SkinInfo, bool>> query)
|
||||
{
|
||||
using (var context = contextFactory.CreateContext())
|
||||
return context.All<SkinInfo>().FirstOrDefault(query)?.ToLive();
|
||||
}
|
||||
|
||||
public event Action SourceChanged;
|
||||
|
||||
@@ -289,46 +289,23 @@ namespace osu.Game.Skinning
|
||||
|
||||
#region Implementation of IModelManager<SkinInfo>
|
||||
|
||||
public event Action<SkinInfo> ItemUpdated
|
||||
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)
|
||||
{
|
||||
add => skinModelManager.ItemUpdated += value;
|
||||
remove => skinModelManager.ItemUpdated -= value;
|
||||
}
|
||||
using (var context = contextFactory.CreateContext())
|
||||
{
|
||||
var items = context.All<SkinInfo>()
|
||||
.Where(s => !s.Protected && !s.DeletePending);
|
||||
if (filter != null)
|
||||
items = items.Where(filter);
|
||||
|
||||
public event Action<SkinInfo> ItemRemoved
|
||||
{
|
||||
add => skinModelManager.ItemRemoved += value;
|
||||
remove => skinModelManager.ItemRemoved -= value;
|
||||
}
|
||||
// check the removed skin is not the current user choice. if it is, switch back to default.
|
||||
Guid currentUserSkin = CurrentSkinInfo.Value.ID;
|
||||
|
||||
public void Update(SkinInfo item)
|
||||
{
|
||||
skinModelManager.Update(item);
|
||||
}
|
||||
if (items.Any(s => s.ID == currentUserSkin))
|
||||
scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive());
|
||||
|
||||
public bool Delete(SkinInfo item)
|
||||
{
|
||||
return skinModelManager.Delete(item);
|
||||
}
|
||||
|
||||
public void Delete(List<SkinInfo> items, bool silent = false)
|
||||
{
|
||||
skinModelManager.Delete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(List<SkinInfo> items, bool silent = false)
|
||||
{
|
||||
skinModelManager.Undelete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(SkinInfo item)
|
||||
{
|
||||
skinModelManager.Undelete(item);
|
||||
}
|
||||
|
||||
public bool IsAvailableLocally(SkinInfo model)
|
||||
{
|
||||
return skinModelManager.IsAvailableLocally(model);
|
||||
skinModelManager.Delete(items.ToList(), silent);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -8,21 +8,28 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Stores;
|
||||
using Realms;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
public class SkinModelManager : ArchiveModelManager<SkinInfo, SkinFileInfo>
|
||||
public class SkinModelManager : RealmArchiveModelManager<SkinInfo>
|
||||
{
|
||||
private const string skin_info_file = "skininfo.json";
|
||||
|
||||
private readonly IStorageResourceProvider skinResources;
|
||||
|
||||
public SkinModelManager(Storage storage, DatabaseContextFactory contextFactory, SkinStore skinStore, GameHost host, IStorageResourceProvider skinResources)
|
||||
: base(storage, contextFactory, skinStore, host)
|
||||
public SkinModelManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IStorageResourceProvider skinResources)
|
||||
: base(storage, contextFactory)
|
||||
{
|
||||
this.skinResources = skinResources;
|
||||
|
||||
@@ -42,18 +49,55 @@ namespace osu.Game.Skinning
|
||||
|
||||
protected override bool HasCustomHashFunction => true;
|
||||
|
||||
protected override string ComputeHash(SkinInfo item)
|
||||
protected override Task Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file);
|
||||
|
||||
if (skinInfoFile != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var existingStream = Files.Storage.GetStream(skinInfoFile.File.GetStoragePath()))
|
||||
using (var reader = new StreamReader(existingStream))
|
||||
{
|
||||
var deserialisedSkinInfo = JsonConvert.DeserializeObject<SkinInfo>(reader.ReadToEnd());
|
||||
|
||||
if (deserialisedSkinInfo != null)
|
||||
{
|
||||
// for now we only care about the instantiation info.
|
||||
// eventually we probably want to transfer everything across.
|
||||
model.InstantiationInfo = deserialisedSkinInfo.InstantiationInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogForModel(model, $"Error during {skin_info_file} parsing, falling back to default", e);
|
||||
|
||||
// Not sure if we should still run the import in the case of failure here, but let's do so for now.
|
||||
model.InstantiationInfo = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// Always rewrite instantiation info (even after parsing in from the skin json) for sanity.
|
||||
model.InstantiationInfo = createInstance(model).GetType().GetInvariantInstantiationInfo();
|
||||
|
||||
checkSkinIniMetadata(model, realm);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void checkSkinIniMetadata(SkinInfo item, Realm realm)
|
||||
{
|
||||
var instance = createInstance(item);
|
||||
|
||||
// This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations.
|
||||
|
||||
// `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above.
|
||||
string skinIniSourcedName = instance.Configuration.SkinInfo.Name;
|
||||
string skinIniSourcedCreator = instance.Configuration.SkinInfo.Creator;
|
||||
string archiveName = item.Name.Replace(@".osk", string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isImport = item.ID == 0;
|
||||
bool isImport = !item.IsManaged;
|
||||
|
||||
if (isImport)
|
||||
{
|
||||
@@ -71,12 +115,10 @@ namespace osu.Game.Skinning
|
||||
// Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching.
|
||||
// This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place.
|
||||
if (skinIniSourcedName != item.Name)
|
||||
updateSkinIniMetadata(item);
|
||||
|
||||
return base.ComputeHash(item);
|
||||
updateSkinIniMetadata(item, realm);
|
||||
}
|
||||
|
||||
private void updateSkinIniMetadata(SkinInfo item)
|
||||
private void updateSkinIniMetadata(SkinInfo item, Realm realm)
|
||||
{
|
||||
string nameLine = @$"Name: {item.Name}";
|
||||
string authorLine = @$"Author: {item.Creator}";
|
||||
@@ -95,39 +137,47 @@ namespace osu.Game.Skinning
|
||||
{
|
||||
// In the case a skin doesn't have a skin.ini yet, let's create one.
|
||||
writeNewSkinIni();
|
||||
return;
|
||||
}
|
||||
|
||||
using (Stream stream = new MemoryStream())
|
||||
else
|
||||
{
|
||||
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
using (Stream stream = new MemoryStream())
|
||||
{
|
||||
using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath()))
|
||||
using (var sr = new StreamReader(existingStream))
|
||||
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
string line;
|
||||
while ((line = sr.ReadLine()) != null)
|
||||
using (var existingStream = Files.Storage.GetStream(existingFile.File.GetStoragePath()))
|
||||
using (var sr = new StreamReader(existingStream))
|
||||
{
|
||||
string? line;
|
||||
while ((line = sr.ReadLine()) != null)
|
||||
sw.WriteLine(line);
|
||||
}
|
||||
|
||||
sw.WriteLine();
|
||||
|
||||
foreach (string line in newLines)
|
||||
sw.WriteLine(line);
|
||||
}
|
||||
|
||||
sw.WriteLine();
|
||||
ReplaceFile(existingFile, stream, realm);
|
||||
|
||||
foreach (string line in newLines)
|
||||
sw.WriteLine(line);
|
||||
}
|
||||
// can be removed 20220502.
|
||||
if (!ensureIniWasUpdated(item))
|
||||
{
|
||||
Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important);
|
||||
|
||||
ReplaceFile(item, existingFile, stream);
|
||||
var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase));
|
||||
if (existingIni != null)
|
||||
item.Files.Remove(existingIni);
|
||||
|
||||
// can be removed 20220502.
|
||||
if (!ensureIniWasUpdated(item))
|
||||
{
|
||||
Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important);
|
||||
|
||||
DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)));
|
||||
writeNewSkinIni();
|
||||
writeNewSkinIni();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hash is already populated at this point in import.
|
||||
// As we have changed files, it needs to be recomputed.
|
||||
item.Hash = ComputeHash(item);
|
||||
|
||||
void writeNewSkinIni()
|
||||
{
|
||||
using (Stream stream = new MemoryStream())
|
||||
@@ -138,8 +188,10 @@ namespace osu.Game.Skinning
|
||||
sw.WriteLine(line);
|
||||
}
|
||||
|
||||
AddFile(item, stream, @"skin.ini");
|
||||
AddFile(item, stream, @"skin.ini", realm);
|
||||
}
|
||||
|
||||
item.Hash = ComputeHash(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,36 +206,61 @@ namespace osu.Game.Skinning
|
||||
return instance.Configuration.SkinInfo.Name == item.Name;
|
||||
}
|
||||
|
||||
protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = createInstance(model);
|
||||
|
||||
model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo();
|
||||
|
||||
model.Name = instance.Configuration.SkinInfo.Name;
|
||||
model.Creator = instance.Configuration.SkinInfo.Creator;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void populateMissingHashes()
|
||||
{
|
||||
var skinsWithoutHashes = ModelStore.ConsumableItems.Where(i => i.Hash == null).ToArray();
|
||||
|
||||
foreach (SkinInfo skin in skinsWithoutHashes)
|
||||
using (var realm = ContextFactory.CreateContext())
|
||||
{
|
||||
try
|
||||
var skinsWithoutHashes = realm.All<SkinInfo>().Where(i => string.IsNullOrEmpty(i.Hash)).ToArray();
|
||||
|
||||
foreach (SkinInfo skin in skinsWithoutHashes)
|
||||
{
|
||||
Update(skin);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Delete(skin);
|
||||
Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid");
|
||||
try
|
||||
{
|
||||
Update(skin);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Delete(skin);
|
||||
Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Skin createInstance(SkinInfo item) => item.CreateInstance(skinResources);
|
||||
|
||||
public void Save(Skin skin)
|
||||
{
|
||||
skin.SkinInfo.PerformWrite(s =>
|
||||
{
|
||||
// Serialise out the SkinInfo itself.
|
||||
string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented });
|
||||
|
||||
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson)))
|
||||
{
|
||||
AddFile(s, streamContent, skin_info_file, s.Realm);
|
||||
}
|
||||
|
||||
// Then serialise each of the drawable component groups into respective files.
|
||||
foreach (var drawableInfo in skin.DrawableComponentInfo)
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented });
|
||||
|
||||
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json)))
|
||||
{
|
||||
string filename = @$"{drawableInfo.Key}.json";
|
||||
|
||||
var oldFile = s.Files.FirstOrDefault(f => f.Filename == filename);
|
||||
|
||||
if (oldFile != null)
|
||||
ReplaceFile(oldFile, streamContent, s.Realm);
|
||||
else
|
||||
AddFile(s, streamContent, filename, s.Realm);
|
||||
}
|
||||
}
|
||||
|
||||
s.Hash = ComputeHash(s);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using osu.Game.Database;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes<SkinInfo, SkinFileInfo>
|
||||
public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes<EFSkinInfo, SkinFileInfo>
|
||||
{
|
||||
public SkinStore(DatabaseContextFactory contextFactory, Storage storage = null)
|
||||
: base(contextFactory, storage)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// 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 System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Models;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
using Realms;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace osu.Game.Stores
|
||||
{
|
||||
/// <summary>
|
||||
/// Class which adds all the missing pieces bridging the gap between <see cref="RealmArchiveModelImporter{TModel}"/> and <see cref="ArchiveModelManager{TModel,TFileModel}"/>.
|
||||
/// </summary>
|
||||
public abstract class RealmArchiveModelManager<TModel> : RealmArchiveModelImporter<TModel>, IModelManager<TModel>, IModelFileManager<TModel, RealmNamedFileUsage>
|
||||
where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete
|
||||
{
|
||||
public event Action<TModel>? ItemUpdated
|
||||
{
|
||||
// This may be brought back for beatmaps to ease integration.
|
||||
// The eventual goal would be not requiring this and using realm subscriptions in its place.
|
||||
add => throw new NotImplementedException();
|
||||
remove => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public event Action<TModel>? ItemRemoved
|
||||
{
|
||||
// This may be brought back for beatmaps to ease integration.
|
||||
// The eventual goal would be not requiring this and using realm subscriptions in its place.
|
||||
add => throw new NotImplementedException();
|
||||
remove => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private readonly RealmFileStore realmFileStore;
|
||||
|
||||
protected RealmArchiveModelManager(Storage storage, RealmContextFactory contextFactory)
|
||||
: base(storage, contextFactory)
|
||||
{
|
||||
realmFileStore = new RealmFileStore(contextFactory, storage);
|
||||
}
|
||||
|
||||
public void DeleteFile(TModel item, RealmNamedFileUsage file) =>
|
||||
item.Realm.Write(() => DeleteFile(item, file, item.Realm));
|
||||
|
||||
public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents)
|
||||
=> item.Realm.Write(() => ReplaceFile(file, contents, item.Realm));
|
||||
|
||||
public void AddFile(TModel item, Stream contents, string filename)
|
||||
=> item.Realm.Write(() => AddFile(item, contents, filename, item.Realm));
|
||||
|
||||
/// <summary>
|
||||
/// Delete a file from within an ongoing realm transaction.
|
||||
/// </summary>
|
||||
protected void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm)
|
||||
{
|
||||
item.Files.Remove(file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace a file from within an ongoing realm transaction.
|
||||
/// </summary>
|
||||
protected void ReplaceFile(RealmNamedFileUsage file, Stream contents, Realm realm)
|
||||
{
|
||||
file.File = realmFileStore.Add(contents, realm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten.
|
||||
/// </summary>
|
||||
protected void AddFile(TModel item, Stream contents, string filename, Realm realm)
|
||||
{
|
||||
var existing = item.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
ReplaceFile(existing, contents, realm);
|
||||
return;
|
||||
}
|
||||
|
||||
var file = realmFileStore.Add(contents, realm);
|
||||
var namedUsage = new RealmNamedFileUsage(file, filename);
|
||||
|
||||
item.Files.Add(namedUsage);
|
||||
}
|
||||
|
||||
public override async Task<ILive<TModel>?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await base.Import(item, archive, lowPriority, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple items.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </summary>
|
||||
public void Delete(List<TModel> items, bool silent = false)
|
||||
{
|
||||
if (items.Count == 0) return;
|
||||
|
||||
var notification = new ProgressNotification
|
||||
{
|
||||
Progress = 0,
|
||||
Text = $"Preparing to delete all {HumanisedModelName}s...",
|
||||
CompletionText = $"Deleted all {HumanisedModelName}s!",
|
||||
State = ProgressNotificationState.Active,
|
||||
};
|
||||
|
||||
if (!silent)
|
||||
PostNotification?.Invoke(notification);
|
||||
|
||||
int i = 0;
|
||||
|
||||
foreach (var b in items)
|
||||
{
|
||||
if (notification.State == ProgressNotificationState.Cancelled)
|
||||
// user requested abort
|
||||
return;
|
||||
|
||||
notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})";
|
||||
|
||||
Delete(b);
|
||||
|
||||
notification.Progress = (float)i / items.Count;
|
||||
}
|
||||
|
||||
notification.State = ProgressNotificationState.Completed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore multiple items that were previously deleted.
|
||||
/// This will post notifications tracking progress.
|
||||
/// </summary>
|
||||
public void Undelete(List<TModel> items, bool silent = false)
|
||||
{
|
||||
if (!items.Any()) return;
|
||||
|
||||
var notification = new ProgressNotification
|
||||
{
|
||||
CompletionText = "Restored all deleted items!",
|
||||
Progress = 0,
|
||||
State = ProgressNotificationState.Active,
|
||||
};
|
||||
|
||||
if (!silent)
|
||||
PostNotification?.Invoke(notification);
|
||||
|
||||
int i = 0;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (notification.State == ProgressNotificationState.Cancelled)
|
||||
// user requested abort
|
||||
return;
|
||||
|
||||
notification.Text = $"Restoring ({++i} of {items.Count})";
|
||||
|
||||
Undelete(item);
|
||||
|
||||
notification.Progress = (float)i / items.Count;
|
||||
}
|
||||
|
||||
notification.State = ProgressNotificationState.Completed;
|
||||
}
|
||||
|
||||
public bool Delete(TModel item)
|
||||
{
|
||||
if (item.DeletePending)
|
||||
return false;
|
||||
|
||||
item.Realm.Write(r => item.DeletePending = true);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Undelete(TModel item)
|
||||
{
|
||||
if (!item.DeletePending)
|
||||
return;
|
||||
|
||||
item.Realm.Write(r => item.DeletePending = false);
|
||||
}
|
||||
|
||||
public virtual bool IsAvailableLocally(TModel model) => false; // Not relevant for skins since they can't be downloaded yet.
|
||||
|
||||
public void Update(TModel skin)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ using osu.Game.Rulesets;
|
||||
|
||||
namespace osu.Game.Stores
|
||||
{
|
||||
public class RealmRulesetStore : IDisposable
|
||||
public class RealmRulesetStore : IRulesetStore, IDisposable
|
||||
{
|
||||
private readonly RealmContextFactory realmFactory;
|
||||
|
||||
@@ -29,9 +29,9 @@ namespace osu.Game.Stores
|
||||
/// <summary>
|
||||
/// All available rulesets.
|
||||
/// </summary>
|
||||
public IEnumerable<IRulesetInfo> AvailableRulesets => availableRulesets;
|
||||
public IEnumerable<RealmRuleset> AvailableRulesets => availableRulesets;
|
||||
|
||||
private readonly List<IRulesetInfo> availableRulesets = new List<IRulesetInfo>();
|
||||
private readonly List<RealmRuleset> availableRulesets = new List<RealmRuleset>();
|
||||
|
||||
public RealmRulesetStore(RealmContextFactory realmFactory, Storage? storage = null)
|
||||
{
|
||||
@@ -64,14 +64,14 @@ namespace osu.Game.Stores
|
||||
/// </summary>
|
||||
/// <param name="id">The ruleset's internal ID.</param>
|
||||
/// <returns>A ruleset, if available, else null.</returns>
|
||||
public IRulesetInfo? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id);
|
||||
public RealmRuleset? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a ruleset using a known short name.
|
||||
/// </summary>
|
||||
/// <param name="shortName">The ruleset's short name.</param>
|
||||
/// <returns>A ruleset, if available, else null.</returns>
|
||||
public IRulesetInfo? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName);
|
||||
public RealmRuleset? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName);
|
||||
|
||||
private Assembly? resolveRulesetDependencyAssembly(object? sender, ResolveEventArgs args)
|
||||
{
|
||||
@@ -258,5 +258,13 @@ namespace osu.Game.Stores
|
||||
{
|
||||
AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly;
|
||||
}
|
||||
|
||||
#region Implementation of IRulesetStore
|
||||
|
||||
IRulesetInfo? IRulesetStore.GetRuleset(int id) => GetRuleset(id);
|
||||
IRulesetInfo? IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName);
|
||||
IEnumerable<IRulesetInfo> IRulesetStore.AvailableRulesets => AvailableRulesets;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ using osu.Framework.Timing;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.Models;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Screens.Ranking;
|
||||
@@ -88,11 +89,7 @@ namespace osu.Game.Tests.Beatmaps
|
||||
AddStep("setup skins", () =>
|
||||
{
|
||||
userSkinInfo.Files.Clear();
|
||||
userSkinInfo.Files.Add(new SkinFileInfo
|
||||
{
|
||||
Filename = userFile,
|
||||
FileInfo = new IO.FileInfo { Hash = userFile }
|
||||
});
|
||||
userSkinInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = userFile }, userFile));
|
||||
|
||||
beatmapInfo.BeatmapSet.Files.Clear();
|
||||
beatmapInfo.BeatmapSet.Files.Add(new BeatmapSetFileInfo
|
||||
|
||||
@@ -430,21 +430,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
Debug.Assert(Room != null);
|
||||
|
||||
// Add the item to the list first in order to compute gameplay order.
|
||||
serverSidePlaylist.Add(item);
|
||||
await updatePlaylistOrder(Room).ConfigureAwait(false);
|
||||
|
||||
item.ID = ++lastPlaylistItemId;
|
||||
|
||||
serverSidePlaylist.Add(item);
|
||||
await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false);
|
||||
|
||||
await updatePlaylistOrder(Room).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true)
|
||||
{
|
||||
MultiplayerPlaylistItem nextItem = serverSidePlaylist
|
||||
.Where(i => !i.Expired)
|
||||
.OrderBy(i => i.PlaylistOrder)
|
||||
.FirstOrDefault()
|
||||
?? room.Playlist.Last();
|
||||
// Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item.
|
||||
MultiplayerPlaylistItem nextItem = serverSidePlaylist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder).FirstOrDefault()
|
||||
?? serverSidePlaylist.OrderByDescending(i => i.PlayedAt).First();
|
||||
|
||||
currentIndex = serverSidePlaylist.IndexOf(nextItem);
|
||||
|
||||
@@ -462,36 +460,32 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
switch (room.Settings.QueueMode)
|
||||
{
|
||||
default:
|
||||
orderedActiveItems = serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID == 0 ? int.MaxValue : item.ID).ToList();
|
||||
orderedActiveItems = serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList();
|
||||
break;
|
||||
|
||||
case QueueMode.AllPlayersRoundRobin:
|
||||
orderedActiveItems = new List<MultiplayerPlaylistItem>();
|
||||
var itemsByPriority = new List<(MultiplayerPlaylistItem item, int priority)>();
|
||||
|
||||
// Todo: This could probably be more efficient, likely at the cost of increased complexity.
|
||||
// Number of "expired" or "used" items per player.
|
||||
Dictionary<int, int> perUserCounts = serverSidePlaylist
|
||||
.GroupBy(item => item.OwnerID)
|
||||
.ToDictionary(group => group.Key, group => group.Count(item => item.Expired));
|
||||
|
||||
// We'll run a simulation over all items which are not expired ("unprocessed"). Expired items will not have their ordering updated.
|
||||
List<MultiplayerPlaylistItem> unprocessedItems = serverSidePlaylist.Where(item => !item.Expired).ToList();
|
||||
|
||||
// In every iteration of the simulation, pick the first available item from the user with the lowest number of items in the queue to add to the result set.
|
||||
// If multiple users have the same number of items in the queue, then the item with the lowest ID is chosen.
|
||||
while (unprocessedItems.Count > 0)
|
||||
// Assign a priority for items from each user, starting from 0 and increasing in order which the user added the items.
|
||||
foreach (var group in room.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID))
|
||||
{
|
||||
MultiplayerPlaylistItem candidateItem = unprocessedItems
|
||||
.OrderBy(item => perUserCounts[item.OwnerID])
|
||||
.ThenBy(item => item.ID == 0 ? int.MaxValue : item.ID)
|
||||
.First();
|
||||
|
||||
unprocessedItems.Remove(candidateItem);
|
||||
orderedActiveItems.Add(candidateItem);
|
||||
|
||||
perUserCounts[candidateItem.OwnerID]++;
|
||||
int priority = 0;
|
||||
itemsByPriority.AddRange(group.Select(item => (item, priority++)));
|
||||
}
|
||||
|
||||
orderedActiveItems = itemsByPriority
|
||||
// Order by each user's priority.
|
||||
.OrderBy(i => i.priority)
|
||||
// Many users will have the same priority of items, so attempt to break the tie by maintaining previous ordering.
|
||||
// Suppose there are two users: User1 and User2. User1 adds two items, and then User2 adds a third. If the previous order is not maintained,
|
||||
// then after playing the first item by User1, their second item will become priority=0 and jump to the front of the queue (because it was added first).
|
||||
.ThenBy(i => i.item.PlaylistOrder)
|
||||
// If there are still ties (normally shouldn't happen), break ties by making items added earlier go first.
|
||||
// This could happen if e.g. the item orders get reset.
|
||||
.ThenBy(i => i.item.ID)
|
||||
.Select(i => i.item)
|
||||
.ToList();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -504,10 +498,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
item.PlaylistOrder = (ushort)i;
|
||||
|
||||
// Items which have an ID of 0 are not in the database, so avoid propagating database/hub events for them.
|
||||
if (item.ID <= 0)
|
||||
continue;
|
||||
|
||||
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ namespace osu.Game.Tests.Visual
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = skin?.SkinInfo?.Name ?? "none",
|
||||
Text = skin?.SkinInfo?.Value.Name ?? "none",
|
||||
Scale = new Vector2(1.5f),
|
||||
Padding = new MarginPadding(5),
|
||||
},
|
||||
|
||||
@@ -29,9 +29,9 @@ namespace osu.Game.Users
|
||||
{
|
||||
public IBeatmapInfo BeatmapInfo { get; }
|
||||
|
||||
public RulesetInfo Ruleset { get; }
|
||||
public IRulesetInfo Ruleset { get; }
|
||||
|
||||
protected InGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
|
||||
protected InGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
|
||||
{
|
||||
BeatmapInfo = beatmapInfo;
|
||||
Ruleset = ruleset;
|
||||
@@ -42,7 +42,7 @@ namespace osu.Game.Users
|
||||
|
||||
public class InMultiplayerGame : InGame
|
||||
{
|
||||
public InMultiplayerGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
|
||||
public InMultiplayerGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
|
||||
: base(beatmapInfo, ruleset)
|
||||
{
|
||||
}
|
||||
@@ -52,7 +52,7 @@ namespace osu.Game.Users
|
||||
|
||||
public class InPlaylistGame : InGame
|
||||
{
|
||||
public InPlaylistGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
|
||||
public InPlaylistGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
|
||||
: base(beatmapInfo, ruleset)
|
||||
{
|
||||
}
|
||||
@@ -60,7 +60,7 @@ namespace osu.Game.Users
|
||||
|
||||
public class InSoloGame : InGame
|
||||
{
|
||||
public InSoloGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
|
||||
public InSoloGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
|
||||
: base(beatmapInfo, ruleset)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Realm" Version="10.7.1" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2021.1203.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2021.1207.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
|
||||
<PackageReference Include="Sentry" Version="3.11.1" />
|
||||
<PackageReference Include="SharpCompress" Version="0.30.0" />
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Package References">
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1203.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1207.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
|
||||
</ItemGroup>
|
||||
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
|
||||
@@ -83,7 +83,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2021.1203.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2021.1207.0" />
|
||||
<PackageReference Include="SharpCompress" Version="0.30.0" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="SharpRaven" Version="2.4.0" />
|
||||
|
||||
Reference in New Issue
Block a user