diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 985fc09df3..4177c402aa 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -27,7 +27,7 @@
]
},
"ppy.localisationanalyser.tools": {
- "version": "2021.1210.0",
+ "version": "2022.320.0",
"commands": [
"localisation"
]
diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
new file mode 100644
index 0000000000..5b19c3732c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-issue.yml
@@ -0,0 +1,72 @@
+name: Bug report
+description: Report a very clearly broken issue.
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # osu! bug report
+
+ Important to note that your issue may have already been reported before. Please check:
+ - Pinned issues, at the top of https://github.com/ppy/osu/issues.
+ - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0).
+ - And most importantly, search for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful.
+
+ - type: dropdown
+ attributes:
+ label: Type
+ options:
+ - Crash to desktop
+ - Game behaviour
+ - Performance
+ - Cosmetic
+ - Other
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Bug description
+ description: How did you find the bug? Any additional details that might help?
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Screenshots or videos
+ description: Add screenshots or videos that show the bug here.
+ placeholder: Drag and drop the screenshots/videos into this box.
+ validations:
+ required: false
+ - type: input
+ attributes:
+ label: Version
+ description: The version you encountered this bug on. This is shown at the bottom of the main menu and also at the end of the settings screen.
+ validations:
+ required: true
+ - type: markdown
+ attributes:
+ value: |
+ ## Logs
+
+ Attaching log files is required for every reported bug. See instructions below on how to find them.
+
+ If the game has not yet been closed since you found the bug:
+ 1. Head on to game settings and click on "Open osu! folder"
+ 2. Then open the `logs` folder located there
+
+ **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead.
+
+ The default places to find the logs are as follows:
+ - `%AppData%/osu/logs` *on Windows*
+ - `~/.local/share/osu/logs` *on Linux & macOS*
+ - `Android/data/sh.ppy.osulazer/files/logs` *on Android*
+ - *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
+
+ If you have selected a custom location for the game files, you can find the `logs` folder there.
+
+ After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below.
+
+ - type: textarea
+ attributes:
+ label: Logs
+ placeholder: Drag and drop the log files into this box.
+ validations:
+ required: true
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000000..5b7a98f4ba
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "ms-dotnettools.csharp"
+ ]
+}
diff --git a/README.md b/README.md
index f64240f67a..dba0b2670d 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ If you are looking to install or test osu! without setting up a development envi
**Latest build:**
-| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.15+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk)
+| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.15+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk)
| ------------- | ------------- | ------------- | ------------- | ------------- |
- The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets.
diff --git a/osu.Android.props b/osu.Android.props
index 1b5461959a..6a3b113fa2 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,8 +51,8 @@
-
-
+
+
diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs
index e70def7f8b..bb3a724b91 100644
--- a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs
+++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public TestLegacySkin(SkinInfo skin, IResourceStore storage)
// Bypass LegacySkinResourceStore to avoid returning null for retrieving files due to bad skin info (SkinInfo.Files = null).
- : base(skin, storage, null, "skin.ini")
+ : base(skin, null, storage)
{
}
}
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs
index ed4b139e00..1abbd67d8f 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs
@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Fun;
public override string Description => "No need to chase the circle – the circle chases you!";
public override double ScoreMultiplier => 1;
- public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay) };
+ public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) };
private IFrameStableClock gameplayClock;
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
index e04a30d06c..f46573c494 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Game.Configuration;
@@ -16,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModClassic : ModClassic, IApplicableToHitObject, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset
{
+ public override Type[] IncompatibleMods => new[] { typeof(OsuModStrictTracking) };
+
[SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")]
public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true);
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
index 1bf63ef6d4..9719de441e 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer
{
public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things.";
- public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).ToArray();
+ public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModAimAssist) }).ToArray();
///
/// How early before a hitobject's start time to trigger a hit.
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs
new file mode 100644
index 0000000000..ee325db66a
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs
@@ -0,0 +1,148 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Linq;
+using System.Threading;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Beatmaps;
+using osu.Game.Rulesets.Osu.Judgements;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Objects.Drawables;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Rulesets.Osu.Mods
+{
+ public class OsuModStrictTracking : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset
+ {
+ public override string Name => @"Strict Tracking";
+ public override string Acronym => @"ST";
+ public override IconUsage? Icon => FontAwesome.Solid.PenFancy;
+ public override ModType Type => ModType.DifficultyIncrease;
+ public override string Description => @"Follow circles just got serious...";
+ public override double ScoreMultiplier => 1.0;
+ public override Type[] IncompatibleMods => new[] { typeof(ModClassic) };
+
+ public void ApplyToDrawableHitObject(DrawableHitObject drawable)
+ {
+ if (drawable is DrawableSlider slider)
+ {
+ slider.Tracking.ValueChanged += e =>
+ {
+ if (e.NewValue || slider.Judged) return;
+
+ var tail = slider.NestedHitObjects.OfType().First();
+
+ if (!tail.Judged)
+ tail.MissForcefully();
+ };
+ }
+ }
+
+ public void ApplyToBeatmap(IBeatmap beatmap)
+ {
+ var osuBeatmap = (OsuBeatmap)beatmap;
+
+ if (osuBeatmap.HitObjects.Count == 0) return;
+
+ var hitObjects = osuBeatmap.HitObjects.Select(ho =>
+ {
+ if (ho is Slider slider)
+ {
+ var newSlider = new StrictTrackingSlider(slider);
+ return newSlider;
+ }
+
+ return ho;
+ }).ToList();
+
+ osuBeatmap.HitObjects = hitObjects;
+ }
+
+ public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset)
+ {
+ drawableRuleset.Playfield.RegisterPool(10, 100);
+ }
+
+ private class StrictTrackingSliderTailCircle : SliderTailCircle
+ {
+ public StrictTrackingSliderTailCircle(Slider slider)
+ : base(slider)
+ {
+ }
+
+ public override Judgement CreateJudgement() => new OsuJudgement();
+ }
+
+ private class StrictTrackingDrawableSliderTail : DrawableSliderTail
+ {
+ public override bool DisplayResult => true;
+ }
+
+ private class StrictTrackingSlider : Slider
+ {
+ public StrictTrackingSlider(Slider original)
+ {
+ StartTime = original.StartTime;
+ Samples = original.Samples;
+ Path = original.Path;
+ NodeSamples = original.NodeSamples;
+ RepeatCount = original.RepeatCount;
+ Position = original.Position;
+ NewCombo = original.NewCombo;
+ ComboOffset = original.ComboOffset;
+ LegacyLastTickOffset = original.LegacyLastTickOffset;
+ TickDistanceMultiplier = original.TickDistanceMultiplier;
+ }
+
+ protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
+ {
+ var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken);
+
+ foreach (var e in sliderEvents)
+ {
+ switch (e.Type)
+ {
+ case SliderEventType.Head:
+ AddNested(HeadCircle = new SliderHeadCircle
+ {
+ StartTime = e.Time,
+ Position = Position,
+ StackHeight = StackHeight,
+ });
+ break;
+
+ case SliderEventType.LegacyLastTick:
+ AddNested(TailCircle = new StrictTrackingSliderTailCircle(this)
+ {
+ RepeatIndex = e.SpanIndex,
+ StartTime = e.Time,
+ Position = EndPosition,
+ StackHeight = StackHeight
+ });
+ break;
+
+ case SliderEventType.Repeat:
+ AddNested(new SliderRepeat(this)
+ {
+ RepeatIndex = e.SpanIndex,
+ StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration,
+ Position = Position + Path.PositionAt(e.PathProgress),
+ StackHeight = StackHeight,
+ Scale = Scale,
+ });
+ break;
+ }
+ }
+
+ UpdateNestedSamples();
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs
index 776165cfb4..a698311bf7 100644
--- a/osu.Game.Rulesets.Osu/Objects/Slider.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs
@@ -154,7 +154,7 @@ namespace osu.Game.Rulesets.Osu.Objects
public Slider()
{
- SamplesBindable.CollectionChanged += (_, __) => updateNestedSamples();
+ SamplesBindable.CollectionChanged += (_, __) => UpdateNestedSamples();
Path.Version.ValueChanged += _ => updateNestedPositions();
}
@@ -227,7 +227,7 @@ namespace osu.Game.Rulesets.Osu.Objects
}
}
- updateNestedSamples();
+ UpdateNestedSamples();
}
private void updateNestedPositions()
@@ -241,7 +241,7 @@ namespace osu.Game.Rulesets.Osu.Objects
TailCircle.Position = EndPosition;
}
- private void updateNestedSamples()
+ protected void UpdateNestedSamples()
{
var firstSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)
?? Samples.FirstOrDefault(); // TODO: remove this when guaranteed sort is present for samples (https://github.com/ppy/osu/issues/1933)
diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs
index 2fdf42fca1..47a2618ddd 100644
--- a/osu.Game.Rulesets.Osu/OsuRuleset.cs
+++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs
@@ -159,6 +159,7 @@ namespace osu.Game.Rulesets.Osu
new MultiMod(new OsuModDoubleTime(), new OsuModNightcore()),
new OsuModHidden(),
new MultiMod(new OsuModFlashlight(), new OsuModBlinds()),
+ new OsuModStrictTracking()
};
case ModType.Conversion:
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
index ff9f6f0e07..900ad6f6d3 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
@@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
switch (osuComponent.Component)
{
case OsuSkinComponents.FollowPoint:
- return this.GetAnimation(component.LookupName, true, false, true, startAtCurrentTime: false);
+ return this.GetAnimation(component.LookupName, true, true, true, startAtCurrentTime: false);
case OsuSkinComponents.SliderFollowCircle:
var followCircle = this.GetAnimation("sliderfollowcircle", true, true, true);
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
index d19b3c71f1..0d436c1ef7 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
@@ -175,7 +175,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
private class TestLegacySkin : LegacySkin
{
public TestLegacySkin(IResourceStore storage, string fileName)
- : base(new SkinInfo { Name = "Test Skin", Creator = "Craftplacer" }, storage, null, fileName)
+ : base(new SkinInfo { Name = "Test Skin", Creator = "Craftplacer" }, null, storage, fileName)
{
}
}
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
index 2ba8c51a10..1474f2d277 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
@@ -8,6 +8,7 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Formats;
using osu.Game.Replays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
@@ -64,6 +65,62 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
+ [TestCase(3, true)]
+ [TestCase(6, false)]
+ [TestCase(LegacyBeatmapDecoder.LATEST_VERSION, false)]
+ public void TestLegacyBeatmapReplayOffsetsDecode(int beatmapVersion, bool offsetApplied)
+ {
+ const double first_frame_time = 48;
+ const double second_frame_time = 65;
+
+ var decoder = new TestLegacyScoreDecoder(beatmapVersion);
+
+ using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr"))
+ {
+ var score = decoder.Parse(resourceStream);
+
+ Assert.That(score.Replay.Frames[0].Time, Is.EqualTo(first_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0)));
+ Assert.That(score.Replay.Frames[1].Time, Is.EqualTo(second_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0)));
+ }
+ }
+
+ [TestCase(3)]
+ [TestCase(6)]
+ [TestCase(LegacyBeatmapDecoder.LATEST_VERSION)]
+ public void TestLegacyBeatmapReplayOffsetsEncodeDecode(int beatmapVersion)
+ {
+ const double first_frame_time = 2000;
+ const double second_frame_time = 3000;
+
+ var ruleset = new OsuRuleset().RulesetInfo;
+ var scoreInfo = TestResources.CreateTestScoreInfo(ruleset);
+ var beatmap = new TestBeatmap(ruleset)
+ {
+ BeatmapInfo =
+ {
+ BeatmapVersion = beatmapVersion
+ }
+ };
+
+ var score = new Score
+ {
+ ScoreInfo = scoreInfo,
+ Replay = new Replay
+ {
+ Frames = new List
+ {
+ new OsuReplayFrame(first_frame_time, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton),
+ new OsuReplayFrame(second_frame_time, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton)
+ }
+ }
+ };
+
+ var decodedAfterEncode = encodeThenDecode(beatmapVersion, score, beatmap);
+
+ Assert.That(decodedAfterEncode.Replay.Frames[0].Time, Is.EqualTo(first_frame_time));
+ Assert.That(decodedAfterEncode.Replay.Frames[1].Time, Is.EqualTo(second_frame_time));
+ }
+
[Test]
public void TestCultureInvariance()
{
@@ -86,15 +143,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
// 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);
+ var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap);
Assert.Multiple(() =>
{
@@ -110,6 +159,20 @@ namespace osu.Game.Tests.Beatmaps.Formats
});
}
+ private static Score encodeThenDecode(int beatmapVersion, Score score, TestBeatmap beatmap)
+ {
+ var encodeStream = new MemoryStream();
+
+ var encoder = new LegacyScoreEncoder(score, beatmap);
+ encoder.Encode(encodeStream);
+
+ var decodeStream = new MemoryStream(encodeStream.GetBuffer());
+
+ var decoder = new TestLegacyScoreDecoder(beatmapVersion);
+ var decodedAfterEncode = decoder.Parse(decodeStream);
+ return decodedAfterEncode;
+ }
+
[TearDown]
public void TearDown()
{
@@ -118,6 +181,8 @@ namespace osu.Game.Tests.Beatmaps.Formats
private class TestLegacyScoreDecoder : LegacyScoreDecoder
{
+ private readonly int beatmapVersion;
+
private static readonly Dictionary rulesets = new Ruleset[]
{
new OsuRuleset(),
@@ -126,6 +191,11 @@ namespace osu.Game.Tests.Beatmaps.Formats
new ManiaRuleset()
}.ToDictionary(ruleset => ((ILegacyRuleset)ruleset).LegacyID);
+ public TestLegacyScoreDecoder(int beatmapVersion = LegacyBeatmapDecoder.LATEST_VERSION)
+ {
+ this.beatmapVersion = beatmapVersion;
+ }
+
protected override Ruleset GetRuleset(int rulesetId) => rulesets[rulesetId];
protected override WorkingBeatmap GetBeatmap(string md5Hash) => new TestWorkingBeatmap(new Beatmap
@@ -134,7 +204,8 @@ namespace osu.Game.Tests.Beatmaps.Formats
{
MD5Hash = md5Hash,
Ruleset = new OsuRuleset().RulesetInfo,
- Difficulty = new BeatmapDifficulty()
+ Difficulty = new BeatmapDifficulty(),
+ BeatmapVersion = beatmapVersion,
}
});
}
diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs
index 9d67381b5a..f9c13a8169 100644
--- a/osu.Game.Tests/Database/BeatmapImporterTests.cs
+++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs
@@ -147,7 +147,10 @@ namespace osu.Game.Tests.Database
Live? imported;
using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream()))
+ {
imported = await importer.Import(reader);
+ EnsureLoaded(realm.Realm);
+ }
Assert.AreEqual(1, realm.Realm.All().Count());
@@ -510,6 +513,8 @@ namespace osu.Game.Tests.Database
new ImportTask(zipStream, string.Empty)
);
+ realm.Run(r => r.Refresh());
+
checkBeatmapSetCount(realm.Realm, 0);
checkBeatmapCount(realm.Realm, 0);
@@ -565,6 +570,8 @@ namespace osu.Game.Tests.Database
{
}
+ EnsureLoaded(realm.Realm);
+
checkBeatmapSetCount(realm.Realm, 1);
checkBeatmapCount(realm.Realm, 12);
@@ -590,6 +597,8 @@ namespace osu.Game.Tests.Database
Assert.IsTrue(imported.DeletePending);
+ var originalAddedDate = imported.DateAdded;
+
var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm);
// check the newly "imported" beatmap is actually just the restored previous import. since it matches hash.
@@ -597,6 +606,7 @@ namespace osu.Game.Tests.Database
Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID);
Assert.IsFalse(imported.DeletePending);
Assert.IsFalse(importedSecondTime.DeletePending);
+ Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate));
});
}
@@ -646,6 +656,8 @@ namespace osu.Game.Tests.Database
Assert.IsTrue(imported.DeletePending);
+ var originalAddedDate = imported.DateAdded;
+
var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm);
// check the newly "imported" beatmap is actually just the restored previous import. since it matches hash.
@@ -653,6 +665,7 @@ namespace osu.Game.Tests.Database
Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID);
Assert.IsFalse(imported.DeletePending);
Assert.IsFalse(importedSecondTime.DeletePending);
+ Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate));
});
}
@@ -720,6 +733,8 @@ namespace osu.Game.Tests.Database
var imported = importer.Import(toImport);
+ realm.Run(r => r.Refresh());
+
Assert.NotNull(imported);
Debug.Assert(imported != null);
@@ -885,6 +900,8 @@ namespace osu.Game.Tests.Database
string? temp = TestResources.GetTestBeatmapForImport();
await importer.Import(temp);
+ EnsureLoaded(realm.Realm);
+
// Update via the beatmap, not the beatmap info, to ensure correct linking
BeatmapSetInfo setToUpdate = realm.Realm.All().First();
diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
index 6457a23a1b..76ec35d87d 100644
--- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
+++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
@@ -148,7 +148,7 @@ namespace osu.Game.Tests.Gameplay
private class TestSkin : LegacySkin
{
public TestSkin(string resourceName, IStorageResourceProvider resources)
- : base(DefaultLegacySkin.CreateInfo(), new TestResourceStore(resourceName), resources, "skin.ini")
+ : base(DefaultLegacySkin.CreateInfo(), resources, new TestResourceStore(resourceName))
{
}
}
diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs
index 69e66942ab..7516e7500b 100644
--- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs
+++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs
@@ -1,12 +1,21 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using NUnit.Framework;
-using osu.Framework.Graphics.OpenGL.Textures;
+using osu.Framework.Audio;
using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
+using osu.Game.Database;
+using osu.Game.IO;
using osu.Game.Skinning;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
namespace osu.Game.Tests.NonVisual.Skinning
{
@@ -71,7 +80,7 @@ namespace osu.Game.Tests.NonVisual.Skinning
var texture = legacySkin.GetTexture(requestedComponent);
Assert.IsNotNull(texture);
- Assert.AreEqual(textureStore.Textures[expectedTexture], texture);
+ Assert.AreEqual(textureStore.Textures[expectedTexture].Width, texture.Width);
Assert.AreEqual(expectedScale, texture.ScaleAdjust);
}
@@ -88,23 +97,50 @@ namespace osu.Game.Tests.NonVisual.Skinning
private class TestLegacySkin : LegacySkin
{
- public TestLegacySkin(TextureStore textureStore)
- : base(new SkinInfo(), null, null, string.Empty)
+ public TestLegacySkin(IResourceStore textureStore)
+ : base(new SkinInfo(), new TestResourceProvider(textureStore), null, string.Empty)
{
- Textures = textureStore;
+ }
+
+ private class TestResourceProvider : IStorageResourceProvider
+ {
+ private readonly IResourceStore textureStore;
+
+ public TestResourceProvider(IResourceStore textureStore)
+ {
+ this.textureStore = textureStore;
+ }
+
+ public AudioManager AudioManager => null;
+ public IResourceStore Files => null;
+ public IResourceStore Resources => null;
+ public RealmAccess RealmAccess => null;
+ public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => textureStore;
}
}
- private class TestTextureStore : TextureStore
+ private class TestTextureStore : IResourceStore
{
- public readonly Dictionary Textures;
+ public readonly Dictionary Textures;
public TestTextureStore(params string[] fileNames)
{
- Textures = fileNames.ToDictionary(fileName => fileName, fileName => new Texture(1, 1));
+ // use an incrementing width to allow assertion matching on correct textures as they turn from uploads into actual textures.
+ int width = 1;
+ Textures = fileNames.ToDictionary(fileName => fileName, fileName => new TextureUpload(new Image(width, width++)));
}
- public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => Textures.GetValueOrDefault(name);
+ public TextureUpload Get(string name) => Textures.GetValueOrDefault(name);
+
+ public Task GetAsync(string name, CancellationToken cancellationToken = new CancellationToken()) => Task.FromResult(Get(name));
+
+ public Stream GetStream(string name) => throw new NotImplementedException();
+
+ public IEnumerable GetAvailableResources() => throw new NotImplementedException();
+
+ public void Dispose()
+ {
+ }
}
}
}
diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs
index 71544e94f3..0c1981b35d 100644
--- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs
+++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs
@@ -77,7 +77,7 @@ namespace osu.Game.Tests.Skins
public class BeatmapSkinSource : LegacyBeatmapSkin
{
public BeatmapSkinSource()
- : base(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, null, null)
+ : base(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, null)
{
}
diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs
index 870d6d8f57..d3cacaa88c 100644
--- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs
+++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs
@@ -202,7 +202,7 @@ namespace osu.Game.Tests.Skins
public class BeatmapSkinSource : LegacyBeatmapSkin
{
public BeatmapSkinSource()
- : base(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, null, null)
+ : base(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, null)
{
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
index ecd4035edd..b109234fec 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
@@ -13,6 +13,7 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Database;
+using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Osu;
@@ -23,6 +24,7 @@ using osu.Game.Screens.Edit.Setup;
using osu.Game.Storyboards;
using osu.Game.Tests.Resources;
using osuTK;
+using osuTK.Input;
using SharpCompress.Archives;
using SharpCompress.Archives.Zip;
@@ -63,13 +65,19 @@ namespace osu.Game.Tests.Visual.Editing
EditorBeatmap editorBeatmap = null;
AddStep("store editor beatmap", () => editorBeatmap = EditorBeatmap);
- AddStep("exit without save", () =>
+
+ AddStep("exit without save", () => Editor.Exit());
+ AddStep("hold to confirm", () =>
{
- Editor.Exit();
- DialogOverlay.CurrentDialog.PerformOkAction();
+ var confirmButton = DialogOverlay.CurrentDialog.ChildrenOfType().First();
+
+ InputManager.MoveMouseTo(confirmButton);
+ InputManager.PressButton(MouseButton.Left);
});
AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen());
+ AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
+
AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.ID)?.Value.DeletePending == true);
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
index d1c1558003..e75c7f25a3 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
@@ -17,6 +17,13 @@ namespace osu.Game.Tests.Visual.Editing
{
public class TestSceneEditorSaving : EditorSavingTestScene
{
+ [Test]
+ public void TestCantExitWithoutSaving()
+ {
+ AddRepeatStep("Exit", () => InputManager.Key(Key.Escape), 10);
+ AddAssert("Editor is still active screen", () => Game.ScreenStack.CurrentScreen is Editor);
+ }
+
[Test]
public void TestMetadata()
{
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs
index c5ea9e6204..53364b6d89 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs
@@ -18,9 +18,11 @@ using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Skinning.Legacy;
using osu.Game.Rulesets.Scoring;
+using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning;
using osu.Game.Storyboards;
+using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Visual.Gameplay
{
@@ -37,12 +39,18 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
+ [Cached]
+ private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset());
+
+ [Cached]
+ private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());
+
protected override bool HasCustomSteps => true;
[Test]
public void TestEmptyLegacyBeatmapSkinFallsBack()
{
- CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null));
+ CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null));
AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded));
AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value));
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs
index 505f73159f..2d12645811 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs
@@ -8,12 +8,14 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
+using osu.Framework.Timing;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
+using osu.Game.Tests.Beatmaps;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay
@@ -30,6 +32,12 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
+ [Cached]
+ private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset());
+
+ [Cached]
+ private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());
+
// best way to check without exposing.
private Drawable hideTarget => hudOverlay.KeyCounter;
private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First();
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs
index b195d2aa74..5a1fc1b1e5 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs
@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
+using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
@@ -107,7 +108,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("create player", () =>
{
- Beatmap.Value = CreateWorkingBeatmap(beatmap, storyboard);
+ Beatmap.Value = new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), Audio);
LoadScreen(player = new LeadInPlayer());
});
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
index e74345aae9..38d83058c0 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
@@ -1,14 +1,19 @@
// Copyright (c) ppy Pty Ltd . 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.Graphics;
+using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Overlays;
+using osu.Game.Overlays.Settings;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
+using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osu.Game.Skinning.Editor;
+using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay
{
@@ -29,7 +34,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("reload skin editor", () =>
{
skinEditor?.Expire();
- Player.ScaleTo(0.8f);
+ Player.ScaleTo(0.4f);
LoadComponentAsync(skinEditor = new SkinEditor(Player), Add);
});
}
@@ -40,6 +45,36 @@ namespace osu.Game.Tests.Visual.Gameplay
AddToggleStep("toggle editor visibility", visible => skinEditor.ToggleVisibility());
}
+ [Test]
+ public void TestEditComponent()
+ {
+ BarHitErrorMeter hitErrorMeter = null;
+
+ AddStep("select bar hit error blueprint", () =>
+ {
+ var blueprint = skinEditor.ChildrenOfType().First(b => b.Item is BarHitErrorMeter);
+
+ hitErrorMeter = (BarHitErrorMeter)blueprint.Item;
+ skinEditor.SelectedComponents.Clear();
+ skinEditor.SelectedComponents.Add(blueprint.Item);
+ });
+
+ AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault);
+
+ AddStep("hover first slider", () =>
+ {
+ InputManager.MoveMouseTo(
+ skinEditor.ChildrenOfType().First()
+ .ChildrenOfType>().First()
+ .ChildrenOfType>().First()
+ );
+ });
+
+ AddStep("adjust slider via keyboard", () => InputManager.Key(Key.Left));
+
+ AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default);
+ }
+
protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
}
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs
index 8f33f6fac5..8150252d45 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs
@@ -5,11 +5,13 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
+using osu.Framework.Timing;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Skinning.Editor;
+using osu.Game.Tests.Beatmaps;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay
@@ -22,6 +24,12 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
+ [Cached]
+ private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset());
+
+ [Cached]
+ private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());
+
[SetUpSteps]
public void SetUpSteps()
{
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs
index cdf349ff7f..ac5e408d90 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs
@@ -10,11 +10,13 @@ using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
+using osu.Framework.Timing;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
+using osu.Game.Tests.Beatmaps;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay
@@ -29,6 +31,12 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
+ [Cached]
+ private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset());
+
+ [Cached]
+ private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());
+
private IEnumerable hudOverlays => CreatedDrawables.OfType();
// best way to check without exposing.
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
index d614815316..8b420cebc8 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
@@ -70,6 +70,56 @@ namespace osu.Game.Tests.Visual.Gameplay
});
}
+ [Test]
+ public void TestSeekToGameplayStartFramesArriveAfterPlayerLoad()
+ {
+ const double gameplay_start = 10000;
+
+ loadSpectatingScreen();
+
+ start();
+
+ waitForPlayer();
+
+ sendFrames(startTime: gameplay_start);
+
+ AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
+ }
+
+ ///
+ /// Tests the same as but with the frames arriving just as is transitioning into existence.
+ ///
+ [Test]
+ public void TestSeekToGameplayStartFramesArriveAsPlayerLoaded()
+ {
+ const double gameplay_start = 10000;
+
+ loadSpectatingScreen();
+
+ start();
+
+ AddUntilStep("wait for player loader", () => (Stack.CurrentScreen as PlayerLoader)?.IsLoaded == true);
+
+ AddUntilStep("queue send frames on player load", () =>
+ {
+ var loadingPlayer = (Stack.CurrentScreen as PlayerLoader)?.CurrentPlayer;
+
+ if (loadingPlayer == null)
+ return false;
+
+ loadingPlayer.OnLoadComplete += _ =>
+ {
+ spectatorClient.SendFramesFromUser(streamingUser.Id, 10, gameplay_start);
+ };
+ return true;
+ });
+
+ waitForPlayer();
+
+ AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
+ AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
+ }
+
[Test]
public void TestFrameStarvationAndResume()
{
@@ -319,9 +369,9 @@ namespace osu.Game.Tests.Visual.Gameplay
private void checkPaused(bool state) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state);
- private void sendFrames(int count = 10)
+ private void sendFrames(int count = 10, double startTime = 0)
{
- AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count));
+ AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime));
}
private void loadSpectatingScreen()
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
new file mode 100644
index 0000000000..a374488306
--- /dev/null
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
@@ -0,0 +1,338 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Cursor;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Multiplayer.Countdown;
+using osu.Game.Online.Rooms;
+using osu.Game.Rulesets;
+using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
+using osu.Game.Tests.Resources;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Multiplayer
+{
+ public class TestSceneMatchStartControl : MultiplayerTestScene
+ {
+ private MatchStartControl control;
+ private BeatmapSetInfo importedSet;
+
+ private readonly Bindable selectedItem = new Bindable();
+
+ private BeatmapManager beatmaps;
+ private RulesetStore rulesets;
+
+ [BackgroundDependencyLoader]
+ private void load(GameHost host, AudioManager audio)
+ {
+ Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
+ Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default));
+ Dependencies.Cache(Realm);
+ }
+
+ [SetUp]
+ public new void Setup() => Schedule(() =>
+ {
+ AvailabilityTracker.SelectedItem.BindTo(selectedItem);
+
+ beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
+ importedSet = beatmaps.GetAllUsableBeatmapSets().First();
+ Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
+
+ selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
+ {
+ RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID
+ };
+
+ Child = new PopoverContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Child = control = new MatchStartControl
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(200, 50),
+ }
+ };
+ });
+
+ [Test]
+ public void TestStartWithCountdown()
+ {
+ ClickButtonWhenEnabled();
+ AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+ ClickButtonWhenEnabled();
+ AddStep("click the first countdown button", () =>
+ {
+ var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First();
+ InputManager.MoveMouseTo(popoverButton);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("countdown button not visible", () => !this.ChildrenOfType().Single().IsPresent);
+ AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown());
+ AddUntilStep("match started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.WaitingForLoad);
+ }
+
+ [Test]
+ public void TestCancelCountdown()
+ {
+ ClickButtonWhenEnabled();
+ AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+ ClickButtonWhenEnabled();
+ AddStep("click the first countdown button", () =>
+ {
+ var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First();
+ InputManager.MoveMouseTo(popoverButton);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ ClickButtonWhenEnabled();
+
+ AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown());
+ AddUntilStep("match not started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready);
+ }
+
+ [Test]
+ public void TestReadyAndUnReadyDuringCountdown()
+ {
+ AddStep("add second user as host", () =>
+ {
+ MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
+ MultiplayerClient.TransferHost(2);
+ });
+
+ AddStep("start with countdown", () => MultiplayerClient.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(2) }).WaitSafely());
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle);
+ }
+
+ [Test]
+ public void TestCountdownButtonEnablementAndVisibilityWhileSpectating()
+ {
+ AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating));
+ AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating);
+
+ AddAssert("countdown button is visible", () => this.ChildrenOfType().Single().IsPresent);
+ AddAssert("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value);
+
+ AddStep("add second user", () => MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }));
+ AddAssert("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value);
+
+ AddStep("set second user ready", () => MultiplayerClient.ChangeUserState(2, MultiplayerUserState.Ready));
+ AddAssert("countdown button enabled", () => this.ChildrenOfType().Single().Enabled.Value);
+ }
+
+ [Test]
+ public void TestSpectatingDuringCountdownWithNoReadyUsersCancelsCountdown()
+ {
+ ClickButtonWhenEnabled();
+ AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+ ClickButtonWhenEnabled();
+ AddStep("click the first countdown button", () =>
+ {
+ var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First();
+ InputManager.MoveMouseTo(popoverButton);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating));
+ AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating);
+
+ AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown());
+ AddUntilStep("match not started", () => MultiplayerClient.Room?.State == MultiplayerRoomState.Open);
+ }
+
+ [Test]
+ public void TestReadyButtonEnabledWhileSpectatingDuringCountdown()
+ {
+ AddStep("add second user", () => MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }));
+ AddStep("set second user ready", () => MultiplayerClient.ChangeUserState(2, MultiplayerUserState.Ready));
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+ ClickButtonWhenEnabled();
+ AddStep("click the first countdown button", () =>
+ {
+ var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First();
+ InputManager.MoveMouseTo(popoverButton);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating));
+ AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating);
+
+ AddAssert("ready button enabled", () => this.ChildrenOfType().Single().Enabled.Value);
+ }
+
+ [Test]
+ public void TestBecomeHostDuringCountdownAndReady()
+ {
+ AddStep("add second user as host", () =>
+ {
+ MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
+ MultiplayerClient.TransferHost(2);
+ });
+
+ AddStep("start countdown", () => MultiplayerClient.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(1) }).WaitSafely());
+ AddUntilStep("countdown started", () => MultiplayerClient.Room?.Countdown != null);
+
+ AddStep("transfer host to local user", () => MultiplayerClient.TransferHost(API.LocalUser.Value.OnlineID));
+ AddUntilStep("local user is host", () => MultiplayerClient.Room?.Host?.Equals(MultiplayerClient.LocalUser) == true);
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready);
+ AddAssert("countdown still active", () => MultiplayerClient.Room?.Countdown != null);
+ }
+
+ [Test]
+ public void TestDeletedBeatmapDisableReady()
+ {
+ OsuButton readyButton = null;
+
+ AddUntilStep("ensure ready button enabled", () =>
+ {
+ readyButton = control.ChildrenOfType().Single();
+ return readyButton.Enabled.Value;
+ });
+
+ AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
+ AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value);
+ AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet));
+ AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value);
+ }
+
+ [Test]
+ public void TestToggleStateWhenNotHost()
+ {
+ AddStep("add second user as host", () =>
+ {
+ MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
+ MultiplayerClient.TransferHost(2);
+ });
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle);
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void TestToggleStateWhenHost(bool allReady)
+ {
+ AddStep("setup", () =>
+ {
+ MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0);
+
+ if (!allReady)
+ MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
+ });
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
+
+ verifyGameplayStartFlow();
+ }
+
+ [Test]
+ public void TestBecomeHostWhileReady()
+ {
+ AddStep("add host", () =>
+ {
+ MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
+ MultiplayerClient.TransferHost(2);
+ });
+
+ ClickButtonWhenEnabled();
+ AddStep("make user host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0));
+
+ verifyGameplayStartFlow();
+ }
+
+ [Test]
+ public void TestLoseHostWhileReady()
+ {
+ AddStep("setup", () =>
+ {
+ MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0);
+ MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
+ });
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
+
+ AddStep("transfer host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[1].UserID ?? 0));
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("user is idle (match not started)", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle);
+ AddUntilStep("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value);
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void TestManyUsersChangingState(bool isHost)
+ {
+ const int users = 10;
+ AddStep("setup", () =>
+ {
+ MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0);
+ for (int i = 0; i < users; i++)
+ MultiplayerClient.AddUser(new APIUser { Id = i, Username = "Another user" });
+ });
+
+ if (!isHost)
+ AddStep("transfer host", () => MultiplayerClient.TransferHost(2));
+
+ ClickButtonWhenEnabled();
+
+ AddRepeatStep("change user ready state", () =>
+ {
+ MultiplayerClient.ChangeUserState(RNG.Next(0, users), RNG.NextBool() ? MultiplayerUserState.Ready : MultiplayerUserState.Idle);
+ }, 20);
+
+ AddRepeatStep("ready all users", () =>
+ {
+ var nextUnready = MultiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle);
+ if (nextUnready != null)
+ MultiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready);
+ }, users);
+ }
+
+ private void verifyGameplayStartFlow()
+ {
+ AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
+ ClickButtonWhenEnabled();
+ AddUntilStep("user waiting for load", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad);
+
+ AddStep("finish gameplay", () =>
+ {
+ MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.Loaded);
+ MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay);
+ });
+
+ AddUntilStep("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
index e38da96bd5..d0765fc4b3 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
@@ -41,6 +41,7 @@ using osu.Game.Screens.Ranking;
using osu.Game.Screens.Spectate;
using osu.Game.Tests.Resources;
using osuTK.Input;
+using ReadyButton = osu.Game.Screens.OnlinePlay.Components.ReadyButton;
namespace osu.Game.Tests.Visual.Multiplayer
{
@@ -424,7 +425,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("Beatmap doesn't match current item", () => Beatmap.Value.BeatmapInfo.OnlineID != multiplayerClient.Room?.Playlist.First().BeatmapID);
- AddStep("start match externally", () => multiplayerClient.StartMatch());
+ AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely());
AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player);
@@ -462,7 +463,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("Ruleset doesn't match current item", () => Ruleset.Value.OnlineID != multiplayerClient.Room?.Playlist.First().RulesetID);
- AddStep("start match externally", () => multiplayerClient.StartMatch());
+ AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely());
AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player);
@@ -500,7 +501,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("Mods don't match current item", () => !SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym)));
- AddStep("start match externally", () => multiplayerClient.StartMatch());
+ AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely());
AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player);
@@ -535,7 +536,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for spectating user state", () => multiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating);
- AddStep("start match externally", () => multiplayerClient.StartMatch());
+ AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely());
AddAssert("play not started", () => multiplayerComponents.IsCurrentScreen());
}
@@ -568,7 +569,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for spectating user state", () => multiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating);
- AddStep("start match externally", () => multiplayerClient.StartMatch());
+ AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely());
AddStep("restore beatmap", () =>
{
@@ -883,7 +884,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("start match by other user", () =>
{
multiplayerClient.ChangeUserState(1234, MultiplayerUserState.Ready);
- multiplayerClient.StartMatch();
+ multiplayerClient.StartMatch().WaitSafely();
});
AddUntilStep("wait for loading", () => multiplayerClient.Room?.State == MultiplayerRoomState.WaitingForLoad);
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
index 292319171d..8da077cd44 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
@@ -163,6 +163,25 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("second user crown visible", () => this.ChildrenOfType().ElementAt(1).ChildrenOfType().First().Alpha == 1);
}
+ [Test]
+ public void TestHostGetsPinnedToTop()
+ {
+ AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
+ {
+ Id = 3,
+ Username = "Second",
+ CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ }));
+
+ AddStep("make second user host", () => MultiplayerClient.TransferHost(3));
+ AddAssert("second user above first", () =>
+ {
+ var first = this.ChildrenOfType().ElementAt(0);
+ var second = this.ChildrenOfType().ElementAt(1);
+ return second.Y < first.Y;
+ });
+ }
+
[Test]
public void TestKickButtonOnlyPresentWhenHost()
{
@@ -202,9 +221,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestManyUsers()
{
+ const int users_count = 20;
+
AddStep("add many users", () =>
{
- for (int i = 0; i < 20; i++)
+ for (int i = 0; i < users_count; i++)
{
MultiplayerClient.AddUser(new APIUser
{
@@ -243,6 +264,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
}
});
+
+ AddRepeatStep("switch hosts", () => MultiplayerClient.TransferHost(RNG.Next(0, users_count)), 10);
+ AddStep("give host back", () => MultiplayerClient.TransferHost(API.LocalUser.Value.Id));
}
[Test]
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
index 6b5b45b73e..cbd8b472b8 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
@@ -11,6 +11,8 @@ using osu.Framework.Graphics;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
@@ -129,6 +131,25 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("item 1 not in lists", () => !inHistoryList(0) && !inQueueList(0));
}
+ [Test]
+ public void TestQueueTabCount()
+ {
+ assertQueueTabCount(1);
+
+ addItemStep();
+ assertQueueTabCount(2);
+
+ addItemStep();
+ assertQueueTabCount(3);
+
+ AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely());
+ assertQueueTabCount(2);
+
+ AddStep("leave room", () => RoomManager.PartRoom());
+ AddUntilStep("wait for room part", () => !RoomJoined);
+ assertQueueTabCount(0);
+ }
+
[Ignore("Expired items are initially removed from the room.")]
[Test]
public void TestJoinRoomWithMixedItemsAddedInCorrectLists()
@@ -213,6 +234,17 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
}
+ private void assertQueueTabCount(int count)
+ {
+ string queueTabText = count > 0 ? $"Queue ({count})" : "Queue";
+ AddUntilStep($"Queue tab shows \"{queueTabText}\"", () =>
+ {
+ return this.ChildrenOfType.OsuTabItem>()
+ .Single(t => t.Value == MultiplayerPlaylistDisplayMode.Queue)
+ .ChildrenOfType().Single().Text == queueTabText;
+ });
+ }
+
private void changeDisplayModeStep(MultiplayerPlaylistDisplayMode mode) => AddStep($"change list to {mode}", () => list.DisplayMode.Value = mode);
private bool inQueueList(int playlistItemId)
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
deleted file mode 100644
index c86d5e482a..0000000000
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
+++ /dev/null
@@ -1,223 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.Linq;
-using System.Threading.Tasks;
-using NUnit.Framework;
-using osu.Framework.Allocation;
-using osu.Framework.Audio;
-using osu.Framework.Bindables;
-using osu.Framework.Extensions;
-using osu.Framework.Graphics;
-using osu.Framework.Platform;
-using osu.Framework.Testing;
-using osu.Framework.Utils;
-using osu.Game.Beatmaps;
-using osu.Game.Graphics.UserInterface;
-using osu.Game.Online.API.Requests.Responses;
-using osu.Game.Online.Multiplayer;
-using osu.Game.Online.Rooms;
-using osu.Game.Rulesets;
-using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
-using osu.Game.Tests.Resources;
-using osuTK;
-
-namespace osu.Game.Tests.Visual.Multiplayer
-{
- public class TestSceneMultiplayerReadyButton : MultiplayerTestScene
- {
- private MultiplayerReadyButton button;
- private BeatmapSetInfo importedSet;
-
- private readonly Bindable selectedItem = new Bindable();
-
- private BeatmapManager beatmaps;
- private RulesetStore rulesets;
-
- private IDisposable readyClickOperation;
-
- [BackgroundDependencyLoader]
- private void load(GameHost host, AudioManager audio)
- {
- Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
- Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default));
- Dependencies.Cache(Realm);
- }
-
- [SetUp]
- public new void Setup() => Schedule(() =>
- {
- AvailabilityTracker.SelectedItem.BindTo(selectedItem);
-
- beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
- importedSet = beatmaps.GetAllUsableBeatmapSets().First();
- Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
-
- selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
- {
- RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID
- };
-
- if (button != null)
- Remove(button);
-
- Add(button = new MultiplayerReadyButton
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(200, 50),
- OnReadyClick = () =>
- {
- readyClickOperation = OngoingOperationTracker.BeginOperation();
-
- Task.Run(async () =>
- {
- if (MultiplayerClient.IsHost && MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready)
- {
- await MultiplayerClient.StartMatch();
- return;
- }
-
- await MultiplayerClient.ToggleReady();
-
- readyClickOperation.Dispose();
- });
- }
- });
- });
-
- [Test]
- public void TestDeletedBeatmapDisableReady()
- {
- OsuButton readyButton = null;
-
- AddUntilStep("ensure ready button enabled", () =>
- {
- readyButton = button.ChildrenOfType().Single();
- return readyButton.Enabled.Value;
- });
-
- AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
- AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value);
- AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet));
- AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value);
- }
-
- [Test]
- public void TestToggleStateWhenNotHost()
- {
- AddStep("add second user as host", () =>
- {
- MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
- MultiplayerClient.TransferHost(2);
- });
-
- ClickButtonWhenEnabled();
- AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
-
- ClickButtonWhenEnabled();
- AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle);
- }
-
- [TestCase(true)]
- [TestCase(false)]
- public void TestToggleStateWhenHost(bool allReady)
- {
- AddStep("setup", () =>
- {
- MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0);
-
- if (!allReady)
- MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
- });
-
- ClickButtonWhenEnabled();
- AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
-
- verifyGameplayStartFlow();
- }
-
- [Test]
- public void TestBecomeHostWhileReady()
- {
- AddStep("add host", () =>
- {
- MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
- MultiplayerClient.TransferHost(2);
- });
-
- ClickButtonWhenEnabled();
- AddStep("make user host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0));
-
- verifyGameplayStartFlow();
- }
-
- [Test]
- public void TestLoseHostWhileReady()
- {
- AddStep("setup", () =>
- {
- MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0);
- MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" });
- });
-
- ClickButtonWhenEnabled();
- AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
-
- AddStep("transfer host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[1].UserID ?? 0));
-
- ClickButtonWhenEnabled();
- AddUntilStep("user is idle (match not started)", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle);
- AddAssert("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value);
- }
-
- [TestCase(true)]
- [TestCase(false)]
- public void TestManyUsersChangingState(bool isHost)
- {
- const int users = 10;
- AddStep("setup", () =>
- {
- MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0);
- for (int i = 0; i < users; i++)
- MultiplayerClient.AddUser(new APIUser { Id = i, Username = "Another user" });
- });
-
- if (!isHost)
- AddStep("transfer host", () => MultiplayerClient.TransferHost(2));
-
- ClickButtonWhenEnabled();
-
- AddRepeatStep("change user ready state", () =>
- {
- MultiplayerClient.ChangeUserState(RNG.Next(0, users), RNG.NextBool() ? MultiplayerUserState.Ready : MultiplayerUserState.Idle);
- }, 20);
-
- AddRepeatStep("ready all users", () =>
- {
- var nextUnready = MultiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle);
- if (nextUnready != null)
- MultiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready);
- }, users);
- }
-
- private void verifyGameplayStartFlow()
- {
- AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready);
- ClickButtonWhenEnabled();
- AddUntilStep("user waiting for load", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad);
-
- AddAssert("ready button disabled", () => !button.ChildrenOfType().Single().Enabled.Value);
- AddStep("transitioned to gameplay", () => readyClickOperation.Dispose());
-
- AddStep("finish gameplay", () =>
- {
- MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.Loaded);
- MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay);
- });
-
- AddUntilStep("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value);
- }
- }
-}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
index 956d40a456..13917f4eb0 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
@@ -1,9 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System;
using System.Linq;
-using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@@ -11,6 +9,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
@@ -28,7 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public class TestSceneMultiplayerSpectateButton : MultiplayerTestScene
{
private MultiplayerSpectateButton spectateButton;
- private MultiplayerReadyButton readyButton;
+ private MatchStartControl startControl;
private readonly Bindable selectedItem = new Bindable();
@@ -36,8 +35,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
private BeatmapManager beatmaps;
private RulesetStore rulesets;
- private IDisposable readyClickOperation;
-
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
@@ -60,49 +57,26 @@ namespace osu.Game.Tests.Visual.Multiplayer
RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID,
};
- Child = new FillFlowContainer
+ Child = new PopoverContainer
{
- AutoSizeAxes = Axes.Both,
- Direction = FillDirection.Vertical,
- Children = new Drawable[]
+ RelativeSizeAxes = Axes.Both,
+ Child = new FillFlowContainer
{
- spectateButton = new MultiplayerSpectateButton
+ AutoSizeAxes = Axes.Both,
+ Direction = FillDirection.Vertical,
+ Children = new Drawable[]
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(200, 50),
- OnSpectateClick = () =>
+ spectateButton = new MultiplayerSpectateButton
{
- readyClickOperation = OngoingOperationTracker.BeginOperation();
-
- Task.Run(async () =>
- {
- await MultiplayerClient.ToggleSpectate();
- readyClickOperation.Dispose();
- });
- }
- },
- readyButton = new MultiplayerReadyButton
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(200, 50),
- OnReadyClick = () =>
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(200, 50),
+ },
+ startControl = new MatchStartControl
{
- readyClickOperation = OngoingOperationTracker.BeginOperation();
-
- Task.Run(async () =>
- {
- if (MultiplayerClient.IsHost && MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready)
- {
- await MultiplayerClient.StartMatch();
- return;
- }
-
- await MultiplayerClient.ToggleReady();
-
- readyClickOperation.Dispose();
- });
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(200, 50),
}
}
}
@@ -172,6 +146,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
=> AddUntilStep($"spectate button {(shouldBeEnabled ? "is" : "is not")} enabled", () => spectateButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled);
private void assertReadyButtonEnablement(bool shouldBeEnabled)
- => AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled);
+ => AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => startControl.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled);
}
}
diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
index f2e6aa1e16..394976eb43 100644
--- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
+++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.UserInterface;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
@@ -14,6 +15,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
+using osu.Game.Overlays.Settings;
using osu.Game.Overlays.Toolbar;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
@@ -21,10 +23,12 @@ using osu.Game.Scoring;
using osu.Game.Screens.Menu;
using osu.Game.Screens.OnlinePlay.Lounge;
using osu.Game.Screens.Play;
+using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Screens.Select.Options;
+using osu.Game.Skinning.Editor;
using osu.Game.Tests.Beatmaps.IO;
using osuTK;
using osuTK.Input;
@@ -66,6 +70,73 @@ namespace osu.Game.Tests.Visual.Navigation
AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
}
+ [Test]
+ public void TestEditComponentDuringGameplay()
+ {
+ Screens.Select.SongSelect songSelect = null;
+ PushAndConfirm(() => songSelect = new TestPlaySongSelect());
+ AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
+
+ AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
+
+ AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
+
+ SkinEditor skinEditor = null;
+
+ AddStep("open skin editor", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.PressKey(Key.ShiftLeft);
+ InputManager.Key(Key.S);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ InputManager.ReleaseKey(Key.ShiftLeft);
+ });
+
+ AddUntilStep("get skin editor", () => (skinEditor = Game.ChildrenOfType().FirstOrDefault()) != null);
+
+ AddStep("Click gameplay scene button", () =>
+ {
+ skinEditor.ChildrenOfType().First(b => b.Text == "Gameplay").TriggerClick();
+ });
+
+ AddUntilStep("wait for player", () =>
+ {
+ // dismiss any notifications that may appear (ie. muted notification).
+ clickMouseInCentre();
+ return Game.ScreenStack.CurrentScreen is Player;
+ });
+
+ BarHitErrorMeter hitErrorMeter = null;
+
+ AddUntilStep("select bar hit error blueprint", () =>
+ {
+ var blueprint = skinEditor.ChildrenOfType().FirstOrDefault(b => b.Item is BarHitErrorMeter);
+
+ if (blueprint == null)
+ return false;
+
+ hitErrorMeter = (BarHitErrorMeter)blueprint.Item;
+ skinEditor.SelectedComponents.Clear();
+ skinEditor.SelectedComponents.Add(blueprint.Item);
+ return true;
+ });
+
+ AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault);
+
+ AddStep("hover first slider", () =>
+ {
+ InputManager.MoveMouseTo(
+ skinEditor.ChildrenOfType().First()
+ .ChildrenOfType>().First()
+ .ChildrenOfType>().First()
+ );
+ });
+
+ AddStep("adjust slider via keyboard", () => InputManager.Key(Key.Left));
+
+ AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default);
+ }
+
[Test]
public void TestRetryCountIncrements()
{
@@ -120,7 +191,8 @@ namespace osu.Game.Tests.Visual.Navigation
AddStep("press back button", () => Game.ChildrenOfType().First().Action());
- AddStep("show local scores", () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local));
+ AddStep("show local scores",
+ () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local));
AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null);
@@ -152,7 +224,8 @@ namespace osu.Game.Tests.Visual.Navigation
AddStep("press back button", () => Game.ChildrenOfType().First().Action());
- AddStep("show local scores", () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local));
+ AddStep("show local scores",
+ () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local));
AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null);
@@ -262,6 +335,20 @@ namespace osu.Game.Tests.Visual.Navigation
exitViaBackButtonAndConfirm();
}
+ [Test]
+ public void TestModsResetOnEnteringMultiplayer()
+ {
+ var osuAutomationMod = new OsuModAutoplay();
+
+ AddStep("Enable autoplay", () => { Game.SelectedMods.Value = new[] { osuAutomationMod }; });
+
+ PushAndConfirm(() => new Screens.OnlinePlay.Multiplayer.Multiplayer());
+ AddUntilStep("Mods are removed", () => Game.SelectedMods.Value.Count == 0);
+
+ AddStep("Return to menu", () => Game.ScreenStack.CurrentScreen.Exit());
+ AddUntilStep("Mods are restored", () => Game.SelectedMods.Value.Contains(osuAutomationMod));
+ }
+
[Test]
public void TestExitMultiWithEscape()
{
diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs
new file mode 100644
index 0000000000..af419c8b91
--- /dev/null
+++ b/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs
@@ -0,0 +1,163 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Chat;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Chat.ChannelList;
+using osuTK;
+
+namespace osu.Game.Tests.Visual.Online
+{
+ [TestFixture]
+ public class TestSceneChannelListItem : OsuTestScene
+ {
+ [Cached]
+ private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink);
+
+ [Cached]
+ private readonly Bindable selected = new Bindable();
+
+ private static readonly List channels = new List
+ {
+ createPublicChannel("#public-channel"),
+ createPublicChannel("#public-channel-long-name"),
+ createPrivateChannel("test user", 2),
+ createPrivateChannel("test user long name", 3),
+ };
+
+ private readonly Dictionary channelMap = new Dictionary();
+
+ private FillFlowContainer flow;
+ private OsuSpriteText selectedText;
+ private OsuSpriteText leaveText;
+
+ [SetUp]
+ public void SetUp()
+ {
+ Schedule(() =>
+ {
+ foreach (var item in channelMap.Values)
+ item.Expire();
+
+ channelMap.Clear();
+
+ Child = new FillFlowContainer
+ {
+ Direction = FillDirection.Vertical,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ AutoSizeAxes = Axes.Both,
+ Spacing = new Vector2(10),
+ Children = new Drawable[]
+ {
+ selectedText = new OsuSpriteText
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ },
+ leaveText = new OsuSpriteText
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ Height = 16,
+ AlwaysPresent = true,
+ },
+ new Container
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ AutoSizeAxes = Axes.Y,
+ Width = 190,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = colourProvider.Background6,
+ },
+ flow = new FillFlowContainer
+ {
+ Direction = FillDirection.Vertical,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ },
+ },
+ },
+ },
+ };
+
+ selected.BindValueChanged(change =>
+ {
+ selectedText.Text = $"Selected Channel: {change.NewValue?.Name ?? "[null]"}";
+ }, true);
+
+ foreach (var channel in channels)
+ {
+ var item = new ChannelListItem(channel);
+ flow.Add(item);
+ channelMap.Add(channel, item);
+ item.OnRequestSelect += c => selected.Value = c;
+ item.OnRequestLeave += leaveChannel;
+ }
+ });
+ }
+
+ [Test]
+ public void TestVisual()
+ {
+ AddStep("Select second item", () => selected.Value = channels.Skip(1).First());
+
+ AddStep("Unread Selected", () =>
+ {
+ if (selected.Value != null)
+ channelMap[selected.Value].Unread.Value = true;
+ });
+
+ AddStep("Read Selected", () =>
+ {
+ if (selected.Value != null)
+ channelMap[selected.Value].Unread.Value = false;
+ });
+
+ AddStep("Add Mention Selected", () =>
+ {
+ if (selected.Value != null)
+ channelMap[selected.Value].Mentions.Value++;
+ });
+
+ AddStep("Add 98 Mentions Selected", () =>
+ {
+ if (selected.Value != null)
+ channelMap[selected.Value].Mentions.Value += 98;
+ });
+
+ AddStep("Clear Mentions Selected", () =>
+ {
+ if (selected.Value != null)
+ channelMap[selected.Value].Mentions.Value = 0;
+ });
+ }
+
+ private void leaveChannel(Channel channel)
+ {
+ leaveText.Text = $"OnRequestLeave: {channel.Name}";
+ leaveText.FadeOutFromOne(1000, Easing.InQuint);
+ }
+
+ private static Channel createPublicChannel(string name) =>
+ new Channel { Name = name, Type = ChannelType.Public, Id = 1234 };
+
+ private static Channel createPrivateChannel(string username, int id)
+ => new Channel(new APIUser { Id = id, Username = username });
+ }
+}
diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs
index 00ff6a9576..80a6698761 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs
@@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestHideOverlay()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
+
AddAssert("Chat overlay is visible", () => chatOverlay.State.Value == Visibility.Visible);
AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible);
@@ -134,6 +136,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestChannelSelection()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible);
AddStep("Setup get message response", () => onGetMessages = channel =>
{
@@ -169,6 +172,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestSearchInSelector()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Search for 'no. 2'", () => chatOverlay.ChildrenOfType().First().Text = "no. 2");
AddUntilStep("Only channel 2 visible", () =>
{
@@ -180,6 +184,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestChannelShortcutKeys()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join channels", () => channels.ForEach(channel => channelManager.JoinChannel(channel)));
AddStep("Close channel selector", () => InputManager.Key(Key.Escape));
AddUntilStep("Wait for close", () => chatOverlay.SelectionOverlayState == Visibility.Hidden);
@@ -199,6 +204,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestCloseChannelBehaviour()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddUntilStep("Join until dropdown has channels", () =>
{
if (visibleChannels.Count() < joinedChannels.Count())
@@ -269,6 +275,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestChannelCloseButton()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join 2 channels", () =>
{
channelManager.JoinChannel(channel1);
@@ -289,6 +296,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestCloseTabShortcut()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join 2 channels", () =>
{
channelManager.JoinChannel(channel1);
@@ -314,6 +322,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestNewTabShortcut()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join 2 channels", () =>
{
channelManager.JoinChannel(channel1);
@@ -330,6 +339,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestRestoreTabShortcut()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join 3 channels", () =>
{
channelManager.JoinChannel(channel1);
@@ -375,6 +385,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestChatCommand()
{
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join channel 1", () => channelManager.JoinChannel(channel1));
AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1]));
@@ -398,6 +409,8 @@ namespace osu.Game.Tests.Visual.Online
{
Channel multiplayerChannel = null;
+ AddStep("open chat overlay", () => chatOverlay.Show());
+
AddStep("join multiplayer channel", () => channelManager.JoinChannel(multiplayerChannel = new Channel(new APIUser())
{
Name = "#mp_1",
@@ -417,6 +430,7 @@ namespace osu.Game.Tests.Visual.Online
{
Message message = null;
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join channel 1", () => channelManager.JoinChannel(channel1));
AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1]));
@@ -443,6 +457,7 @@ namespace osu.Game.Tests.Visual.Online
{
Message message = null;
+ AddStep("Open chat overlay", () => chatOverlay.Show());
AddStep("Join channel 1", () => channelManager.JoinChannel(channel1));
AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1]));
@@ -471,6 +486,8 @@ namespace osu.Game.Tests.Visual.Online
{
Message message = null;
+ AddStep("Open chat overlay", () => chatOverlay.Show());
+
AddStep("Join channel 1", () => channelManager.JoinChannel(channel1));
AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1]));
@@ -496,14 +513,11 @@ namespace osu.Game.Tests.Visual.Online
}
[Test]
- public void TestHighlightWhileChatHidden()
+ public void TestHighlightWhileChatNeverOpen()
{
Message message = null;
- AddStep("hide chat", () => chatOverlay.Hide());
-
AddStep("Join channel 1", () => channelManager.JoinChannel(channel1));
- AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1]));
AddStep("Send message in channel 1", () =>
{
@@ -520,7 +534,7 @@ namespace osu.Game.Tests.Visual.Online
});
});
- AddStep("Highlight message and show chat", () =>
+ AddStep("Highlight message and open chat", () =>
{
chatOverlay.HighlightMessage(message, channel1);
chatOverlay.Show();
@@ -571,8 +585,6 @@ namespace osu.Game.Tests.Visual.Online
ChannelManager,
ChatOverlay = new TestChatOverlay { RelativeSizeAxes = Axes.Both, },
};
-
- ChatOverlay.Show();
}
}
diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs
index 8e53c7c402..6bd6115e68 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs
@@ -40,6 +40,10 @@ namespace osu.Game.Tests.Visual.UserInterface
{
Text = @"You're a fake!",
},
+ new PopupDialogDangerousButton
+ {
+ Text = @"Careful with this one..",
+ },
};
}
}
diff --git a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs
index 93cfa9634e..f0aa857769 100644
--- a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs
+++ b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs
@@ -26,7 +26,7 @@ namespace osu.Game.Tournament.Screens.Setup
dropdown.Current.BindValueChanged(v => Button.Enabled.Value = v.NewValue != startupTournament, true);
Action = () => game.GracefullyExit();
- folderButton.Action = storage.PresentExternally;
+ folderButton.Action = () => storage.PresentExternally();
ButtonText = "Close osu!";
}
diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs
index c6f69286cd..f90208d0c0 100644
--- a/osu.Game/Beatmaps/BeatmapInfo.cs
+++ b/osu.Game/Beatmaps/BeatmapInfo.cs
@@ -9,6 +9,7 @@ using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Models;
using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Overlays.BeatmapSet.Scores;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using Realms;
@@ -169,7 +170,12 @@ namespace osu.Game.Beatmaps
[Ignored]
public APIBeatmap? OnlineInfo { get; set; }
+ ///
+ /// The maximum achievable combo on this beatmap, populated for online info purposes only.
+ /// Todo: This should never be used nor exist, but is still relied on in since can't be used yet. For now this is obsoleted until it is removed.
+ ///
[Ignored]
+ [Obsolete("Use ScoreManager.GetMaximumAchievableComboAsync instead.")]
public int? MaxCombo { get; set; }
[Ignored]
diff --git a/osu.Game/Beatmaps/EFBeatmapInfo.cs b/osu.Game/Beatmaps/EFBeatmapInfo.cs
index 8daeaa7030..740adfd1c7 100644
--- a/osu.Game/Beatmaps/EFBeatmapInfo.cs
+++ b/osu.Game/Beatmaps/EFBeatmapInfo.cs
@@ -53,9 +53,6 @@ namespace osu.Game.Beatmaps
[NotMapped]
public APIBeatmap OnlineInfo { get; set; }
- [NotMapped]
- public int? MaxCombo { get; set; }
-
///
/// The playable length in milliseconds of this beatmap.
///
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
index e2a043490f..79d8bd3bb3 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
@@ -19,6 +19,11 @@ namespace osu.Game.Beatmaps.Formats
{
public class LegacyBeatmapDecoder : LegacyDecoder
{
+ ///
+ /// An offset which needs to be applied to old beatmaps (v4 and lower) to correct timing changes that were applied at a game client level.
+ ///
+ public const int EARLY_VERSION_TIMING_OFFSET = 24;
+
internal static RulesetStore RulesetStore;
private Beatmap beatmap;
@@ -50,8 +55,7 @@ namespace osu.Game.Beatmaps.Formats
RulesetStore = new AssemblyRulesetStore();
}
- // BeatmapVersion 4 and lower had an incorrect offset (stable has this set as 24ms off)
- offset = FormatVersion < 5 ? 24 : 0;
+ offset = FormatVersion < 5 ? EARLY_VERSION_TIMING_OFFSET : 0;
}
protected override Beatmap CreateTemplateObject()
diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
index d3f356bb24..7d28208157 100644
--- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs
+++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
@@ -225,7 +225,7 @@ namespace osu.Game.Beatmaps
{
try
{
- return new LegacyBeatmapSkin(BeatmapInfo, resources.Files, resources);
+ return new LegacyBeatmapSkin(BeatmapInfo, resources);
}
catch (Exception e)
{
diff --git a/osu.Game/Database/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs
index c9deee19fe..cbf5c5ffe9 100644
--- a/osu.Game/Database/EFToRealmMigrator.cs
+++ b/osu.Game/Database/EFToRealmMigrator.cs
@@ -295,7 +295,6 @@ namespace osu.Game.Database
TimelineZoom = beatmap.TimelineZoom,
Countdown = beatmap.Countdown,
CountdownOffset = beatmap.CountdownOffset,
- MaxCombo = beatmap.MaxCombo,
Bookmarks = beatmap.Bookmarks,
BeatmapSet = realmBeatmapSet,
};
diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index f0d4011ab8..8574002436 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+#nullable enable
+
using System;
using System.Collections.Generic;
using System.ComponentModel;
@@ -17,6 +19,7 @@ using osu.Framework.Input.Bindings;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
+using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Input.Bindings;
@@ -28,8 +31,6 @@ using osu.Game.Stores;
using Realms;
using Realms.Exceptions;
-#nullable enable
-
namespace osu.Game.Database
{
///
@@ -46,6 +47,8 @@ namespace osu.Game.Database
private readonly IDatabaseContextFactory? efContextFactory;
+ private readonly SynchronizationContext? updateThreadSyncContext;
+
///
/// Version history:
/// 6 ~2021-10-18 First tracked version.
@@ -143,12 +146,15 @@ namespace osu.Game.Database
///
/// The game storage which will be used to create the realm backing file.
/// The filename to use for the realm backing file. A ".realm" extension will be added automatically if not specified.
+ /// The game update thread, used to post realm operations into a thread-safe context.
/// An EF factory used only for migration purposes.
- public RealmAccess(Storage storage, string filename, IDatabaseContextFactory? efContextFactory = null)
+ public RealmAccess(Storage storage, string filename, GameThread? updateThread = null, IDatabaseContextFactory? efContextFactory = null)
{
this.storage = storage;
this.efContextFactory = efContextFactory;
+ updateThreadSyncContext = updateThread?.SynchronizationContext ?? SynchronizationContext.Current;
+
Filename = filename;
if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal))
@@ -379,9 +385,6 @@ namespace osu.Game.Database
public IDisposable RegisterForNotifications(Func> query, NotificationCallbackDelegate callback)
where T : RealmObjectBase
{
- if (!ThreadSafety.IsUpdateThread)
- throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread.");
-
lock (realmLock)
{
Func action = realm => query(realm).QueryAsyncWithNotifications(callback);
@@ -459,23 +462,24 @@ namespace osu.Game.Database
/// An which should be disposed to unsubscribe any inner subscription.
public IDisposable RegisterCustomSubscription(Func action)
{
- if (!ThreadSafety.IsUpdateThread)
- throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread.");
-
- var syncContext = SynchronizationContext.Current;
+ if (updateThreadSyncContext == null)
+ throw new InvalidOperationException("Attempted to register a realm subscription before update thread registration.");
total_subscriptions.Value++;
- registerSubscription(action);
+ if (ThreadSafety.IsUpdateThread)
+ updateThreadSyncContext.Send(_ => registerSubscription(action), null);
+ else
+ updateThreadSyncContext.Post(_ => registerSubscription(action), null);
// This token is returned to the consumer.
// When disposed, it will cause the registration to be permanently ceased (unsubscribed with realm and unregistered by this class).
return new InvokeOnDisposal(() =>
{
if (ThreadSafety.IsUpdateThread)
- syncContext.Send(_ => unsubscribe(), null);
+ updateThreadSyncContext.Send(_ => unsubscribe(), null);
else
- syncContext.Post(_ => unsubscribe(), null);
+ updateThreadSyncContext.Post(_ => unsubscribe(), null);
void unsubscribe()
{
diff --git a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs
index 999dd183aa..b2f08eee0a 100644
--- a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs
+++ b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs
@@ -28,6 +28,14 @@ namespace osu.Game.Graphics.Containers
///
protected virtual bool AllowMultipleFires => false;
+ ///
+ /// Specify a custom activation delay, overriding the game-wide user setting.
+ ///
+ ///
+ /// This should be used in special cases where we want to be extra sure the user knows what they are doing. An example is when changes would be lost.
+ ///
+ protected virtual double? HoldActivationDelay => null;
+
public Bindable Progress = new BindableDouble();
private Bindable holdActivationDelay;
@@ -35,7 +43,9 @@ namespace osu.Game.Graphics.Containers
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
- holdActivationDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay);
+ holdActivationDelay = HoldActivationDelay != null
+ ? new Bindable(HoldActivationDelay.Value)
+ : config.GetBindable(OsuSetting.UIHoldActivationDelay);
}
protected void BeginConfirm()
diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs
index 2f9e4dae51..ad69ec4078 100644
--- a/osu.Game/Graphics/UserInterface/DialogButton.cs
+++ b/osu.Game/Graphics/UserInterface/DialogButton.cs
@@ -45,8 +45,9 @@ namespace osu.Game.Graphics.UserInterface
}
}
+ protected readonly Container ColourContainer;
+
private readonly Container backgroundContainer;
- private readonly Container colourContainer;
private readonly Container glowContainer;
private readonly Box leftGlow;
private readonly Box centerGlow;
@@ -113,7 +114,7 @@ namespace osu.Game.Graphics.UserInterface
Masking = true,
Children = new Drawable[]
{
- colourContainer = new Container
+ ColourContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Origin = Anchor.Centre,
@@ -182,7 +183,7 @@ namespace osu.Game.Graphics.UserInterface
{
buttonColour = value;
updateGlow();
- colourContainer.Colour = value;
+ ColourContainer.Colour = value;
}
}
@@ -230,11 +231,11 @@ namespace osu.Game.Graphics.UserInterface
Alpha = 0.05f
};
- colourContainer.Add(flash);
+ ColourContainer.Add(flash);
flash.FadeOutFromOne(100).Expire();
clickAnimating = true;
- colourContainer.ResizeWidthTo(colourContainer.Width * 1.05f, 100, Easing.OutQuint)
+ ColourContainer.ResizeWidthTo(ColourContainer.Width * 1.05f, 100, Easing.OutQuint)
.OnComplete(_ =>
{
clickAnimating = false;
@@ -246,14 +247,14 @@ namespace osu.Game.Graphics.UserInterface
protected override bool OnMouseDown(MouseDownEvent e)
{
- colourContainer.ResizeWidthTo(hover_width * 0.98f, click_duration * 4, Easing.OutQuad);
+ ColourContainer.ResizeWidthTo(hover_width * 0.98f, click_duration * 4, Easing.OutQuad);
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
if (State == SelectionState.Selected)
- colourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In);
+ ColourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In);
base.OnMouseUp(e);
}
@@ -279,12 +280,12 @@ namespace osu.Game.Graphics.UserInterface
if (newState == SelectionState.Selected)
{
spriteText.TransformSpacingTo(hoverSpacing, hover_duration, Easing.OutElastic);
- colourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic);
+ ColourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic);
glowContainer.FadeIn(hover_duration, Easing.OutQuint);
}
else
{
- colourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic);
+ ColourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic);
spriteText.TransformSpacingTo(Vector2.Zero, hover_duration, Easing.OutElastic);
glowContainer.FadeOut(hover_duration, Easing.OutQuint);
}
diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs
index 6f0f898de3..a6605de1d2 100644
--- a/osu.Game/IO/WrappedStorage.cs
+++ b/osu.Game/IO/WrappedStorage.cs
@@ -70,9 +70,9 @@ namespace osu.Game.IO
public override Stream GetStream(string path, FileAccess access = FileAccess.Read, FileMode mode = FileMode.OpenOrCreate) =>
UnderlyingStorage.GetStream(MutatePath(path), access, mode);
- public override void OpenFileExternally(string filename) => UnderlyingStorage.OpenFileExternally(MutatePath(filename));
+ public override bool OpenFileExternally(string filename) => UnderlyingStorage.OpenFileExternally(MutatePath(filename));
- public override void PresentFileExternally(string filename) => UnderlyingStorage.PresentFileExternally(MutatePath(filename));
+ public override bool PresentFileExternally(string filename) => UnderlyingStorage.PresentFileExternally(MutatePath(filename));
public override Storage GetStorageForDirectory(string path)
{
diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs
new file mode 100644
index 0000000000..b067f3b235
--- /dev/null
+++ b/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs
@@ -0,0 +1,22 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using MessagePack;
+
+namespace osu.Game.Online.Multiplayer.Countdown
+{
+ ///
+ /// Indicates a change to the 's countdown.
+ ///
+ [MessagePackObject]
+ public class CountdownChangedEvent : MatchServerEvent
+ {
+ ///
+ /// The new countdown.
+ ///
+ [Key(0)]
+ public MultiplayerCountdown? Countdown { get; set; }
+ }
+}
diff --git a/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs
new file mode 100644
index 0000000000..08eab26090
--- /dev/null
+++ b/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs
@@ -0,0 +1,23 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using MessagePack;
+
+#nullable enable
+
+namespace osu.Game.Online.Multiplayer.Countdown
+{
+ ///
+ /// A request for a countdown to start the match.
+ ///
+ [MessagePackObject]
+ public class StartMatchCountdownRequest : MatchUserRequest
+ {
+ ///
+ /// How long the countdown should last.
+ ///
+ [Key(0)]
+ public TimeSpan Duration { get; set; }
+ }
+}
diff --git a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs
new file mode 100644
index 0000000000..20a0e32734
--- /dev/null
+++ b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs
@@ -0,0 +1,17 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using MessagePack;
+
+namespace osu.Game.Online.Multiplayer.Countdown
+{
+ ///
+ /// Request to stop the current countdown.
+ ///
+ [MessagePackObject]
+ public class StopCountdownRequest : MatchUserRequest
+ {
+ }
+}
diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs
index 891fb2cc3b..4ce55e424d 100644
--- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs
+++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs
@@ -1,8 +1,11 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+#nullable enable
+
using System;
using MessagePack;
+using osu.Game.Online.Multiplayer.Countdown;
namespace osu.Game.Online.Multiplayer
{
@@ -11,6 +14,8 @@ namespace osu.Game.Online.Multiplayer
///
[Serializable]
[MessagePackObject]
+ // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types.
+ [Union(0, typeof(CountdownChangedEvent))]
public abstract class MatchServerEvent
{
}
diff --git a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs
new file mode 100644
index 0000000000..6c1cdd97d3
--- /dev/null
+++ b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs
@@ -0,0 +1,17 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using MessagePack;
+
+namespace osu.Game.Online.Multiplayer
+{
+ ///
+ /// A which will start the match after ending.
+ ///
+ [MessagePackObject]
+ public class MatchStartCountdown : MultiplayerCountdown
+ {
+ }
+}
diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs
index 9c3b07049c..a26a2b3fc2 100644
--- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs
+++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs
@@ -7,6 +7,7 @@ using MessagePack;
namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus
{
+ [MessagePackObject]
public class ChangeTeamRequest : MatchUserRequest
{
[Key(0)]
diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs
index 8c6809e7f3..888b55e428 100644
--- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs
+++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs
@@ -3,6 +3,7 @@
using System;
using MessagePack;
+using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
namespace osu.Game.Online.Multiplayer
@@ -12,7 +13,10 @@ namespace osu.Game.Online.Multiplayer
///
[Serializable]
[MessagePackObject]
- [Union(0, typeof(ChangeTeamRequest))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types.
+ // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types.
+ [Union(0, typeof(ChangeTeamRequest))]
+ [Union(1, typeof(StartMatchCountdownRequest))]
+ [Union(2, typeof(StopCountdownRequest))]
public abstract class MatchUserRequest
{
}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
index a56cc7f8d6..faa995ed19 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
@@ -16,6 +16,7 @@ using osu.Framework.Logging;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Rooms;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Rulesets;
@@ -170,6 +171,8 @@ namespace osu.Game.Online.Multiplayer
Room = joinedRoom;
APIRoom = room;
+ Debug.Assert(joinedRoom.Playlist.Count > 0);
+
APIRoom.Playlist.Clear();
APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem));
@@ -534,7 +537,24 @@ namespace osu.Game.Online.Multiplayer
public Task MatchEvent(MatchServerEvent e)
{
- // not used by any match types just yet.
+ if (Room == null)
+ return Task.CompletedTask;
+
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ switch (e)
+ {
+ case CountdownChangedEvent countdownChangedEvent:
+ Room.Countdown = countdownChangedEvent.Countdown;
+ break;
+ }
+
+ RoomUpdated?.Invoke();
+ }, false);
+
return Task.CompletedTask;
}
@@ -665,6 +685,8 @@ namespace osu.Game.Online.Multiplayer
Room.Playlist.Remove(Room.Playlist.Single(existing => existing.ID == playlistItemId));
APIRoom.Playlist.RemoveAll(existing => existing.ID == playlistItemId);
+ Debug.Assert(Room.Playlist.Count > 0);
+
ItemRemoved?.Invoke(playlistItemId);
RoomUpdated?.Invoke();
});
diff --git a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs
new file mode 100644
index 0000000000..81190e64c9
--- /dev/null
+++ b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs
@@ -0,0 +1,28 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using System;
+using MessagePack;
+using osu.Game.Online.Multiplayer.Countdown;
+
+namespace osu.Game.Online.Multiplayer
+{
+ ///
+ /// Describes the current countdown in a .
+ ///
+ [MessagePackObject]
+ [Union(0, typeof(MatchStartCountdown))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types.
+ public abstract class MultiplayerCountdown
+ {
+ ///
+ /// The amount of time remaining in the countdown.
+ ///
+ ///
+ /// This is only sent once from the server upon initial retrieval of the or via a .
+ ///
+ [Key(0)]
+ public TimeSpan TimeRemaining { get; set; }
+ }
+}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
index a60e70dab3..e215498ff9 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
@@ -54,6 +54,12 @@ namespace osu.Game.Online.Multiplayer
[Key(6)]
public IList Playlist { get; set; } = new List();
+ ///
+ /// The currently-running countdown.
+ ///
+ [Key(7)]
+ public MultiplayerCountdown? Countdown { get; set; }
+
[JsonConstructor]
[SerializationConstructor]
public MultiplayerRoom(long roomId)
diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs
index f69d23d81c..156f916cef 100644
--- a/osu.Game/Online/SignalRWorkaroundTypes.cs
+++ b/osu.Game/Online/SignalRWorkaroundTypes.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
namespace osu.Game.Online
@@ -18,8 +19,12 @@ namespace osu.Game.Online
internal static readonly IReadOnlyList<(Type derivedType, Type baseType)> BASE_TYPE_MAPPING = new[]
{
(typeof(ChangeTeamRequest), typeof(MatchUserRequest)),
+ (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)),
+ (typeof(StopCountdownRequest), typeof(MatchUserRequest)),
+ (typeof(CountdownChangedEvent), typeof(MatchServerEvent)),
(typeof(TeamVersusRoomState), typeof(MatchRoomState)),
(typeof(TeamVersusUserState), typeof(MatchUserState)),
+ (typeof(MatchStartCountdown), typeof(MultiplayerCountdown))
};
}
}
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 25bd3d71de..4cd954a646 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -1046,6 +1046,10 @@ namespace osu.Game
switch (e.Action)
{
+ case GlobalAction.ToggleSkinEditor:
+ skinEditor.ToggleVisibility();
+ return true;
+
case GlobalAction.ResetInputSettings:
Host.ResetInputHandlers();
frameworkConfig.GetBindable(FrameworkSetting.ConfineMouseMode).SetDefault();
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index 5468db348e..7b9aca4086 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -200,7 +200,7 @@ namespace osu.Game
if (Storage.Exists(DatabaseContextFactory.DATABASE_NAME))
dependencies.Cache(EFContextFactory = new DatabaseContextFactory(Storage));
- dependencies.Cache(realm = new RealmAccess(Storage, "client", EFContextFactory));
+ dependencies.Cache(realm = new RealmAccess(Storage, "client", Host.UpdateThread, EFContextFactory));
dependencies.CacheAs(RulesetStore = new RealmRulesetStore(realm, Storage));
dependencies.CacheAs(RulesetStore);
diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
index 5ef434c427..86e72e9faa 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
@@ -173,7 +173,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
{
Text = score.MaxCombo.ToLocalisableString(@"0\x"),
Font = OsuFont.GetFont(size: text_size),
+#pragma warning disable 618
Colour = score.MaxCombo == score.BeatmapInfo.MaxCombo ? highAccuracyColour : Color4.White
+#pragma warning restore 618
}
};
diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs
index 6f07b20049..7d59c95396 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs
@@ -78,7 +78,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
// TODO: temporary. should be removed once `OrderByTotalScore` can accept `IScoreInfo`.
var beatmapInfo = new BeatmapInfo
{
+#pragma warning disable 618
MaxCombo = apiBeatmap.MaxCombo,
+#pragma warning restore 618
Status = apiBeatmap.Status,
MD5Hash = apiBeatmap.MD5Hash
};
diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs
new file mode 100644
index 0000000000..43574351ed
--- /dev/null
+++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs
@@ -0,0 +1,171 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using System;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Online.Chat;
+using osu.Game.Users.Drawables;
+using osuTK;
+
+namespace osu.Game.Overlays.Chat.ChannelList
+{
+ public class ChannelListItem : OsuClickableContainer
+ {
+ public event Action? OnRequestSelect;
+ public event Action? OnRequestLeave;
+
+ public readonly BindableInt Mentions = new BindableInt();
+
+ public readonly BindableBool Unread = new BindableBool();
+
+ private readonly Channel channel;
+
+ private Box? hoverBox;
+ private Box? selectBox;
+ private OsuSpriteText? text;
+ private ChannelListItemCloseButton? close;
+
+ [Resolved]
+ private Bindable selectedChannel { get; set; } = null!;
+
+ [Resolved]
+ private OverlayColourProvider colourProvider { get; set; } = null!;
+
+ public ChannelListItem(Channel channel)
+ {
+ this.channel = channel;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Height = 30;
+ RelativeSizeAxes = Axes.X;
+
+ Children = new Drawable[]
+ {
+ hoverBox = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = colourProvider.Background3,
+ Alpha = 0f,
+ },
+ selectBox = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = colourProvider.Background4,
+ Alpha = 0f,
+ },
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Padding = new MarginPadding { Left = 18, Right = 10 },
+ Child = new GridContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ ColumnDimensions = new[]
+ {
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(),
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.AutoSize),
+ },
+ Content = new[]
+ {
+ new[]
+ {
+ createIcon(),
+ text = new OsuSpriteText
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Text = channel.Name,
+ Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold),
+ Colour = colourProvider.Light3,
+ Margin = new MarginPadding { Bottom = 2 },
+ RelativeSizeAxes = Axes.X,
+ Truncate = true,
+ },
+ new ChannelListItemMentionPill
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Margin = new MarginPadding { Right = 3 },
+ Mentions = { BindTarget = Mentions },
+ },
+ close = new ChannelListItemCloseButton
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Margin = new MarginPadding { Right = 3 },
+ Action = () => OnRequestLeave?.Invoke(channel),
+ }
+ }
+ },
+ },
+ },
+ };
+
+ Action = () => OnRequestSelect?.Invoke(channel);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ selectedChannel.BindValueChanged(change =>
+ {
+ if (change.NewValue == channel)
+ selectBox?.FadeIn(300, Easing.OutQuint);
+ else
+ selectBox?.FadeOut(200, Easing.OutQuint);
+ }, true);
+
+ Unread.BindValueChanged(change =>
+ {
+ text!.FadeColour(change.NewValue ? colourProvider.Content1 : colourProvider.Light3, 300, Easing.OutQuint);
+ }, true);
+ }
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ hoverBox?.FadeIn(300, Easing.OutQuint);
+ close?.FadeIn(300, Easing.OutQuint);
+ return base.OnHover(e);
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ hoverBox?.FadeOut(200, Easing.OutQuint);
+ close?.FadeOut(200, Easing.OutQuint);
+ base.OnHoverLost(e);
+ }
+
+ private Drawable createIcon()
+ {
+ if (channel.Type != ChannelType.PM)
+ return Drawable.Empty();
+
+ return new UpdateableAvatar(channel.Users.First(), isInteractive: false)
+ {
+ Size = new Vector2(20),
+ Margin = new MarginPadding { Right = 5 },
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ CornerRadius = 10,
+ Masking = true,
+ };
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs
new file mode 100644
index 0000000000..65b9c4a79b
--- /dev/null
+++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs
@@ -0,0 +1,68 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Events;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Overlays.Chat.ChannelList
+{
+ public class ChannelListItemCloseButton : OsuClickableContainer
+ {
+ private SpriteIcon icon = null!;
+
+ private Color4 normalColour;
+ private Color4 hoveredColour;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour osuColour)
+ {
+ normalColour = osuColour.Red2;
+ hoveredColour = Color4.White;
+
+ Alpha = 0f;
+ Size = new Vector2(20);
+ Add(icon = new SpriteIcon
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(0.75f),
+ Icon = FontAwesome.Solid.TimesCircle,
+ RelativeSizeAxes = Axes.Both,
+ Colour = normalColour,
+ });
+ }
+
+ // Transforms matching OsuAnimatedButton
+ protected override bool OnHover(HoverEvent e)
+ {
+ icon.FadeColour(hoveredColour, 300, Easing.OutQuint);
+ return base.OnHover(e);
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ icon.FadeColour(normalColour, 300, Easing.OutQuint);
+ base.OnHoverLost(e);
+ }
+
+ protected override bool OnMouseDown(MouseDownEvent e)
+ {
+ icon.ScaleTo(0.75f, 2000, Easing.OutQuint);
+ return base.OnMouseDown(e);
+ }
+
+ protected override void OnMouseUp(MouseUpEvent e)
+ {
+ icon.ScaleTo(1, 1000, Easing.OutElastic);
+ base.OnMouseUp(e);
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs
new file mode 100644
index 0000000000..5018c8cd64
--- /dev/null
+++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs
@@ -0,0 +1,71 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Overlays.Chat.ChannelList
+{
+ public class ChannelListItemMentionPill : CircularContainer
+ {
+ public readonly BindableInt Mentions = new BindableInt();
+
+ private OsuSpriteText countText = null!;
+
+ private Box box = null!;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour osuColour, OverlayColourProvider colourProvider)
+ {
+ Masking = true;
+ Size = new Vector2(20, 12);
+ Alpha = 0f;
+
+ Children = new Drawable[]
+ {
+ box = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = osuColour.Orange1,
+ },
+ countText = new OsuSpriteText
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Font = OsuFont.Torus.With(size: 11, weight: FontWeight.Bold),
+ Margin = new MarginPadding { Bottom = 1 },
+ Colour = colourProvider.Background5,
+ },
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ Mentions.BindValueChanged(change =>
+ {
+ int mentionCount = change.NewValue;
+
+ countText.Text = mentionCount > 99 ? "99+" : mentionCount.ToString();
+
+ if (mentionCount > 0)
+ {
+ this.FadeIn(1000, Easing.OutQuint);
+ box.FlashColour(Color4.White, 500, Easing.OutQuint);
+ }
+ else
+ this.FadeOut(100, Easing.OutQuint);
+ }, true);
+ }
+ }
+}
diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs
index 3764ac42fc..3d39c7ce3a 100644
--- a/osu.Game/Overlays/ChatOverlay.cs
+++ b/osu.Game/Overlays/ChatOverlay.cs
@@ -73,6 +73,10 @@ namespace osu.Game.Overlays
private Container channelSelectionContainer;
protected ChannelSelectionOverlay ChannelSelectionOverlay;
+ private readonly IBindableList availableChannels = new BindableList();
+ private readonly IBindableList joinedChannels = new BindableList();
+ private readonly Bindable currentChannel = new Bindable();
+
public override bool Contains(Vector2 screenSpacePos) => chatContainer.ReceivePositionalInputAt(screenSpacePos)
|| (ChannelSelectionOverlay.State.Value == Visibility.Visible && ChannelSelectionOverlay.ReceivePositionalInputAt(screenSpacePos));
@@ -198,9 +202,13 @@ namespace osu.Game.Overlays
},
};
+ availableChannels.BindTo(channelManager.AvailableChannels);
+ joinedChannels.BindTo(channelManager.JoinedChannels);
+ currentChannel.BindTo(channelManager.CurrentChannel);
+
textBox.OnCommit += postMessage;
- ChannelTabControl.Current.ValueChanged += current => channelManager.CurrentChannel.Value = current.NewValue;
+ ChannelTabControl.Current.ValueChanged += current => currentChannel.Value = current.NewValue;
ChannelTabControl.ChannelSelectorActive.ValueChanged += active => ChannelSelectionOverlay.State.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden;
ChannelSelectionOverlay.State.ValueChanged += state =>
{
@@ -238,18 +246,12 @@ namespace osu.Game.Overlays
Schedule(() =>
{
// TODO: consider scheduling bindable callbacks to not perform when overlay is not present.
- channelManager.JoinedChannels.BindCollectionChanged(joinedChannelsChanged, true);
-
- channelManager.AvailableChannels.CollectionChanged += availableChannelsChanged;
- availableChannelsChanged(null, null);
-
- currentChannel = channelManager.CurrentChannel.GetBoundCopy();
+ joinedChannels.BindCollectionChanged(joinedChannelsChanged, true);
+ availableChannels.BindCollectionChanged(availableChannelsChanged, true);
currentChannel.BindValueChanged(currentChannelChanged, true);
});
}
- private Bindable currentChannel;
-
private void currentChannelChanged(ValueChangedEvent e)
{
if (e.NewValue == null)
@@ -318,7 +320,7 @@ namespace osu.Game.Overlays
if (!channel.Joined.Value)
channel = channelManager.JoinChannel(channel);
- channelManager.CurrentChannel.Value = channel;
+ currentChannel.Value = channel;
}
channel.HighlightedMessage.Value = message;
@@ -407,7 +409,7 @@ namespace osu.Game.Overlays
return true;
case PlatformAction.DocumentClose:
- channelManager.LeaveChannel(channelManager.CurrentChannel.Value);
+ channelManager.LeaveChannel(currentChannel.Value);
return true;
}
@@ -487,19 +489,7 @@ namespace osu.Game.Overlays
private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args)
{
- ChannelSelectionOverlay.UpdateAvailableChannels(channelManager.AvailableChannels);
- }
-
- protected override void Dispose(bool isDisposing)
- {
- base.Dispose(isDisposing);
-
- if (channelManager != null)
- {
- channelManager.CurrentChannel.ValueChanged -= currentChannelChanged;
- channelManager.JoinedChannels.CollectionChanged -= joinedChannelsChanged;
- channelManager.AvailableChannels.CollectionChanged -= availableChannelsChanged;
- }
+ ChannelSelectionOverlay.UpdateAvailableChannels(availableChannels);
}
private void postMessage(TextBox textBox, bool newText)
diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs
index 0f953f92bb..a70a7f26cc 100644
--- a/osu.Game/Overlays/Dialog/PopupDialog.cs
+++ b/osu.Game/Overlays/Dialog/PopupDialog.cs
@@ -219,7 +219,12 @@ namespace osu.Game.Overlays.Dialog
///
/// Programmatically clicks the first .
///
- public void PerformOkAction() => Buttons.OfType().First().TriggerClick();
+ public void PerformOkAction() => PerformAction();
+
+ ///
+ /// Programmatically clicks the first button of the provided type.
+ ///
+ public void PerformAction() where T : PopupDialogButton => Buttons.OfType().First().TriggerClick();
protected override bool OnKeyDown(KeyDownEvent e)
{
diff --git a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs
new file mode 100644
index 0000000000..1911a4fa56
--- /dev/null
+++ b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs
@@ -0,0 +1,59 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+
+namespace osu.Game.Overlays.Dialog
+{
+ public class PopupDialogDangerousButton : PopupDialogButton
+ {
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ ButtonColour = colours.Red3;
+
+ ColourContainer.Add(new ConfirmFillBox
+ {
+ Action = () => Action(),
+ RelativeSizeAxes = Axes.Both,
+ Blending = BlendingParameters.Additive,
+ });
+ }
+
+ private class ConfirmFillBox : HoldToConfirmContainer
+ {
+ private Box box;
+
+ protected override double? HoldActivationDelay => 500;
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ Child = box = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ };
+
+ Progress.BindValueChanged(progress => box.Width = (float)progress.NewValue, true);
+ }
+
+ protected override bool OnMouseDown(MouseDownEvent e)
+ {
+ BeginConfirm();
+ return true;
+ }
+
+ protected override void OnMouseUp(MouseUpEvent e)
+ {
+ if (!e.HasAnyButtonPressed)
+ AbortConfirm();
+ }
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs
index 158d8811b5..0b4eca6379 100644
--- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs
@@ -68,7 +68,7 @@ namespace osu.Game.Overlays.Settings.Sections.General
Add(new SettingsButton
{
Text = GeneralSettingsStrings.OpenOsuFolder,
- Action = storage.PresentExternally,
+ Action = () => storage.PresentExternally(),
});
Add(new SettingsButton
diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs
index 475c4bff8d..a34776ddf0 100644
--- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs
+++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs
@@ -64,7 +64,7 @@ namespace osu.Game.Overlays.Settings.Sections
new SettingsButton
{
Text = SkinSettingsStrings.SkinLayoutEditor,
- Action = () => skinEditor?.Toggle(),
+ Action = () => skinEditor?.ToggleVisibility(),
},
new ExportSkinButton(),
};
diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
index 8dc037c7c8..2a7f2b037f 100644
--- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
+++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs
@@ -500,12 +500,15 @@ namespace osu.Game.Rulesets.Objects.Legacy
=> new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered));
public bool Equals(LegacyHitSampleInfo? other)
- => base.Equals(other) && CustomSampleBank == other.CustomSampleBank;
+ // The additions to equality checks here are *required* to ensure that pooling works correctly.
+ // Of note, `IsLayered` may cause the usage of `SampleVirtual` instead of an actual sample (in cases playback is not required).
+ // Removing it would cause samples which may actually require playback to potentially source for a `SampleVirtual` sample pool.
+ => base.Equals(other) && CustomSampleBank == other.CustomSampleBank && IsLayered == other.IsLayered;
public override bool Equals(object? obj)
=> obj is LegacyHitSampleInfo other && Equals(other);
- public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank);
+ public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank, IsLayered);
}
private class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable
diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs
index 514232db69..9f03c381ee 100644
--- a/osu.Game/Rulesets/Scoring/HitResult.cs
+++ b/osu.Game/Rulesets/Scoring/HitResult.cs
@@ -122,7 +122,19 @@ namespace osu.Game.Rulesets.Scoring
public static class HitResultExtensions
{
///
- /// Whether a increases/decreases the combo, and affects the combo portion of the score.
+ /// Whether a increases the combo.
+ ///
+ public static bool IncreasesCombo(this HitResult result)
+ => AffectsCombo(result) && IsHit(result);
+
+ ///
+ /// Whether a breaks the combo and resets it back to zero.
+ ///
+ public static bool BreaksCombo(this HitResult result)
+ => AffectsCombo(result) && !IsHit(result);
+
+ ///
+ /// Whether a increases/breaks the combo, and affects the combo portion of the score.
///
public static bool AffectsCombo(this HitResult result)
{
diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
index 0c585fac98..1e268bb2eb 100644
--- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
+++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
@@ -166,20 +166,10 @@ namespace osu.Game.Rulesets.Scoring
if (!result.Type.IsScorable())
return;
- if (result.Type.AffectsCombo())
- {
- switch (result.Type)
- {
- case HitResult.Miss:
- case HitResult.LargeTickMiss:
- Combo.Value = 0;
- break;
-
- default:
- Combo.Value++;
- break;
- }
- }
+ if (result.Type.IncreasesCombo())
+ Combo.Value++;
+ else if (result.Type.BreaksCombo())
+ Combo.Value = 0;
double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0;
diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs
index cd8f99db8b..ea5ffb10c6 100644
--- a/osu.Game/Rulesets/UI/Playfield.cs
+++ b/osu.Game/Rulesets/UI/Playfield.cs
@@ -323,7 +323,7 @@ namespace osu.Game.Rulesets.UI
///
/// The type.
/// The receiver for s.
- protected void RegisterPool(int initialSize, int? maximumSize = null)
+ public void RegisterPool(int initialSize, int? maximumSize = null)
where TObject : HitObject
where TDrawable : DrawableHitObject, new()
=> RegisterPool(new DrawablePool(initialSize, maximumSize));
diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
index fefee370b9..754ace82c5 100644
--- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
+++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
@@ -23,6 +23,8 @@ namespace osu.Game.Scoring.Legacy
private IBeatmap currentBeatmap;
private Ruleset currentRuleset;
+ private float beatmapOffset;
+
public Score Parse(Stream stream)
{
var score = new Score
@@ -72,6 +74,9 @@ namespace osu.Game.Scoring.Legacy
currentBeatmap = workingBeatmap.GetPlayableBeatmap(currentRuleset.RulesetInfo, scoreInfo.Mods);
scoreInfo.BeatmapInfo = currentBeatmap.BeatmapInfo;
+ // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing.
+ beatmapOffset = currentBeatmap.BeatmapInfo.BeatmapVersion < 5 ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0;
+
/* score.HpGraphString = */
sr.ReadString();
@@ -229,7 +234,7 @@ namespace osu.Game.Scoring.Legacy
private void readLegacyReplay(Replay replay, StreamReader reader)
{
- float lastTime = 0;
+ float lastTime = beatmapOffset;
ReplayFrame currentFrame = null;
string[] frames = reader.ReadToEnd().Split(',');
diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
index f0ead05280..ae9afbf32e 100644
--- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
+++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs
@@ -1,12 +1,15 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+#nullable enable
+
using System;
using System.IO;
using System.Linq;
using System.Text;
using osu.Framework.Extensions;
using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Formats;
using osu.Game.Extensions;
using osu.Game.IO.Legacy;
using osu.Game.Replays.Legacy;
@@ -14,8 +17,6 @@ using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
using SharpCompress.Compressors.LZMA;
-#nullable enable
-
namespace osu.Game.Scoring.Legacy
{
public class LegacyScoreEncoder
@@ -111,6 +112,9 @@ namespace osu.Game.Scoring.Legacy
{
StringBuilder replayData = new StringBuilder();
+ // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing.
+ double offset = beatmap?.BeatmapInfo.BeatmapVersion < 5 ? -LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0;
+
if (score.Replay != null)
{
int lastTime = 0;
@@ -120,7 +124,7 @@ namespace osu.Game.Scoring.Legacy
var legacyFrame = getLegacyFrame(f);
// Rounding because stable could only parse integral values
- int time = (int)Math.Round(legacyFrame.Time);
+ int time = (int)Math.Round(legacyFrame.Time + offset);
replayData.Append(FormattableString.Invariant($"{time - lastTime}|{legacyFrame.MouseX ?? 0}|{legacyFrame.MouseY ?? 0}|{(int)legacyFrame.ButtonState},"));
lastTime = time;
}
diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs
index 4de1d580dc..d7185a1677 100644
--- a/osu.Game/Scoring/ScoreInfo.cs
+++ b/osu.Game/Scoring/ScoreInfo.cs
@@ -157,7 +157,7 @@ namespace osu.Game.Scoring
public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy();
///
- /// Whether this represents a legacy (osu!stable) score.
+ /// Whether this represents a legacy (osu!stable) score.
///
[Ignored]
public bool IsLegacyScore => Mods.OfType().Any();
diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs
index 02a7d9a39f..83359838aa 100644
--- a/osu.Game/Scoring/ScoreManager.cs
+++ b/osu.Game/Scoring/ScoreManager.cs
@@ -134,35 +134,9 @@ namespace osu.Game.Scoring
if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash))
return score.TotalScore;
- int beatmapMaxCombo;
-
- if (score.IsLegacyScore)
- {
- // This score is guaranteed to be an osu!stable score.
- // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used.
- if (score.BeatmapInfo.MaxCombo != null)
- beatmapMaxCombo = score.BeatmapInfo.MaxCombo.Value;
- else
- {
- if (difficulties == null)
- return score.TotalScore;
-
- // We can compute the max combo locally after the async beatmap difficulty computation.
- var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false);
-
- // Something failed during difficulty calculation. Fall back to provided score.
- if (difficulty == null)
- return score.TotalScore;
-
- beatmapMaxCombo = difficulty.Value.MaxCombo;
- }
- }
- else
- {
- // This is guaranteed to be a non-legacy score.
- // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values.
- beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum();
- }
+ int? beatmapMaxCombo = await GetMaximumAchievableComboAsync(score, cancellationToken).ConfigureAwait(false);
+ if (beatmapMaxCombo == null)
+ return score.TotalScore;
if (beatmapMaxCombo == 0)
return 0;
@@ -171,7 +145,37 @@ namespace osu.Game.Scoring
var scoreProcessor = ruleset.CreateScoreProcessor();
scoreProcessor.Mods.Value = score.Mods;
- return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo));
+ return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo.Value));
+ }
+
+ ///
+ /// Retrieves the maximum achievable combo for the provided score.
+ ///
+ /// The to compute the maximum achievable combo for.
+ /// A to cancel the process.
+ /// The maximum achievable combo. A return value indicates the difficulty cache has failed to retrieve the combo.
+ public async Task GetMaximumAchievableComboAsync([NotNull] ScoreInfo score, CancellationToken cancellationToken = default)
+ {
+ if (score.IsLegacyScore)
+ {
+ // This score is guaranteed to be an osu!stable score.
+ // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used.
+#pragma warning disable CS0618
+ if (score.BeatmapInfo.MaxCombo != null)
+ return score.BeatmapInfo.MaxCombo.Value;
+#pragma warning restore CS0618
+
+ if (difficulties == null)
+ return null;
+
+ // We can compute the max combo locally after the async beatmap difficulty computation.
+ var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false);
+ return difficulty?.MaxCombo;
+ }
+
+ // This is guaranteed to be a non-legacy score.
+ // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values.
+ return Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum();
}
///
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index dcb7e3a282..57f7429e06 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -97,7 +97,7 @@ namespace osu.Game.Screens.Edit
private bool canSave;
- private bool exitConfirmed;
+ protected bool ExitConfirmed { get; private set; }
private string lastSavedHash;
@@ -586,7 +586,7 @@ namespace osu.Game.Screens.Edit
public override bool OnExiting(IScreen next)
{
- if (!exitConfirmed)
+ if (!ExitConfirmed)
{
// dialog overlay may not be available in visual tests.
if (dialogOverlay == null)
@@ -595,12 +595,9 @@ namespace osu.Game.Screens.Edit
return true;
}
- // if the dialog is already displayed, confirm exit with no save.
- if (dialogOverlay.CurrentDialog is PromptForSaveDialog saveDialog)
- {
- saveDialog.PerformOkAction();
+ // if the dialog is already displayed, block exiting until the user explicitly makes a decision.
+ if (dialogOverlay.CurrentDialog is PromptForSaveDialog)
return true;
- }
if (isNewBeatmap || HasUnsavedChanges)
{
@@ -645,7 +642,7 @@ namespace osu.Game.Screens.Edit
{
Save();
- exitConfirmed = true;
+ ExitConfirmed = true;
this.Exit();
}
@@ -668,7 +665,7 @@ namespace osu.Game.Screens.Edit
Beatmap.SetDefault();
}
- exitConfirmed = true;
+ ExitConfirmed = true;
this.Exit();
}
diff --git a/osu.Game/Screens/Edit/PromptForSaveDialog.cs b/osu.Game/Screens/Edit/PromptForSaveDialog.cs
index e308a9533d..4f70491ade 100644
--- a/osu.Game/Screens/Edit/PromptForSaveDialog.cs
+++ b/osu.Game/Screens/Edit/PromptForSaveDialog.cs
@@ -17,12 +17,12 @@ namespace osu.Game.Screens.Edit
Buttons = new PopupDialogButton[]
{
- new PopupDialogCancelButton
+ new PopupDialogOkButton
{
Text = @"Save my masterpiece!",
Action = saveAndExit
},
- new PopupDialogOkButton
+ new PopupDialogDangerousButton
{
Text = @"Forget all changes",
Action = exit
diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs
index b03425fef4..a1f0d22efc 100644
--- a/osu.Game/Screens/Menu/ButtonSystem.cs
+++ b/osu.Game/Screens/Menu/ButtonSystem.cs
@@ -79,10 +79,10 @@ namespace osu.Game.Screens.Menu
private readonly ButtonArea buttonArea;
- private readonly Button backButton;
+ private readonly MainMenuButton backButton;
- private readonly List
- public class Button : BeatSyncedContainer, IStateful
+ public class MainMenuButton : BeatSyncedContainer, IStateful
{
public event Action StateChanged;
@@ -51,7 +51,7 @@ namespace osu.Game.Screens.Menu
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos);
- public Button(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action clickAction = null, float extraWidth = 0, Key triggerKey = Key.Unknown)
+ public MainMenuButton(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action clickAction = null, float extraWidth = 0, Key triggerKey = Key.Unknown)
{
this.sampleName = sampleName;
this.clickAction = clickAction;
@@ -209,7 +209,7 @@ namespace osu.Game.Screens.Menu
protected override bool OnKeyDown(KeyDownEvent e)
{
- if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed)
+ if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed || e.SuperPressed)
return false;
if (TriggerKey == e.Key && TriggerKey != Key.Unknown)
diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs
index 9822ceaaf6..cdaa39d2be 100644
--- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs
@@ -15,12 +15,12 @@ namespace osu.Game.Screens.OnlinePlay.Components
{
public new readonly BindableBool Enabled = new BindableBool();
- private IBindable availability;
+ private readonly IBindable availability = new Bindable();
[BackgroundDependencyLoader]
private void load(OnlinePlayBeatmapAvailabilityTracker beatmapTracker)
{
- availability = beatmapTracker.Availability.GetBoundCopy();
+ availability.BindTo(beatmapTracker.Availability);
availability.BindValueChanged(_ => updateState());
Enabled.BindValueChanged(_ => updateState(), true);
diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
index e297c90491..a382f65d84 100644
--- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
@@ -12,6 +12,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Screens;
using osu.Game.Audio;
@@ -100,122 +101,126 @@ namespace osu.Game.Screens.OnlinePlay.Match
{
sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection");
- InternalChildren = new Drawable[]
+ InternalChild = new PopoverContainer
{
- beatmapAvailabilityTracker,
- new MultiplayerRoomSounds(),
- new GridContainer
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
{
- RelativeSizeAxes = Axes.Both,
- RowDimensions = new[]
+ beatmapAvailabilityTracker,
+ new MultiplayerRoomSounds(),
+ new GridContainer
{
- new Dimension(),
- new Dimension(GridSizeMode.Absolute, 50)
- },
- Content = new[]
- {
- // Padded main content (drawable room + main content)
- new Drawable[]
+ RelativeSizeAxes = Axes.Both,
+ RowDimensions = new[]
{
- new Container
+ new Dimension(),
+ new Dimension(GridSizeMode.Absolute, 50)
+ },
+ Content = new[]
+ {
+ // Padded main content (drawable room + main content)
+ new Drawable[]
{
- RelativeSizeAxes = Axes.Both,
- Padding = new MarginPadding
+ new Container
{
- Horizontal = WaveOverlayContainer.WIDTH_PADDING,
- Bottom = 30
- },
- Children = new[]
- {
- mainContent = new GridContainer
+ RelativeSizeAxes = Axes.Both,
+ Padding = new MarginPadding
{
- RelativeSizeAxes = Axes.Both,
- RowDimensions = new[]
+ Horizontal = WaveOverlayContainer.WIDTH_PADDING,
+ Bottom = 30
+ },
+ Children = new[]
+ {
+ mainContent = new GridContainer
{
- new Dimension(GridSizeMode.AutoSize),
- new Dimension(GridSizeMode.Absolute, 10)
- },
- Content = new[]
- {
- new Drawable[]
+ RelativeSizeAxes = Axes.Both,
+ RowDimensions = new[]
{
- new DrawableMatchRoom(Room, allowEdit)
- {
- OnEdit = () => settingsOverlay.Show(),
- SelectedItem = { BindTarget = SelectedItem }
- }
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension(GridSizeMode.Absolute, 10)
},
- null,
- new Drawable[]
+ Content = new[]
{
- new Container
+ new Drawable[]
{
- RelativeSizeAxes = Axes.Both,
- Children = new[]
+ new DrawableMatchRoom(Room, allowEdit)
{
- new Container
+ OnEdit = () => settingsOverlay.Show(),
+ SelectedItem = { BindTarget = SelectedItem }
+ }
+ },
+ null,
+ new Drawable[]
+ {
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new[]
{
- RelativeSizeAxes = Axes.Both,
- Masking = true,
- CornerRadius = 10,
- Child = new Box
+ new Container
{
RelativeSizeAxes = Axes.Both,
- Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary.
+ Masking = true,
+ CornerRadius = 10,
+ Child = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary.
+ },
},
- },
- new Container
- {
- RelativeSizeAxes = Axes.Both,
- Padding = new MarginPadding(20),
- Child = CreateMainContent(),
- },
- new Container
- {
- Anchor = Anchor.BottomLeft,
- Origin = Anchor.BottomLeft,
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Child = userModsSelectOverlay = new UserModSelectOverlay
+ new Container
{
- SelectedMods = { BindTarget = UserMods },
- IsValidMod = _ => false
- }
- },
+ RelativeSizeAxes = Axes.Both,
+ Padding = new MarginPadding(20),
+ Child = CreateMainContent(),
+ },
+ new Container
+ {
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Child = userModsSelectOverlay = new UserModSelectOverlay
+ {
+ SelectedMods = { BindTarget = UserMods },
+ IsValidMod = _ => false
+ }
+ },
+ }
}
}
}
+ },
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ // Resolves 1px masking errors between the settings overlay and the room panel.
+ Padding = new MarginPadding(-1),
+ Child = settingsOverlay = CreateRoomSettingsOverlay(Room)
}
},
- new Container
- {
- RelativeSizeAxes = Axes.Both,
- // Resolves 1px masking errors between the settings overlay and the room panel.
- Padding = new MarginPadding(-1),
- Child = settingsOverlay = CreateRoomSettingsOverlay(Room)
- }
},
},
- },
- // Footer
- new Drawable[]
- {
- new Container
+ // Footer
+ new Drawable[]
{
- RelativeSizeAxes = Axes.Both,
- Children = new Drawable[]
+ new Container
{
- new Box
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
{
- RelativeSizeAxes = Axes.Both,
- Colour = Color4Extensions.FromHex(@"28242d") // Temporary.
- },
- new Container
- {
- RelativeSizeAxes = Axes.Both,
- Padding = new MarginPadding(5),
- Child = CreateFooter()
- },
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4Extensions.FromHex(@"28242d") // Temporary.
+ },
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Padding = new MarginPadding(5),
+ Child = CreateFooter()
+ },
+ }
}
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs
new file mode 100644
index 0000000000..af7ed9b9e2
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs
@@ -0,0 +1,224 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Diagnostics;
+using System.Linq;
+using JetBrains.Annotations;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Threading;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Multiplayer.Countdown;
+using osuTK;
+
+namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
+{
+ public class MatchStartControl : MultiplayerRoomComposite
+ {
+ [Resolved]
+ private OngoingOperationTracker ongoingOperationTracker { get; set; }
+
+ [CanBeNull]
+ private IDisposable clickOperation;
+
+ private Sample sampleReady;
+ private Sample sampleReadyAll;
+ private Sample sampleUnready;
+
+ private readonly BindableBool enabled = new BindableBool();
+ private readonly MultiplayerCountdownButton countdownButton;
+ private int countReady;
+ private ScheduledDelegate readySampleDelegate;
+ private IBindable operationInProgress;
+
+ public MatchStartControl()
+ {
+ InternalChild = new GridContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ ColumnDimensions = new[]
+ {
+ new Dimension(),
+ new Dimension(GridSizeMode.AutoSize)
+ },
+ Content = new[]
+ {
+ new Drawable[]
+ {
+ new MultiplayerReadyButton
+ {
+ RelativeSizeAxes = Axes.Both,
+ Size = Vector2.One,
+ Action = onReadyClick,
+ Enabled = { BindTarget = enabled },
+ },
+ countdownButton = new MultiplayerCountdownButton
+ {
+ RelativeSizeAxes = Axes.Y,
+ Size = new Vector2(40, 1),
+ Alpha = 0,
+ Action = startCountdown,
+ Enabled = { BindTarget = enabled }
+ }
+ }
+ }
+ };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(AudioManager audio)
+ {
+ operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy();
+ operationInProgress.BindValueChanged(_ => updateState());
+
+ sampleReady = audio.Samples.Get(@"Multiplayer/player-ready");
+ sampleReadyAll = audio.Samples.Get(@"Multiplayer/player-ready-all");
+ sampleUnready = audio.Samples.Get(@"Multiplayer/player-unready");
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ CurrentPlaylistItem.BindValueChanged(_ => updateState());
+ }
+
+ protected override void OnRoomUpdated()
+ {
+ base.OnRoomUpdated();
+ updateState();
+ }
+
+ protected override void OnRoomLoadRequested()
+ {
+ base.OnRoomLoadRequested();
+ endOperation();
+ }
+
+ private void onReadyClick()
+ {
+ if (Room == null)
+ return;
+
+ Debug.Assert(clickOperation == null);
+ clickOperation = ongoingOperationTracker.BeginOperation();
+
+ // Ensure the current user becomes ready before being able to do anything else (start match, stop countdown, unready).
+ if (!isReady() || !Client.IsHost)
+ {
+ toggleReady();
+ return;
+ }
+
+ // Local user is the room host and is in a ready state.
+ // The only action they can take is to stop a countdown if one's currently running.
+ if (Room.Countdown != null)
+ {
+ stopCountdown();
+ return;
+ }
+
+ // And if a countdown isn't running, start the match.
+ startMatch();
+
+ bool isReady() => Client.LocalUser?.State == MultiplayerUserState.Ready || Client.LocalUser?.State == MultiplayerUserState.Spectating;
+
+ void toggleReady() => Client.ToggleReady().ContinueWith(_ => endOperation());
+
+ void stopCountdown() => Client.SendMatchRequest(new StopCountdownRequest()).ContinueWith(_ => endOperation());
+
+ void startMatch() => Client.StartMatch().ContinueWith(t =>
+ {
+ // accessing Exception here silences any potential errors from the antecedent task
+ if (t.Exception != null)
+ {
+ // gameplay was not started due to an exception; unblock button.
+ endOperation();
+ }
+
+ // gameplay is starting, the button will be unblocked on load requested.
+ });
+ }
+
+ private void startCountdown(TimeSpan duration)
+ {
+ Debug.Assert(clickOperation == null);
+ clickOperation = ongoingOperationTracker.BeginOperation();
+
+ Client.SendMatchRequest(new StartMatchCountdownRequest { Duration = duration }).ContinueWith(_ => endOperation());
+ }
+
+ private void endOperation()
+ {
+ clickOperation?.Dispose();
+ clickOperation = null;
+ }
+
+ private void updateState()
+ {
+ if (Room == null)
+ {
+ enabled.Value = false;
+ return;
+ }
+
+ var localUser = Client.LocalUser;
+
+ int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready);
+ int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating);
+
+ if (Room.Countdown != null)
+ countdownButton.Alpha = 0;
+ else
+ {
+ switch (localUser?.State)
+ {
+ default:
+ countdownButton.Alpha = 0;
+ break;
+
+ case MultiplayerUserState.Spectating:
+ case MultiplayerUserState.Ready:
+ countdownButton.Alpha = Room.Host?.Equals(localUser) == true ? 1 : 0;
+ break;
+ }
+ }
+
+ enabled.Value =
+ Room.State == MultiplayerRoomState.Open
+ && CurrentPlaylistItem.Value?.ID == Room.Settings.PlaylistItemId
+ && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired
+ && !operationInProgress.Value;
+
+ // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready.
+ if (localUser?.State == MultiplayerUserState.Spectating)
+ enabled.Value &= Room.Host?.Equals(localUser) == true && newCountReady > 0;
+
+ if (newCountReady == countReady)
+ return;
+
+ readySampleDelegate?.Cancel();
+ readySampleDelegate = Schedule(() =>
+ {
+ if (newCountReady > countReady)
+ {
+ if (newCountReady == newCountTotal)
+ sampleReadyAll?.Play();
+ else
+ sampleReady?.Play();
+ }
+ else if (newCountReady < countReady)
+ {
+ sampleUnready?.Play();
+ }
+
+ countReady = newCountReady;
+ });
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs
new file mode 100644
index 0000000000..3bf7e91a55
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs
@@ -0,0 +1,83 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using Humanizer;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
+using osu.Game.Graphics;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterfaceV2;
+using osuTK;
+
+namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
+{
+ public class MultiplayerCountdownButton : IconButton, IHasPopover
+ {
+ private static readonly TimeSpan[] available_delays =
+ {
+ TimeSpan.FromSeconds(10),
+ TimeSpan.FromSeconds(30),
+ TimeSpan.FromMinutes(1),
+ TimeSpan.FromMinutes(2)
+ };
+
+ public new Action Action;
+
+ private readonly Drawable background;
+
+ public MultiplayerCountdownButton()
+ {
+ Icon = FontAwesome.Solid.CaretDown;
+ IconScale = new Vector2(0.6f);
+
+ Add(background = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Depth = float.MaxValue
+ });
+
+ base.Action = this.ShowPopover;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ background.Colour = colours.Green;
+ }
+
+ public Popover GetPopover()
+ {
+ var flow = new FillFlowContainer
+ {
+ Width = 200,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(2),
+ };
+
+ foreach (var duration in available_delays)
+ {
+ flow.Add(new OsuButton
+ {
+ RelativeSizeAxes = Axes.X,
+ Text = $"Start match in {duration.Humanize()}",
+ BackgroundColour = background.Colour,
+ Action = () =>
+ {
+ Action(duration);
+ this.HidePopover();
+ }
+ });
+ }
+
+ return new OsuPopover { Child = flow };
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs
index 036e37ddfd..a07c95bca8 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -12,19 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private const float ready_button_width = 600;
private const float spectate_button_width = 200;
- public Action OnReadyClick
- {
- set => readyButton.OnReadyClick = value;
- }
-
- public Action OnSpectateClick
- {
- set => spectateButton.OnSpectateClick = value;
- }
-
- private readonly MultiplayerReadyButton readyButton;
- private readonly MultiplayerSpectateButton spectateButton;
-
public MultiplayerMatchFooter()
{
RelativeSizeAxes = Axes.Both;
@@ -37,12 +23,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
new Drawable[]
{
null,
- spectateButton = new MultiplayerSpectateButton
+ new MultiplayerSpectateButton
{
RelativeSizeAxes = Axes.Both,
},
null,
- readyButton = new MultiplayerReadyButton
+ new MatchStartControl
{
RelativeSizeAxes = Axes.Both,
},
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index 023af85f3b..746e4257f1 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -3,165 +3,176 @@
using System;
using System.Linq;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
-using osu.Framework.Audio;
-using osu.Framework.Audio.Sample;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
+using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Online.Multiplayer;
using osu.Game.Screens.OnlinePlay.Components;
-using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
- public class MultiplayerReadyButton : MultiplayerRoomComposite
+ public class MultiplayerReadyButton : ReadyButton
{
- public Action OnReadyClick
- {
- set => button.Action = value;
- }
+ public new Triangles Triangles => base.Triangles;
+
+ [Resolved]
+ private MultiplayerClient multiplayerClient { get; set; }
[Resolved]
private OsuColour colours { get; set; }
- [Resolved]
- private OngoingOperationTracker ongoingOperationTracker { get; set; }
-
- private IBindable operationInProgress;
-
- private Sample sampleReady;
- private Sample sampleReadyAll;
- private Sample sampleUnready;
-
- private readonly ButtonWithTrianglesExposed button;
-
- private int countReady;
-
- private ScheduledDelegate readySampleDelegate;
-
- public MultiplayerReadyButton()
- {
- InternalChild = button = new ButtonWithTrianglesExposed
- {
- RelativeSizeAxes = Axes.Both,
- Size = Vector2.One,
- Enabled = { Value = true },
- };
- }
-
- [BackgroundDependencyLoader]
- private void load(AudioManager audio)
- {
- operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy();
- operationInProgress.BindValueChanged(_ => updateState());
-
- sampleReady = audio.Samples.Get(@"Multiplayer/player-ready");
- sampleReadyAll = audio.Samples.Get(@"Multiplayer/player-ready-all");
- sampleUnready = audio.Samples.Get(@"Multiplayer/player-unready");
- }
+ [CanBeNull]
+ private MultiplayerRoom room => multiplayerClient.Room;
protected override void LoadComplete()
{
base.LoadComplete();
- CurrentPlaylistItem.BindValueChanged(_ => updateState());
+ multiplayerClient.RoomUpdated += onRoomUpdated;
+ onRoomUpdated();
}
- protected override void OnRoomUpdated()
- {
- base.OnRoomUpdated();
+ private MultiplayerCountdown countdown;
+ private DateTimeOffset countdownReceivedTime;
+ private ScheduledDelegate countdownUpdateDelegate;
- updateState();
+ private void onRoomUpdated() => Scheduler.AddOnce(() =>
+ {
+ if (countdown == null && room?.Countdown != null)
+ countdownReceivedTime = DateTimeOffset.Now;
+
+ countdown = room?.Countdown;
+
+ if (room?.Countdown != null)
+ countdownUpdateDelegate ??= Scheduler.AddDelayed(updateButtonText, 1000, true);
+ else
+ {
+ countdownUpdateDelegate?.Cancel();
+ countdownUpdateDelegate = null;
+ }
+
+ updateButtonText();
+ updateButtonColour();
+ });
+
+ private void updateButtonText()
+ {
+ if (room == null)
+ {
+ Text = "Ready";
+ return;
+ }
+
+ var localUser = multiplayerClient.LocalUser;
+
+ int countReady = room.Users.Count(u => u.State == MultiplayerUserState.Ready);
+ int countTotal = room.Users.Count(u => u.State != MultiplayerUserState.Spectating);
+ string countText = $"({countReady} / {countTotal} ready)";
+
+ if (countdown != null)
+ {
+ TimeSpan timeElapsed = DateTimeOffset.Now - countdownReceivedTime;
+ TimeSpan countdownRemaining;
+
+ if (timeElapsed > countdown.TimeRemaining)
+ countdownRemaining = TimeSpan.Zero;
+ else
+ countdownRemaining = countdown.TimeRemaining - timeElapsed;
+
+ string countdownText = $"Starting in {countdownRemaining:mm\\:ss}";
+
+ switch (localUser?.State)
+ {
+ default:
+ Text = $"Ready ({countdownText.ToLowerInvariant()})";
+ break;
+
+ case MultiplayerUserState.Spectating:
+ case MultiplayerUserState.Ready:
+ Text = $"{countdownText} {countText}";
+ break;
+ }
+ }
+ else
+ {
+ switch (localUser?.State)
+ {
+ default:
+ Text = "Ready";
+ break;
+
+ case MultiplayerUserState.Spectating:
+ case MultiplayerUserState.Ready:
+ Text = room.Host?.Equals(localUser) == true
+ ? $"Start match {countText}"
+ : $"Waiting for host... {countText}";
+
+ break;
+ }
+ }
}
- private void updateState()
+ private void updateButtonColour()
{
- var localUser = Client.LocalUser;
+ if (room == null)
+ {
+ setGreen();
+ return;
+ }
- int newCountReady = Room?.Users.Count(u => u.State == MultiplayerUserState.Ready) ?? 0;
- int newCountTotal = Room?.Users.Count(u => u.State != MultiplayerUserState.Spectating) ?? 0;
+ var localUser = multiplayerClient.LocalUser;
switch (localUser?.State)
{
default:
- button.Text = "Ready";
- updateButtonColour(true);
+ setGreen();
break;
case MultiplayerUserState.Spectating:
case MultiplayerUserState.Ready:
- string countText = $"({newCountReady} / {newCountTotal} ready)";
-
- if (Room?.Host?.Equals(localUser) == true)
- {
- button.Text = $"Start match {countText}";
- updateButtonColour(true);
- }
+ if (room?.Host?.Equals(localUser) == true && room.Countdown == null)
+ setGreen();
else
- {
- button.Text = $"Waiting for host... {countText}";
- updateButtonColour(false);
- }
+ setYellow();
break;
}
- bool enableButton =
- Room?.State == MultiplayerRoomState.Open
- && CurrentPlaylistItem.Value?.ID == Room.Settings.PlaylistItemId
- && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired
- && !operationInProgress.Value;
-
- // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready.
- if (localUser?.State == MultiplayerUserState.Spectating)
- enableButton &= Room?.Host?.Equals(localUser) == true && newCountReady > 0;
-
- button.Enabled.Value = enableButton;
-
- if (newCountReady == countReady)
- return;
-
- readySampleDelegate?.Cancel();
- readySampleDelegate = Schedule(() =>
+ void setYellow()
{
- if (newCountReady > countReady)
- {
- if (newCountReady == newCountTotal)
- sampleReadyAll?.Play();
- else
- sampleReady?.Play();
- }
- else if (newCountReady < countReady)
- {
- sampleUnready?.Play();
- }
-
- countReady = newCountReady;
- });
- }
-
- private void updateButtonColour(bool green)
- {
- if (green)
- {
- button.BackgroundColour = colours.Green;
- button.Triangles.ColourDark = colours.Green;
- button.Triangles.ColourLight = colours.GreenLight;
+ BackgroundColour = colours.YellowDark;
+ Triangles.ColourDark = colours.YellowDark;
+ Triangles.ColourLight = colours.Yellow;
}
- else
+
+ void setGreen()
{
- button.BackgroundColour = colours.YellowDark;
- button.Triangles.ColourDark = colours.YellowDark;
- button.Triangles.ColourLight = colours.Yellow;
+ BackgroundColour = colours.Green;
+ Triangles.ColourDark = colours.Green;
+ Triangles.ColourLight = colours.GreenLight;
}
}
- private class ButtonWithTrianglesExposed : ReadyButton
+ protected override void Dispose(bool isDisposing)
{
- public new Triangles Triangles => base.Triangles;
+ base.Dispose(isDisposing);
+
+ if (multiplayerClient != null)
+ multiplayerClient.RoomUpdated -= onRoomUpdated;
+ }
+
+ public override LocalisableString TooltipText
+ {
+ get
+ {
+ if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready)
+ return "Cancel countdown";
+
+ return base.TooltipText;
+ }
}
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
index db99c6a5d5..d939fbf400 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -15,11 +14,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
public class MultiplayerSpectateButton : MultiplayerRoomComposite
{
- public Action OnSpectateClick
- {
- set => button.Action = value;
- }
-
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
@@ -37,9 +31,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
RelativeSizeAxes = Axes.Both,
Size = Vector2.One,
Enabled = { Value = true },
+ Action = onClick
};
}
+ private void onClick()
+ {
+ var clickOperation = ongoingOperationTracker.BeginOperation();
+
+ Client.ToggleSpectate().ContinueWith(t => endOperation());
+
+ void endOperation() => clickOperation?.Dispose();
+ }
+
[BackgroundDependencyLoader]
private void load()
{
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
index eeafebfec0..879a21e7c1 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
@@ -7,7 +7,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
-using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
@@ -25,6 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
///
public Action RequestEdit;
+ private MultiplayerPlaylistTabControl playlistTabControl;
private MultiplayerQueueList queueList;
private MultiplayerHistoryList historyList;
private bool firstPopulation = true;
@@ -36,7 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
InternalChildren = new Drawable[]
{
- new OsuTabControl
+ playlistTabControl = new MultiplayerPlaylistTabControl
{
RelativeSizeAxes = Axes.X,
Height = tab_control_height,
@@ -64,6 +64,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
}
}
};
+
+ playlistTabControl.QueueItems.BindTarget = queueList.Items;
}
protected override void LoadComplete()
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs
new file mode 100644
index 0000000000..583a05839f
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs
@@ -0,0 +1,39 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Bindables;
+using osu.Framework.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.Rooms;
+
+namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
+{
+ public class MultiplayerPlaylistTabControl : OsuTabControl
+ {
+ public readonly IBindableList QueueItems = new BindableList();
+
+ protected override TabItem CreateTabItem(MultiplayerPlaylistDisplayMode value)
+ {
+ if (value == MultiplayerPlaylistDisplayMode.Queue)
+ return new QueueTabItem { QueueItems = { BindTarget = QueueItems } };
+
+ return base.CreateTabItem(value);
+ }
+
+ private class QueueTabItem : OsuTabItem
+ {
+ public readonly IBindableList QueueItems = new BindableList();
+
+ public QueueTabItem()
+ : base(MultiplayerPlaylistDisplayMode.Queue)
+ {
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ QueueItems.BindCollectionChanged((_, __) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true);
+ }
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
index c78dcb7cb6..e53153e017 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
@@ -1,11 +1,9 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
-using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -46,14 +44,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
[Resolved]
private MultiplayerClient client { get; set; }
- [Resolved]
- private OngoingOperationTracker ongoingOperationTracker { get; set; }
-
private readonly IBindable isConnected = new Bindable();
- [CanBeNull]
- private IDisposable readyClickOperation;
-
private AddItemButton addItemButton;
public MultiplayerMatchSubScreen(Room room)
@@ -230,11 +222,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit));
}
- protected override Drawable CreateFooter() => new MultiplayerMatchFooter
- {
- OnReadyClick = onReadyClick,
- OnSpectateClick = onSpectateClick
- };
+ protected override Drawable CreateFooter() => new MultiplayerMatchFooter();
protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room);
@@ -332,52 +320,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
}
- private void onReadyClick()
- {
- Debug.Assert(readyClickOperation == null);
- readyClickOperation = ongoingOperationTracker.BeginOperation();
-
- if (client.IsHost && (client.LocalUser?.State == MultiplayerUserState.Ready || client.LocalUser?.State == MultiplayerUserState.Spectating))
- {
- client.StartMatch()
- .ContinueWith(t =>
- {
- // accessing Exception here silences any potential errors from the antecedent task
- if (t.Exception != null)
- {
- // gameplay was not started due to an exception; unblock button.
- endOperation();
- }
-
- // gameplay is starting, the button will be unblocked on load requested.
- });
- return;
- }
-
- client.ToggleReady()
- .ContinueWith(t => endOperation());
-
- void endOperation()
- {
- readyClickOperation?.Dispose();
- readyClickOperation = null;
- }
- }
-
- private void onSpectateClick()
- {
- Debug.Assert(readyClickOperation == null);
- readyClickOperation = ongoingOperationTracker.BeginOperation();
-
- client.ToggleSpectate().ContinueWith(t => endOperation());
-
- void endOperation()
- {
- readyClickOperation?.Dispose();
- readyClickOperation = null;
- }
- }
-
private void onRoomUpdated()
{
// may happen if the client is kicked or otherwise removed from the room.
@@ -433,9 +375,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
return;
StartPlay();
-
- readyClickOperation?.Dispose();
- readyClickOperation = null;
}
protected override Screen CreateGameplayScreen()
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
index 7d2fe44c4e..f6f815a3cb 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
@@ -21,6 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
base.LoadComplete();
Client.RoomUpdated += invokeOnRoomUpdated;
+ Client.LoadRequested += invokeOnRoomLoadRequested;
Client.UserLeft += invokeUserLeft;
Client.UserKicked += invokeUserKicked;
Client.UserJoined += invokeUserJoined;
@@ -38,6 +39,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item));
private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item));
private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item));
+ private void invokeOnRoomLoadRequested() => Scheduler.AddOnce(OnRoomLoadRequested);
///
/// Invoked when a user has joined the room.
@@ -94,6 +96,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
}
+ ///
+ /// Invoked when the room requests the local user to load into gameplay.
+ ///
+ protected virtual void OnRoomLoadRequested()
+ {
+ }
+
protected override void Dispose(bool isDisposing)
{
if (Client != null)
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs
index 96a665f33d..7ba0a63856 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
-using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
@@ -187,9 +186,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
const double fade_time = 50;
var currentItem = Playlist.GetCurrentItem();
- Debug.Assert(currentItem != null);
-
- var ruleset = rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance();
+ var ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null;
int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null;
userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty;
@@ -201,15 +198,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
else
userModsDisplay.FadeOut(fade_time);
- if (Client.IsHost && !User.Equals(Client.LocalUser))
- kickButton.FadeIn(fade_time);
- else
- kickButton.FadeOut(fade_time);
-
- if (Room.Host?.Equals(User) == true)
- crown.FadeIn(fade_time);
- else
- crown.FadeOut(fade_time);
+ kickButton.Alpha = Client.IsHost && !User.Equals(Client.LocalUser) ? 1 : 0;
+ crown.Alpha = Room.Host?.Equals(User) == true ? 1 : 0;
// If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187
// This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix.
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs
index afb2111023..14b930f115 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -15,6 +16,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{
private FillFlowContainer panels;
+ [CanBeNull]
+ private ParticipantPanel currentHostPanel;
+
[BackgroundDependencyLoader]
private void load()
{
@@ -55,6 +59,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
// Add panels for all users new to the room.
foreach (var user in Room.Users.Except(panels.Select(p => p.User)))
panels.Add(new ParticipantPanel(user));
+
+ if (currentHostPanel == null || !currentHostPanel.User.Equals(Room.Host))
+ {
+ // Reset position of previous host back to normal, if one existing.
+ if (currentHostPanel != null && panels.Contains(currentHostPanel))
+ panels.SetLayoutPosition(currentHostPanel, 0);
+
+ currentHostPanel = null;
+
+ // Change position of new host to display above all participants.
+ if (Room.Host != null)
+ {
+ currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(Room.Host));
+
+ if (currentHostPanel != null)
+ panels.SetLayoutPosition(currentHostPanel, -1);
+ }
+ }
}
}
}
diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs
index bf1699dca0..c56d04d5ac 100644
--- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs
@@ -115,6 +115,8 @@ namespace osu.Game.Screens.OnlinePlay
this.FadeIn();
waves.Show();
+ Mods.SetDefault();
+
if (loungeSubScreen.IsCurrentScreen())
loungeSubScreen.OnEntering(last);
else
diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
index 542731cf93..dca50c07ad 100644
--- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
+++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
@@ -4,12 +4,16 @@
using System;
using System.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
+using osu.Game.Configuration;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osuTK;
@@ -19,11 +23,27 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
public class BarHitErrorMeter : HitErrorMeter
{
private const int judgement_line_width = 14;
- private const int judgement_line_height = 4;
+
+ [SettingSource("Judgement line thickness", "How thick the individual lines should be.")]
+ public BindableNumber JudgementLineThickness { get; } = new BindableNumber(4)
+ {
+ MinValue = 1,
+ MaxValue = 8,
+ Precision = 0.1f,
+ };
+
+ [SettingSource("Show moving average arrow", "Whether an arrow should move beneath the bar showing the average error.")]
+ public Bindable ShowMovingAverage { get; } = new BindableBool(true);
+
+ [SettingSource("Centre marker style", "How to signify the centre of the display")]
+ public Bindable CentreMarkerStyle { get; } = new Bindable(CentreMarkerStyles.Circle);
+
+ [SettingSource("Label style", "How to show early/late extremities")]
+ public Bindable LabelStyle { get; } = new Bindable(LabelStyles.Icons);
private SpriteIcon arrow;
- private SpriteIcon iconEarly;
- private SpriteIcon iconLate;
+ private Drawable labelEarly;
+ private Drawable labelLate;
private Container colourBarsEarly;
private Container colourBarsLate;
@@ -32,6 +52,18 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
private double maxHitWindow;
+ private double floatingAverage;
+ private Container colourBars;
+ private Container arrowContainer;
+
+ private (HitResult result, double length)[] hitWindows;
+
+ private const int max_concurrent_judgements = 50;
+
+ private Drawable[] centreMarkerDrawables;
+
+ private const int centre_marker_size = 8;
+
public BarHitErrorMeter()
{
AutoSizeAxes = Axes.Both;
@@ -40,13 +72,11 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
[BackgroundDependencyLoader]
private void load()
{
- const int centre_marker_size = 8;
const int bar_height = 200;
const int bar_width = 2;
const float chevron_size = 8;
- const float icon_size = 14;
- var hitWindows = HitWindows.GetAllAvailableWindows().ToArray();
+ hitWindows = HitWindows.GetAllAvailableWindows().ToArray();
InternalChild = new Container
{
@@ -65,22 +95,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
RelativeSizeAxes = Axes.Y,
Children = new Drawable[]
{
- iconEarly = new SpriteIcon
- {
- Y = -10,
- Size = new Vector2(icon_size),
- Icon = FontAwesome.Solid.ShippingFast,
- Anchor = Anchor.TopCentre,
- Origin = Anchor.Centre,
- },
- iconLate = new SpriteIcon
- {
- Y = 10,
- Size = new Vector2(icon_size),
- Icon = FontAwesome.Solid.Bicycle,
- Anchor = Anchor.BottomCentre,
- Origin = Anchor.Centre,
- },
colourBarsEarly = new Container
{
Anchor = Anchor.Centre,
@@ -98,14 +112,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
RelativeSizeAxes = Axes.Y,
Height = 0.5f,
},
- new Circle
- {
- Name = "middle marker behind",
- Colour = GetColourForHitResult(hitWindows.Last().result),
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(centre_marker_size),
- },
judgementsContainer = new Container
{
Name = "judgements",
@@ -114,24 +120,18 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
RelativeSizeAxes = Axes.Y,
Width = judgement_line_width,
},
- new Circle
- {
- Name = "middle marker in front",
- Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f),
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(centre_marker_size),
- Scale = new Vector2(0.5f),
- },
}
},
- new Container
+ arrowContainer = new Container
{
Name = "average chevron",
Anchor = Anchor.CentreLeft,
- Origin = Anchor.CentreLeft,
+ Origin = Anchor.CentreRight,
Width = chevron_size,
+ X = chevron_size,
RelativeSizeAxes = Axes.Y,
+ Alpha = 0,
+ Scale = new Vector2(0, 1),
Child = arrow = new SpriteIcon
{
Anchor = Anchor.TopCentre,
@@ -155,8 +155,180 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
colourBars.Height = 0;
colourBars.ResizeHeightTo(1, 800, Easing.OutQuint);
- arrow.Alpha = 0;
- arrow.Delay(200).FadeInFromZero(600);
+ CentreMarkerStyle.BindValueChanged(style => recreateCentreMarker(style.NewValue), true);
+ LabelStyle.BindValueChanged(style => recreateLabels(style.NewValue), true);
+
+ // delay the appearance animations for only the initial appearance.
+ using (arrowContainer.BeginDelayedSequence(450))
+ {
+ ShowMovingAverage.BindValueChanged(visible =>
+ {
+ arrowContainer.FadeTo(visible.NewValue ? 1 : 0, 250, Easing.OutQuint);
+ arrowContainer.ScaleTo(visible.NewValue ? new Vector2(1) : new Vector2(0, 1), 250, Easing.OutQuint);
+ }, true);
+ }
+ }
+
+ private void recreateCentreMarker(CentreMarkerStyles style)
+ {
+ if (centreMarkerDrawables != null)
+ {
+ foreach (var d in centreMarkerDrawables)
+ {
+ d.ScaleTo(0, 500, Easing.OutQuint)
+ .FadeOut(500, Easing.OutQuint);
+
+ d.Expire();
+ }
+
+ centreMarkerDrawables = null;
+ }
+
+ switch (style)
+ {
+ case CentreMarkerStyles.None:
+ break;
+
+ case CentreMarkerStyles.Circle:
+ centreMarkerDrawables = new Drawable[]
+ {
+ new Circle
+ {
+ Name = "middle marker behind",
+ Colour = GetColourForHitResult(hitWindows.Last().result),
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Depth = float.MaxValue,
+ Size = new Vector2(centre_marker_size),
+ },
+ new Circle
+ {
+ Name = "middle marker in front",
+ Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f),
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Depth = float.MinValue,
+ Size = new Vector2(centre_marker_size / 2f),
+ },
+ };
+ break;
+
+ case CentreMarkerStyles.Line:
+ const float border_size = 1.5f;
+
+ centreMarkerDrawables = new Drawable[]
+ {
+ new Box
+ {
+ Name = "middle marker behind",
+ Colour = GetColourForHitResult(hitWindows.Last().result),
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Depth = float.MaxValue,
+ Size = new Vector2(judgement_line_width, centre_marker_size / 3f),
+ },
+ new Box
+ {
+ Name = "middle marker in front",
+ Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f),
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Depth = float.MinValue,
+ Size = new Vector2(judgement_line_width - border_size, centre_marker_size / 3f - border_size),
+ },
+ };
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(style), style, null);
+ }
+
+ if (centreMarkerDrawables != null)
+ {
+ foreach (var d in centreMarkerDrawables)
+ {
+ colourBars.Add(d);
+
+ d.FadeInFromZero(500, Easing.OutQuint)
+ .ScaleTo(0).ScaleTo(1, 1000, Easing.OutElasticHalf);
+ }
+ }
+ }
+
+ private void recreateLabels(LabelStyles style)
+ {
+ const float icon_size = 14;
+
+ labelEarly?.Expire();
+ labelEarly = null;
+
+ labelLate?.Expire();
+ labelLate = null;
+
+ switch (style)
+ {
+ case LabelStyles.None:
+ break;
+
+ case LabelStyles.Icons:
+ labelEarly = new SpriteIcon
+ {
+ Y = -10,
+ Size = new Vector2(icon_size),
+ Icon = FontAwesome.Solid.ShippingFast,
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.Centre,
+ };
+
+ labelLate = new SpriteIcon
+ {
+ Y = 10,
+ Size = new Vector2(icon_size),
+ Icon = FontAwesome.Solid.Bicycle,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.Centre,
+ };
+
+ break;
+
+ case LabelStyles.Text:
+ labelEarly = new OsuSpriteText
+ {
+ Y = -10,
+ Text = "Early",
+ Font = OsuFont.Default.With(size: 10),
+ Height = 12,
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.Centre,
+ };
+
+ labelLate = new OsuSpriteText
+ {
+ Y = 10,
+ Text = "Late",
+ Font = OsuFont.Default.With(size: 10),
+ Height = 12,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.Centre,
+ };
+
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(style), style, null);
+ }
+
+ if (labelEarly != null)
+ {
+ colourBars.Add(labelEarly);
+ labelEarly.FadeInFromZero(500);
+ }
+
+ if (labelLate != null)
+ {
+ colourBars.Add(labelLate);
+ labelLate.FadeInFromZero(500);
+ }
}
protected override void Update()
@@ -164,8 +336,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
base.Update();
// undo any layout rotation to display icons in the correct orientation
- iconEarly.Rotation = -Rotation;
- iconLate.Rotation = -Rotation;
+ if (labelEarly != null) labelEarly.Rotation = -Rotation;
+ if (labelLate != null) labelLate.Rotation = -Rotation;
}
private void createColourBars((HitResult result, double length)[] windows)
@@ -224,11 +396,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
}
}
- private double floatingAverage;
- private Container colourBars;
-
- private const int max_concurrent_judgements = 50;
-
protected override void OnNewJudgement(JudgementResult judgement)
{
const int arrow_move_duration = 800;
@@ -255,6 +422,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
judgementsContainer.Add(new JudgementLine
{
+ JudgementLineThickness = { BindTarget = JudgementLineThickness },
Y = getRelativeJudgementPosition(judgement.TimeOffset),
Colour = GetColourForHitResult(judgement.Type),
});
@@ -268,11 +436,12 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
internal class JudgementLine : CompositeDrawable
{
+ public readonly BindableNumber JudgementLineThickness = new BindableFloat();
+
public JudgementLine()
{
RelativeSizeAxes = Axes.X;
RelativePositionAxes = Axes.Y;
- Height = judgement_line_height;
Blending = BlendingParameters.Additive;
@@ -295,6 +464,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
Alpha = 0;
Width = 0;
+ JudgementLineThickness.BindValueChanged(thickness => Height = thickness.NewValue, true);
+
this
.FadeTo(0.6f, judgement_fade_in_duration, Easing.OutQuint)
.ResizeWidthTo(1, judgement_fade_in_duration, Easing.OutQuint)
@@ -306,5 +477,19 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
}
public override void Clear() => judgementsContainer.Clear();
+
+ public enum CentreMarkerStyles
+ {
+ None,
+ Circle,
+ Line
+ }
+
+ public enum LabelStyles
+ {
+ None,
+ Icons,
+ Text
+ }
}
}
diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs
index 4087011933..3da63ec2cc 100644
--- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs
+++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs
@@ -84,7 +84,7 @@ namespace osu.Game.Screens.Play.HUD
protected override bool OnMouseMove(MouseMoveEvent e)
{
- positionalAdjust = Vector2.Distance(e.ScreenSpaceMousePosition, button.ScreenSpaceDrawQuad.Centre) / 200;
+ positionalAdjust = Vector2.Distance(e.MousePosition, button.ToSpaceOfOtherDrawable(button.DrawRectangle.Centre, Parent)) / 100;
return base.OnMouseMove(e);
}
diff --git a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs
index c0d0ea0721..7a1f724cfb 100644
--- a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs
+++ b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs
@@ -42,12 +42,10 @@ namespace osu.Game.Screens.Play.HUD
private const float alpha_when_invalid = 0.3f;
- [CanBeNull]
- [Resolved(CanBeNull = true)]
+ [Resolved]
private ScoreProcessor scoreProcessor { get; set; }
- [Resolved(CanBeNull = true)]
- [CanBeNull]
+ [Resolved]
private GameplayState gameplayState { get; set; }
[CanBeNull]
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index cb8f4b6020..73bdeb5783 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -615,16 +615,22 @@ namespace osu.Game.Screens.Play
/// The destination time to seek to.
internal void NonFrameStableSeek(double time)
{
- if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed)
- frameStablePlaybackResetDelegate.RunTask();
+ // TODO: This schedule should not be required and is a temporary hotfix.
+ // See https://github.com/ppy/osu/issues/17267 for the issue.
+ // See https://github.com/ppy/osu/pull/17302 for a better fix which needs some more time.
+ ScheduleAfterChildren(() =>
+ {
+ if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed)
+ frameStablePlaybackResetDelegate.RunTask();
- bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
- DrawableRuleset.FrameStablePlayback = false;
+ bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
+ DrawableRuleset.FrameStablePlayback = false;
- Seek(time);
+ Seek(time);
- // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
- frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
+ // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
+ frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
+ });
}
///
diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs
index 41eb822e39..ba720af2a1 100644
--- a/osu.Game/Screens/Play/PlayerLoader.cs
+++ b/osu.Game/Screens/Play/PlayerLoader.cs
@@ -1,10 +1,11 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+#nullable enable
+
using System;
using System.Diagnostics;
using System.Threading.Tasks;
-using JetBrains.Annotations;
using ManagedBass.Fx;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@@ -48,31 +49,31 @@ namespace osu.Game.Screens.Play
public override bool HandlePositionalInput => true;
// We show the previous screen status
- protected override UserActivity InitialActivity => null;
+ protected override UserActivity? InitialActivity => null;
protected override bool PlayResumeSound => false;
- protected BeatmapMetadataDisplay MetadataInfo { get; private set; }
+ protected BeatmapMetadataDisplay MetadataInfo { get; private set; } = null!;
///
/// A fill flow containing the player settings groups, exposed for the ability to hide it from inheritors of the player loader.
///
- protected FillFlowContainer PlayerSettings { get; private set; }
+ protected FillFlowContainer PlayerSettings { get; private set; } = null!;
- protected VisualSettings VisualSettings { get; private set; }
+ protected VisualSettings VisualSettings { get; private set; } = null!;
- protected AudioSettings AudioSettings { get; private set; }
+ protected AudioSettings AudioSettings { get; private set; } = null!;
- protected Task LoadTask { get; private set; }
+ protected Task? LoadTask { get; private set; }
- protected Task DisposalTask { get; private set; }
+ protected Task? DisposalTask { get; private set; }
private bool backgroundBrightnessReduction;
private readonly BindableDouble volumeAdjustment = new BindableDouble(1);
- private AudioFilter lowPassFilter;
- private AudioFilter highPassFilter;
+ private AudioFilter lowPassFilter = null!;
+ private AudioFilter highPassFilter = null!;
protected bool BackgroundBrightnessReduction
{
@@ -90,47 +91,49 @@ namespace osu.Game.Screens.Play
private bool readyForPush =>
!playerConsumed
// don't push unless the player is completely loaded
- && player?.LoadState == LoadState.Ready
+ && CurrentPlayer?.LoadState == LoadState.Ready
// don't push if the user is hovering one of the panes, unless they are idle.
&& (IsHovered || idleTracker.IsIdle.Value)
// don't push if the user is dragging a slider or otherwise.
- && inputManager?.DraggedDrawable == null
+ && inputManager.DraggedDrawable == null
// don't push if a focused overlay is visible, like settings.
- && inputManager?.FocusedDrawable == null;
+ && inputManager.FocusedDrawable == null;
private readonly Func createPlayer;
- private Player player;
+ ///
+ /// The instance being loaded by this screen.
+ ///
+ public Player? CurrentPlayer { get; private set; }
///
- /// Whether the curent player instance has been consumed via .
+ /// Whether the current player instance has been consumed via .
///
private bool playerConsumed;
- private LogoTrackingContainer content;
+ private LogoTrackingContainer content = null!;
private bool hideOverlays;
- private InputManager inputManager;
+ private InputManager inputManager = null!;
- private IdleTracker idleTracker;
+ private IdleTracker idleTracker = null!;
- private ScheduledDelegate scheduledPushPlayer;
+ private ScheduledDelegate? scheduledPushPlayer;
- [CanBeNull]
- private EpilepsyWarning epilepsyWarning;
+ private EpilepsyWarning? epilepsyWarning;
[Resolved(CanBeNull = true)]
- private NotificationOverlay notificationOverlay { get; set; }
+ private NotificationOverlay? notificationOverlay { get; set; }
[Resolved(CanBeNull = true)]
- private VolumeOverlay volumeOverlay { get; set; }
+ private VolumeOverlay? volumeOverlay { get; set; }
[Resolved]
- private AudioManager audioManager { get; set; }
+ private AudioManager audioManager { get; set; } = null!;
[Resolved(CanBeNull = true)]
- private BatteryInfo batteryInfo { get; set; }
+ private BatteryInfo? batteryInfo { get; set; }
public PlayerLoader(Func createPlayer)
{
@@ -237,12 +240,14 @@ namespace osu.Game.Screens.Play
{
base.OnResuming(last);
- var lastScore = player.Score;
+ Debug.Assert(CurrentPlayer != null);
+
+ var lastScore = CurrentPlayer.Score;
AudioSettings.ReferenceScore.Value = lastScore?.ScoreInfo;
// prepare for a retry.
- player = null;
+ CurrentPlayer = null;
playerConsumed = false;
cancelLoad();
@@ -344,9 +349,10 @@ namespace osu.Game.Screens.Play
private Player consumePlayer()
{
Debug.Assert(!playerConsumed);
+ Debug.Assert(CurrentPlayer != null);
playerConsumed = true;
- return player;
+ return CurrentPlayer;
}
private void prepareNewPlayer()
@@ -354,11 +360,11 @@ namespace osu.Game.Screens.Play
if (!this.IsCurrentScreen())
return;
- player = createPlayer();
- player.RestartCount = restartCount++;
- player.RestartRequested = restartRequested;
+ CurrentPlayer = createPlayer();
+ CurrentPlayer.RestartCount = restartCount++;
+ CurrentPlayer.RestartRequested = restartRequested;
- LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false);
+ LoadTask = LoadComponentAsync(CurrentPlayer, _ => MetadataInfo.Loading = false);
}
private void restartRequested()
@@ -472,7 +478,7 @@ namespace osu.Game.Screens.Play
if (isDisposing)
{
// if the player never got pushed, we should explicitly dispose it.
- DisposalTask = LoadTask?.ContinueWith(_ => player?.Dispose());
+ DisposalTask = LoadTask?.ContinueWith(_ => CurrentPlayer?.Dispose());
}
}
@@ -480,7 +486,7 @@ namespace osu.Game.Screens.Play
#region Mute warning
- private Bindable muteWarningShownOnce;
+ private Bindable muteWarningShownOnce = null!;
private int restartCount;
@@ -535,7 +541,7 @@ namespace osu.Game.Screens.Play
#region Low battery warning
- private Bindable batteryWarningShownOnce;
+ private Bindable batteryWarningShownOnce = null!;
private void showBatteryWarningIfNeeded()
{
diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs
index b27a9c5f5d..e620abb90f 100644
--- a/osu.Game/Screens/Play/SongProgress.cs
+++ b/osu.Game/Screens/Play/SongProgress.cs
@@ -73,9 +73,12 @@ namespace osu.Game.Screens.Play
[Resolved(canBeNull: true)]
private Player player { get; set; }
- [Resolved(canBeNull: true)]
+ [Resolved]
private GameplayClock gameplayClock { get; set; }
+ [Resolved(canBeNull: true)]
+ private DrawableRuleset drawableRuleset { get; set; }
+
private IClock referenceClock;
public bool UsesFixedAnchor { get; set; }
@@ -113,7 +116,7 @@ namespace osu.Game.Screens.Play
}
[BackgroundDependencyLoader(true)]
- private void load(OsuColour colours, OsuConfigManager config, DrawableRuleset drawableRuleset)
+ private void load(OsuColour colours, OsuConfigManager config)
{
base.LoadComplete();
diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs
index 7e39708e65..5b3129dad6 100644
--- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs
+++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs
@@ -65,10 +65,12 @@ namespace osu.Game.Screens.Ranking.Expanded
var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata;
string creator = metadata.Author.Username;
+ int? beatmapMaxCombo = scoreManager.GetMaximumAchievableComboAsync(score).GetResultSafely();
+
var topStatistics = new List
{
new AccuracyStatistic(score.Accuracy),
- new ComboStatistic(score.MaxCombo, !score.Statistics.TryGetValue(HitResult.Miss, out int missCount) || missCount == 0),
+ new ComboStatistic(score.MaxCombo, beatmapMaxCombo),
new PerformanceStatistic(score),
};
@@ -80,8 +82,6 @@ namespace osu.Game.Screens.Ranking.Expanded
statisticDisplays.AddRange(topStatistics);
statisticDisplays.AddRange(bottomStatistics);
- var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely();
-
AddInternal(new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
@@ -224,6 +224,8 @@ namespace osu.Game.Screens.Ranking.Expanded
if (score.Date != default)
AddInternal(new PlayedOnText(score.Date));
+ var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely();
+
if (starDifficulty != null)
{
starAndModDisplay.Add(new StarRatingDisplay(starDifficulty.Value)
diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs
index b92c244174..0e42ec026a 100644
--- a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs
+++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs
@@ -25,11 +25,11 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics
/// Creates a new .
///
/// The combo to be displayed.
- /// Whether this is a perfect combo.
- public ComboStatistic(int combo, bool isPerfect)
- : base("combo", combo)
+ /// The maximum value of .
+ public ComboStatistic(int combo, int? maxCombo)
+ : base("combo", combo, maxCombo)
{
- this.isPerfect = isPerfect;
+ isPerfect = combo == maxCombo;
}
public override void Appear()
diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs
index c7033d37dc..f7b415e886 100644
--- a/osu.Game/Skinning/DefaultLegacySkin.cs
+++ b/osu.Game/Skinning/DefaultLegacySkin.cs
@@ -30,11 +30,9 @@ namespace osu.Game.Skinning
public DefaultLegacySkin(SkinInfo skin, IStorageResourceProvider resources)
: base(
skin,
- new NamespacedResourceStore(resources.Resources, "Skins/Legacy"),
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 LegacyDatabasedSkinResourceStore(skin, resources.Files).GetStream("skin.ini")
+ // In the case of the actual default legacy skin (ie. the fallback one, which a user hasn't applied any modifications to) we want to use the game provided resources.
+ skin.Protected ? new NamespacedResourceStore(resources.Resources, "Skins/Legacy") : null
)
{
Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255);
diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs
index 483e365e78..756f229927 100644
--- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs
+++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs
@@ -2,24 +2,17 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
-using osu.Framework.Utils;
-using osu.Game.Beatmaps;
+using osu.Framework.Logging;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
-using osu.Game.Rulesets;
-using osu.Game.Rulesets.Difficulty;
-using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Scoring;
-using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Components;
using osuTK;
@@ -29,26 +22,19 @@ namespace osu.Game.Skinning.Editor
{
public Action RequestPlacement;
- [Cached]
- private ScoreProcessor scoreProcessor = new ScoreProcessor(new DummyRuleset())
- {
- Combo = { Value = RNG.Next(1, 1000) },
- TotalScore = { Value = RNG.Next(1000, 10000000) }
- };
+ private readonly CompositeDrawable target;
- [Cached(typeof(HealthProcessor))]
- private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
-
- public SkinComponentToolbox()
+ public SkinComponentToolbox(CompositeDrawable target = null)
: base("Components")
{
+ this.target = target;
}
+ private FillFlowContainer fill;
+
[BackgroundDependencyLoader]
private void load()
{
- FillFlowContainer fill;
-
Child = fill = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
@@ -57,25 +43,24 @@ namespace osu.Game.Skinning.Editor
Spacing = new Vector2(2)
};
+ reloadComponents();
+ }
+
+ private void reloadComponents()
+ {
+ fill.Clear();
+
var skinnableTypes = typeof(OsuGame).Assembly.GetTypes()
- .Where(t => !t.IsInterface)
+ .Where(t => !t.IsInterface && !t.IsAbstract)
.Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t))
.OrderBy(t => t.Name)
.ToArray();
foreach (var type in skinnableTypes)
- {
- var component = attemptAddComponent(type);
-
- if (component != null)
- {
- component.RequestPlacement = t => RequestPlacement?.Invoke(t);
- fill.Add(component);
- }
- }
+ attemptAddComponent(type);
}
- private static ToolboxComponentButton attemptAddComponent(Type type)
+ private void attemptAddComponent(Type type)
{
try
{
@@ -83,14 +68,21 @@ namespace osu.Game.Skinning.Editor
Debug.Assert(instance != null);
- if (!((ISkinnableDrawable)instance).IsEditable)
- return null;
+ if (!((ISkinnableDrawable)instance).IsEditable) return;
- return new ToolboxComponentButton(instance);
+ fill.Add(new ToolboxComponentButton(instance, target)
+ {
+ RequestPlacement = t => RequestPlacement?.Invoke(t)
+ });
}
- catch
+ catch (DependencyNotRegisteredException)
{
- return null;
+ // This loading code relies on try-catching any dependency injection errors to know which components are valid for the current target screen.
+ // If a screen can't provide the required dependencies, a skinnable component should not be displayed in the list.
+ }
+ catch (Exception e)
+ {
+ Logger.Error(e, $"Skin component {type} could not be loaded in the editor component list due to an error");
}
}
@@ -101,6 +93,7 @@ namespace osu.Game.Skinning.Editor
public override bool PropagateNonPositionalInputSubTree => false;
private readonly Drawable component;
+ private readonly CompositeDrawable dependencySource;
public Action RequestPlacement;
@@ -109,9 +102,10 @@ namespace osu.Game.Skinning.Editor
private const float contracted_size = 60;
private const float expanded_size = 120;
- public ToolboxComponentButton(Drawable component)
+ public ToolboxComponentButton(Drawable component, CompositeDrawable dependencySource)
{
this.component = component;
+ this.dependencySource = dependencySource;
Enabled.Value = true;
@@ -143,7 +137,7 @@ namespace osu.Game.Skinning.Editor
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(10) { Bottom = 20 },
Masking = true,
- Child = innerContainer = new Container
+ Child = innerContainer = new DependencyBorrowingContainer(dependencySource)
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
@@ -186,14 +180,17 @@ namespace osu.Game.Skinning.Editor
}
}
- private class DummyRuleset : Ruleset
+ public class DependencyBorrowingContainer : Container
{
- public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException();
- public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException();
- public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException();
- public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException();
- public override string Description => string.Empty;
- public override string ShortName => string.Empty;
+ private readonly CompositeDrawable donor;
+
+ public DependencyBorrowingContainer(CompositeDrawable donor)
+ {
+ this.donor = donor;
+ }
+
+ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
+ new DependencyContainer(donor?.Dependencies ?? base.CreateChildDependencies(parent));
}
}
}
diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index f7e5aeecdf..4cc7e0bcdb 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -11,7 +11,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Testing;
-using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
@@ -50,7 +49,8 @@ namespace osu.Game.Skinning.Editor
private Container content;
- private EditorSidebarSection settingsToolbox;
+ private EditorSidebar componentsSidebar;
+ private EditorSidebar settingsSidebar;
public SkinEditor()
{
@@ -146,32 +146,13 @@ namespace osu.Game.Skinning.Editor
{
new Drawable[]
{
- new EditorSidebar
- {
- Children = new[]
- {
- new SkinComponentToolbox
- {
- RequestPlacement = placeComponent
- },
- }
- },
+ componentsSidebar = new EditorSidebar(),
content = new Container
{
Depth = float.MaxValue,
RelativeSizeAxes = Axes.Both,
},
- new EditorSidebar
- {
- Children = new[]
- {
- settingsToolbox = new SkinSettingsToolbox
- {
- Anchor = Anchor.CentreRight,
- Origin = Anchor.CentreRight,
- }
- }
- },
+ settingsSidebar = new EditorSidebar(),
}
}
}
@@ -211,7 +192,15 @@ namespace osu.Game.Skinning.Editor
Scheduler.AddOnce(loadBlueprintContainer);
Scheduler.AddOnce(populateSettings);
- void loadBlueprintContainer() => content.Child = new SkinBlueprintContainer(targetScreen);
+ void loadBlueprintContainer()
+ {
+ content.Child = new SkinBlueprintContainer(targetScreen);
+
+ componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable)
+ {
+ RequestPlacement = placeComponent
+ };
+ }
}
private void skinChanged()
@@ -238,12 +227,7 @@ namespace osu.Game.Skinning.Editor
private void placeComponent(Type type)
{
- var target = availableTargets.FirstOrDefault()?.Target;
-
- if (target == null)
- return;
-
- var targetContainer = getTarget(target.Value);
+ var targetContainer = getFirstTarget();
if (targetContainer == null)
return;
@@ -266,18 +250,16 @@ namespace osu.Game.Skinning.Editor
private void populateSettings()
{
- settingsToolbox.Clear();
+ settingsSidebar.Clear();
- var first = SelectedComponents.OfType().FirstOrDefault();
-
- if (first != null)
- {
- settingsToolbox.Children = first.CreateSettingsControls().ToArray();
- }
+ foreach (var component in SelectedComponents.OfType())
+ settingsSidebar.Add(new SkinSettingsToolbox(component));
}
private IEnumerable availableTargets => targetScreen.ChildrenOfType();
+ private ISkinnableTarget getFirstTarget() => availableTargets.FirstOrDefault();
+
private ISkinnableTarget getTarget(SkinnableTarget target)
{
return availableTargets.FirstOrDefault(c => c.Target == target);
diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
index 9fc233d3e3..497283a820 100644
--- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
+++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
@@ -19,15 +19,15 @@ namespace osu.Game.Skinning.Editor
/// A container which handles loading a skin editor on user request for a specified target.
/// This also handles the scaling / positioning adjustment of the target.
///
- public class SkinEditorOverlay : CompositeDrawable, IKeyBindingHandler
+ public class SkinEditorOverlay : OverlayContainer, IKeyBindingHandler
{
private readonly ScalingContainer scalingContainer;
+ protected override bool BlockNonPositionalInput => true;
+
[CanBeNull]
private SkinEditor skinEditor;
- public const float VISIBLE_TARGET_SCALE = 0.8f;
-
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
@@ -49,33 +49,13 @@ namespace osu.Game.Skinning.Editor
Hide();
return true;
-
- case GlobalAction.ToggleSkinEditor:
- Toggle();
- return true;
}
return false;
}
- public void Toggle()
+ protected override void PopIn()
{
- if (skinEditor == null)
- Show();
- else
- skinEditor.ToggleVisibility();
- }
-
- public override void Hide()
- {
- // base call intentionally omitted.
- skinEditor?.Hide();
- }
-
- public override void Show()
- {
- // base call intentionally omitted as we have custom behaviour.
-
if (skinEditor != null)
{
skinEditor.Show();
@@ -83,29 +63,24 @@ namespace osu.Game.Skinning.Editor
}
var editor = new SkinEditor();
+
editor.State.BindValueChanged(visibility => updateComponentVisibility());
skinEditor = editor;
- // Schedule ensures that if `Show` is called before this overlay is loaded,
- // it will not throw (LoadComponentAsync requires the load target to be in a loaded state).
- Schedule(() =>
+ LoadComponentAsync(editor, _ =>
{
if (editor != skinEditor)
return;
- LoadComponentAsync(editor, _ =>
- {
- if (editor != skinEditor)
- return;
+ AddInternal(editor);
- AddInternal(editor);
-
- SetTarget(lastTargetScreen);
- });
+ SetTarget(lastTargetScreen);
});
}
+ protected override void PopOut() => skinEditor?.Hide();
+
private void updateComponentVisibility()
{
Debug.Assert(skinEditor != null);
diff --git a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs
index 5da6147e4c..d126eff075 100644
--- a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs
+++ b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs
@@ -94,7 +94,7 @@ namespace osu.Game.Skinning.Editor
var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod();
if (replayGeneratingMod != null)
- screen.Push(new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateReplayScore(beatmap, mods)));
+ screen.Push(new PlayerLoader(() => new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateReplayScore(beatmap, mods))));
}, new[] { typeof(Player), typeof(SongSelect) })
},
}
@@ -104,7 +104,7 @@ namespace osu.Game.Skinning.Editor
};
}
- private class SceneButton : OsuButton
+ public class SceneButton : OsuButton
{
public SceneButton()
{
diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
index bd6d097eb2..d7fb5c0498 100644
--- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
+++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
@@ -199,6 +199,12 @@ namespace osu.Game.Skinning.Editor
Items = createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray()
};
+ yield return new OsuMenuItem("Reset position", MenuItemType.Standard, () =>
+ {
+ foreach (var blueprint in SelectedBlueprints)
+ ((Drawable)blueprint.Item).Position = Vector2.Zero;
+ });
+
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;
diff --git a/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs b/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs
index fc06d3647a..d2823ed0e4 100644
--- a/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs
+++ b/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs
@@ -1,8 +1,10 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Game.Configuration;
using osu.Game.Screens.Edit.Components;
using osuTK;
@@ -12,8 +14,8 @@ namespace osu.Game.Skinning.Editor
{
protected override Container Content { get; }
- public SkinSettingsToolbox()
- : base("Settings")
+ public SkinSettingsToolbox(Drawable component)
+ : base($"Settings ({component.GetType().Name})")
{
base.Content.Add(Content = new FillFlowContainer
{
@@ -21,6 +23,7 @@ namespace osu.Game.Skinning.Editor
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(10),
+ Children = component.CreateSettingsControls().ToArray()
});
}
}
diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs
index f80a980351..e3685c986b 100644
--- a/osu.Game/Skinning/LegacyBeatmapSkin.cs
+++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using JetBrains.Annotations;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -20,13 +21,27 @@ namespace osu.Game.Skinning
protected override bool AllowManiaSkin => false;
protected override bool UseCustomSampleBanks => true;
- public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, IResourceStore storage, IStorageResourceProvider resources)
- : base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path)
+ ///
+ /// Construct a new legacy beatmap skin instance.
+ ///
+ /// The model for this beatmap.
+ /// Access to raw game resources.
+ public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, [CanBeNull] IStorageResourceProvider resources)
+ : base(createSkinInfo(beatmapInfo), resources, createRealmBackedStore(beatmapInfo, resources), beatmapInfo.Path)
{
// Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer)
Configuration.AllowDefaultComboColoursFallback = false;
}
+ private static IResourceStore createRealmBackedStore(BeatmapInfo beatmapInfo, [CanBeNull] IStorageResourceProvider resources)
+ {
+ if (resources == null)
+ // should only ever be used in tests.
+ return new ResourceStore();
+
+ return new RealmBackedResourceStore(beatmapInfo.BeatmapSet, resources.Files, new[] { @"ogg" });
+ }
+
public override Drawable GetDrawableComponent(ISkinComponent component)
{
if (component is SkinnableTargetComponent targetComponent)
@@ -77,6 +92,10 @@ namespace osu.Game.Skinning
}
private static SkinInfo createSkinInfo(BeatmapInfo beatmapInfo) =>
- new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata.Author.Username ?? string.Empty };
+ new SkinInfo
+ {
+ Name = beatmapInfo.ToString(),
+ Creator = beatmapInfo.Metadata.Author.Username ?? string.Empty
+ };
}
}
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index 359d9e5624..244774fd4c 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -15,6 +15,7 @@ using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Game.Audio;
using osu.Game.Beatmaps.Formats;
+using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
@@ -27,12 +28,6 @@ namespace osu.Game.Skinning
{
public class LegacySkin : Skin
{
- [CanBeNull]
- protected TextureStore Textures;
-
- [CanBeNull]
- protected ISampleStore Samples;
-
///
/// Whether texture for the keys exists.
/// Used to determine if the mania ruleset is skinned.
@@ -51,7 +46,7 @@ namespace osu.Game.Skinning
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
- : this(skin, new LegacyDatabasedSkinResourceStore(skin, resources.Files), resources, "skin.ini")
+ : this(skin, resources, null)
{
}
@@ -59,36 +54,12 @@ namespace osu.Game.Skinning
/// Construct a new legacy skin instance.
///
/// The model for this skin.
- /// A storage for looking up files within this skin using user-facing filenames.
/// Access to raw game resources.
+ /// An optional store which will be used for looking up skin resources. If null, one will be created from realm pattern.
/// The user-facing filename of the configuration file to be parsed. Can accept an .osu or skin.ini file.
- protected LegacySkin(SkinInfo skin, [CanBeNull] IResourceStore storage, [CanBeNull] IStorageResourceProvider resources, string configurationFilename)
- : this(skin, storage, resources, string.IsNullOrEmpty(configurationFilename) ? null : storage?.GetStream(configurationFilename))
+ protected LegacySkin(SkinInfo skin, [CanBeNull] IStorageResourceProvider resources, [CanBeNull] IResourceStore storage, string configurationFilename = @"skin.ini")
+ : base(skin, resources, storage, configurationFilename)
{
- }
-
- ///
- /// Construct a new legacy skin instance.
- ///
- /// The model for this skin.
- /// A storage for looking up files within this skin using user-facing filenames.
- /// Access to raw game resources.
- /// An optional stream containing the contents of a skin.ini file.
- protected LegacySkin(SkinInfo skin, [CanBeNull] IResourceStore storage, [CanBeNull] IStorageResourceProvider resources, [CanBeNull] Stream configurationStream)
- : base(skin, resources, configurationStream)
- {
- if (storage != null)
- {
- var samples = resources?.AudioManager?.GetSampleStore(storage);
- if (samples != null)
- samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY;
-
- Samples = samples;
- Textures = new TextureStore(resources?.CreateTextureLoaderStore(storage));
-
- (storage as ResourceStore)?.AddExtension("ogg");
- }
-
// todo: this shouldn't really be duplicated here (from ManiaLegacySkinTransformer). we need to come up with a better solution.
hasKeyTexture = new Lazy(() => this.GetAnimation(
lookupForMania(new LegacyManiaSkinConfigurationLookup(4, LegacyManiaSkinConfigurationLookups.KeyImage, 0))?.Value ?? "mania-key1", true,
@@ -385,26 +356,15 @@ namespace osu.Game.Skinning
}
})
{
- Children = this.HasFont(LegacyFont.Score)
- ? new Drawable[]
- {
- new LegacyComboCounter(),
- new LegacyScoreCounter(),
- new LegacyAccuracyCounter(),
- new LegacyHealthDisplay(),
- new SongProgress(),
- new BarHitErrorMeter(),
- }
- : new Drawable[]
- {
- // TODO: these should fallback to using osu!classic skin textures, rather than doing this.
- new DefaultComboCounter(),
- new DefaultScoreCounter(),
- new DefaultAccuracyCounter(),
- new DefaultHealthDisplay(),
- new SongProgress(),
- new BarHitErrorMeter(),
- }
+ Children = new Drawable[]
+ {
+ new LegacyComboCounter(),
+ new LegacyScoreCounter(),
+ new LegacyAccuracyCounter(),
+ new LegacyHealthDisplay(),
+ new SongProgress(),
+ new BarHitErrorMeter(),
+ }
};
return skinnableTargetWrapper;
@@ -551,12 +511,5 @@ namespace osu.Game.Skinning
// Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/osu/approachcircle" -> "approachcircle").
yield return componentName.Split('/').Last();
}
-
- protected override void Dispose(bool isDisposing)
- {
- base.Dispose(isDisposing);
- Textures?.Dispose();
- Samples?.Dispose();
- }
}
}
diff --git a/osu.Game/Skinning/LegacySkinResourceStore.cs b/osu.Game/Skinning/LegacySkinResourceStore.cs
deleted file mode 100644
index 2487a469c8..0000000000
--- a/osu.Game/Skinning/LegacySkinResourceStore.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using osu.Framework.Extensions;
-using osu.Framework.IO.Stores;
-using osu.Game.Database;
-using osu.Game.Extensions;
-
-namespace osu.Game.Skinning
-{
- public class LegacySkinResourceStore : ResourceStore
- {
- private readonly IHasNamedFiles source;
-
- public LegacySkinResourceStore(IHasNamedFiles source, IResourceStore underlyingStore)
- : base(underlyingStore)
- {
- this.source = source;
- }
-
- protected override IEnumerable 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) =>
- source.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
-
- public override IEnumerable GetAvailableResources() => source.Files.Select(f => f.Filename);
- }
-}
diff --git a/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs
similarity index 72%
rename from osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs
rename to osu.Game/Skinning/RealmBackedResourceStore.cs
index cd90fea9bb..fc9036727f 100644
--- a/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs
+++ b/osu.Game/Skinning/RealmBackedResourceStore.cs
@@ -4,21 +4,29 @@
using System.Collections.Generic;
using osu.Framework.Extensions;
using osu.Framework.IO.Stores;
+using osu.Game.Database;
using osu.Game.Extensions;
namespace osu.Game.Skinning
{
- public class LegacyDatabasedSkinResourceStore : ResourceStore
+ public class RealmBackedResourceStore : ResourceStore
{
private readonly Dictionary fileToStoragePathMapping = new Dictionary();
- public LegacyDatabasedSkinResourceStore(SkinInfo source, IResourceStore underlyingStore)
+ public RealmBackedResourceStore(IHasRealmFiles source, IResourceStore underlyingStore, string[] extensions = null)
: base(underlyingStore)
{
+ // Must be initialised before the file cache.
+ if (extensions != null)
+ {
+ foreach (string extension in extensions)
+ AddExtension(extension);
+ }
+
initialiseFileCache(source);
}
- private void initialiseFileCache(SkinInfo source)
+ private void initialiseFileCache(IHasRealmFiles source)
{
fileToStoragePathMapping.Clear();
foreach (var f in source.Files)
diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs
index 931bdfed48..e00dd950a7 100644
--- a/osu.Game/Skinning/Skin.cs
+++ b/osu.Game/Skinning/Skin.cs
@@ -13,10 +13,10 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
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;
@@ -24,8 +24,19 @@ namespace osu.Game.Skinning
{
public abstract class Skin : IDisposable, ISkin
{
+ ///
+ /// A texture store which can be used to perform user file lookups for this skin.
+ ///
+ [CanBeNull]
+ protected TextureStore Textures { get; }
+
+ ///
+ /// A sample store which can be used to perform user file lookups for this skin.
+ ///
+ [CanBeNull]
+ protected ISampleStore Samples { get; }
+
public readonly Live SkinInfo;
- private readonly IStorageResourceProvider resources;
public SkinConfiguration Configuration { get; set; }
@@ -41,16 +52,32 @@ namespace osu.Game.Skinning
public abstract IBindable GetConfig(TLookup lookup);
- protected Skin(SkinInfo skin, IStorageResourceProvider resources, [CanBeNull] Stream configurationStream = null)
+ ///
+ /// Construct a new skin.
+ ///
+ /// The skin's metadata. Usually a live realm object.
+ /// Access to game-wide resources.
+ /// An optional store which will *replace* all file lookups that are usually sourced from .
+ /// An optional filename to read the skin configuration from. If not provided, the configuration will be retrieved from the storage using "skin.ini".
+ protected Skin(SkinInfo skin, [CanBeNull] IStorageResourceProvider resources, [CanBeNull] IResourceStore storage = null, [CanBeNull] string configurationFilename = @"skin.ini")
{
- SkinInfo = resources?.RealmAccess != null
- ? skin.ToLive(resources.RealmAccess)
- // This path should only be used in some tests.
- : skin.ToLiveUnmanaged();
+ if (resources != null)
+ {
+ SkinInfo = resources.RealmAccess != null
+ ? skin.ToLive(resources.RealmAccess)
+ : skin.ToLiveUnmanaged();
- this.resources = resources;
+ storage ??= new RealmBackedResourceStore(skin, resources.Files, new[] { @"ogg" });
- configurationStream ??= getConfigurationStream();
+ var samples = resources.AudioManager?.GetSampleStore(storage);
+ if (samples != null)
+ samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY;
+
+ Samples = samples;
+ Textures = new TextureStore(resources.CreateTextureLoaderStore(storage));
+ }
+
+ var configurationStream = storage?.GetStream(configurationFilename);
if (configurationStream != null)
// stream will be closed after use by LineBufferedReader.
@@ -59,40 +86,30 @@ namespace osu.Game.Skinning
Configuration = new SkinConfiguration();
// skininfo files may be null for default skin.
- SkinInfo.PerformRead(s =>
+ foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
{
- // we may want to move this to some kind of async operation in the future.
- foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
+ string filename = $"{skinnableTarget}.json";
+
+ byte[] bytes = storage?.Get(filename);
+
+ if (bytes == null)
+ continue;
+
+ try
{
- string filename = $"{skinnableTarget}.json";
+ string jsonContent = Encoding.UTF8.GetString(bytes);
+ var deserializedContent = JsonConvert.DeserializeObject>(jsonContent);
- // skininfo files may be null for default skin.
- var fileInfo = s.Files.FirstOrDefault(f => f.Filename == filename);
-
- if (fileInfo == null)
+ if (deserializedContent == null)
continue;
- byte[] bytes = resources?.Files.Get(fileInfo.File.GetStoragePath());
-
- if (bytes == null)
- continue;
-
- try
- {
- string jsonContent = Encoding.UTF8.GetString(bytes);
- var deserializedContent = JsonConvert.DeserializeObject>(jsonContent);
-
- if (deserializedContent == null)
- continue;
-
- DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
- }
- catch (Exception ex)
- {
- Logger.Error(ex, "Failed to load skin configuration.");
- }
+ DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
}
- });
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "Failed to load skin configuration.");
+ }
+ }
}
protected virtual void ParseConfigurationStream(Stream stream)
@@ -101,16 +118,6 @@ namespace osu.Game.Skinning
Configuration = new LegacySkinDecoder().Decode(reader);
}
- private Stream getConfigurationStream()
- {
- string path = SkinInfo.PerformRead(s => s.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath());
-
- if (string.IsNullOrEmpty(path))
- return null;
-
- return resources?.Files.GetStream(path);
- }
-
///
/// Remove all stored customisations for the provided target.
///
@@ -168,6 +175,9 @@ namespace osu.Game.Skinning
return;
isDisposed = true;
+
+ Textures?.Dispose();
+ Samples?.Dispose();
}
#endregion
diff --git a/osu.Game/Stores/BeatmapImporter.cs b/osu.Game/Stores/BeatmapImporter.cs
index e6b655589c..f04a0210ef 100644
--- a/osu.Game/Stores/BeatmapImporter.cs
+++ b/osu.Game/Stores/BeatmapImporter.cs
@@ -163,6 +163,12 @@ namespace osu.Game.Stores
return existing.OnlineID == import.OnlineID && existingIds.SequenceEqual(importIds);
}
+ protected override void UndeleteForReuse(BeatmapSetInfo existing)
+ {
+ base.UndeleteForReuse(existing);
+ existing.DateAdded = DateTimeOffset.UtcNow;
+ }
+
public override bool IsAvailableLocally(BeatmapSetInfo model)
{
return Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID));
diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs
index 3011bc0320..1d0e16d549 100644
--- a/osu.Game/Stores/RealmArchiveModelImporter.cs
+++ b/osu.Game/Stores/RealmArchiveModelImporter.cs
@@ -351,7 +351,8 @@ namespace osu.Game.Stores
using (var transaction = realm.BeginWrite())
{
- existing.DeletePending = false;
+ if (existing.DeletePending)
+ UndeleteForReuse(existing);
transaction.Commit();
}
@@ -387,7 +388,9 @@ namespace osu.Game.Stores
{
LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import.");
- existing.DeletePending = false;
+ if (existing.DeletePending)
+ UndeleteForReuse(existing);
+
transaction.Commit();
return existing.ToLive(Realm);
@@ -527,6 +530,15 @@ namespace osu.Game.Stores
private bool checkAllFilesExist(TModel model) =>
model.Files.All(f => Files.Storage.Exists(f.File.GetStoragePath()));
+ ///
+ /// Called when an existing model is in a soft deleted state but being recovered.
+ ///
+ /// The existing model.
+ protected virtual void UndeleteForReuse(TModel existing)
+ {
+ existing.DeletePending = false;
+ }
+
///
/// Whether this specified path should be removed after successful import.
///
diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs
index 2a3e51b4f5..4667a385b3 100644
--- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs
+++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs
@@ -96,12 +96,14 @@ namespace osu.Game.Tests.Beatmaps
AddStep("setup skins", () =>
{
userSkinInfo.Files.Clear();
- userSkinInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = userFile }, userFile));
+ if (!string.IsNullOrEmpty(userFile))
+ userSkinInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = userFile }, userFile));
Debug.Assert(beatmapInfo.BeatmapSet != null);
beatmapInfo.BeatmapSet.Files.Clear();
- beatmapInfo.BeatmapSet.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = beatmapFile }, beatmapFile));
+ if (!string.IsNullOrEmpty(beatmapFile))
+ beatmapInfo.BeatmapSet.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = beatmapFile }, beatmapFile));
// Need to refresh the cached skin source to refresh the skin resource store.
dependencies.SkinSource = new SkinProvidingContainer(Skin = new LegacySkin(userSkinInfo, this));
@@ -191,22 +193,32 @@ namespace osu.Game.Tests.Beatmaps
}
}
- private class TestWorkingBeatmap : ClockBackedTestWorkingBeatmap
+ private class TestWorkingBeatmap : ClockBackedTestWorkingBeatmap, IStorageResourceProvider
{
private readonly BeatmapInfo skinBeatmapInfo;
- private readonly IResourceStore resourceStore;
private readonly IStorageResourceProvider resources;
- public TestWorkingBeatmap(BeatmapInfo skinBeatmapInfo, IResourceStore resourceStore, IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, IStorageResourceProvider resources)
+ public TestWorkingBeatmap(BeatmapInfo skinBeatmapInfo, IResourceStore accessMarkingResourceStore, IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock,
+ IStorageResourceProvider resources)
: base(beatmap, storyboard, referenceClock, resources.AudioManager)
{
this.skinBeatmapInfo = skinBeatmapInfo;
- this.resourceStore = resourceStore;
+ Files = accessMarkingResourceStore;
this.resources = resources;
}
- protected internal override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, resourceStore, resources);
+ protected internal override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, this);
+
+ public AudioManager AudioManager => resources.AudioManager;
+
+ public IResourceStore Files { get; }
+
+ public IResourceStore Resources => resources.Resources;
+
+ public RealmAccess RealmAccess => resources.RealmAccess;
+
+ public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => resources.CreateTextureLoaderStore(underlyingStore);
}
}
}
diff --git a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs
index 5c522058d9..597c5e9a2b 100644
--- a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs
+++ b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs
@@ -7,7 +7,6 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
-using osu.Framework.IO.Stores;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Skinning;
@@ -112,7 +111,7 @@ namespace osu.Game.Tests.Beatmaps
public static readonly Color4 HYPER_DASH_FRUIT_COLOUR = Color4.DarkGoldenrod;
public TestBeatmapSkin(BeatmapInfo beatmapInfo, bool hasColours)
- : base(beatmapInfo, new ResourceStore(), null)
+ : base(beatmapInfo, null)
{
if (hasColours)
{
@@ -141,7 +140,7 @@ namespace osu.Game.Tests.Beatmaps
public static readonly Color4 HYPER_DASH_FRUIT_COLOUR = Color4.LightCyan;
public TestSkin(bool hasCustomColours)
- : base(new SkinInfo(), new ResourceStore(), null, string.Empty)
+ : base(new SkinInfo(), null, null)
{
if (hasCustomColours)
{
diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs
index 24015590e2..51221cb8fe 100644
--- a/osu.Game/Tests/Visual/EditorTestScene.cs
+++ b/osu.Game/Tests/Visual/EditorTestScene.cs
@@ -7,10 +7,13 @@ using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;
+using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit;
@@ -93,6 +96,10 @@ namespace osu.Game.Tests.Visual
protected class TestEditor : Editor
{
+ [Resolved(canBeNull: true)]
+ [CanBeNull]
+ private DialogOverlay dialogOverlay { get; set; }
+
public new void Undo() => base.Undo();
public new void Redo() => base.Redo();
@@ -111,6 +118,18 @@ namespace osu.Game.Tests.Visual
public new bool HasUnsavedChanges => base.HasUnsavedChanges;
+ public override bool OnExiting(IScreen next)
+ {
+ // For testing purposes allow the screen to exit without saving on second attempt.
+ if (!ExitConfirmed && dialogOverlay?.CurrentDialog is PromptForSaveDialog saveDialog)
+ {
+ saveDialog.PerformAction();
+ return true;
+ }
+
+ return base.OnExiting(next);
+ }
+
public TestEditor(EditorLoader loader = null)
: base(loader)
{
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index 6dc5159b6f..9be1b18062 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -7,12 +7,15 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods;
@@ -114,12 +117,24 @@ namespace osu.Game.Tests.Visual.Multiplayer
public void ChangeUserState(int userId, MultiplayerUserState newState)
{
Debug.Assert(Room != null);
+
((IMultiplayerClient)this).UserStateChanged(userId, newState);
Schedule(() =>
{
switch (Room.State)
{
+ case MultiplayerRoomState.Open:
+ // If there are no remaining ready users or the host is not ready, stop any existing countdown.
+ // Todo: When we have an "automatic start" mode, this should also start a new countdown if any users _are_ ready.
+ // Todo: This doesn't yet support non-match-start countdowns.
+ bool shouldStopCountdown = Room.Users.All(u => u.State != MultiplayerUserState.Ready);
+ shouldStopCountdown |= Room.Host?.State != MultiplayerUserState.Ready && Room.Host?.State != MultiplayerUserState.Spectating;
+
+ if (shouldStopCountdown)
+ countdownStopSource?.Cancel();
+ break;
+
case MultiplayerRoomState.WaitingForLoad:
if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad))
{
@@ -282,6 +297,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
return Task.CompletedTask;
}
+ private CancellationTokenSource? countdownSkipSource;
+ private CancellationTokenSource? countdownStopSource;
+ private Task countdownTask = Task.CompletedTask;
+
+ ///
+ /// Skips to the end of the currently-running countdown, if one is running,
+ /// and runs the callback (e.g. to start the match) as soon as possible unless the countdown has been cancelled.
+ ///
+ public void SkipToEndOfCountdown() => countdownSkipSource?.Cancel();
+
public override async Task SendMatchRequest(MatchUserRequest request)
{
Debug.Assert(Room != null);
@@ -289,6 +314,67 @@ namespace osu.Game.Tests.Visual.Multiplayer
switch (request)
{
+ case StartMatchCountdownRequest matchCountdownRequest:
+ Debug.Assert(ThreadSafety.IsUpdateThread);
+
+ countdownStopSource?.Cancel();
+
+ // Note that this will leak CTSs, however this is a test method and we haven't noticed foregoing disposal of non-linked CTSs to be detrimental.
+ // If necessary, this can be moved into the final schedule below, and the class-level fields be nulled out accordingly.
+ var stopSource = countdownStopSource = new CancellationTokenSource();
+ var skipSource = countdownSkipSource = new CancellationTokenSource();
+ var countdown = new MatchStartCountdown { TimeRemaining = matchCountdownRequest.Duration };
+
+ Task lastCountdownTask = countdownTask;
+ countdownTask = start();
+
+ async Task start()
+ {
+ await lastCountdownTask;
+
+ Schedule(() =>
+ {
+ if (stopSource.IsCancellationRequested)
+ return;
+
+ Room.Countdown = countdown;
+ MatchEvent(new CountdownChangedEvent { Countdown = countdown });
+ });
+
+ try
+ {
+ using (var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stopSource.Token, skipSource.Token))
+ await Task.Delay(matchCountdownRequest.Duration, cancellationSource.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Clients need to be notified of cancellations in the following code.
+ }
+
+ Schedule(() =>
+ {
+ if (Room.Countdown != countdown)
+ return;
+
+ Room.Countdown = null;
+ MatchEvent(new CountdownChangedEvent { Countdown = null });
+
+ if (stopSource.IsCancellationRequested)
+ return;
+
+ StartMatch().WaitSafely();
+ });
+ }
+
+ break;
+
+ case StopCountdownRequest _:
+ countdownStopSource?.Cancel();
+
+ Room.Countdown = null;
+ await MatchEvent(new CountdownChangedEvent { Countdown = Room.Countdown });
+ break;
+
case ChangeTeamRequest changeTeam:
TeamVersusRoomState roomState = (TeamVersusRoomState)Room.MatchState!;
diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
index b7aa8af4aa..2deb8686cc 100644
--- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
+++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
@@ -127,9 +127,9 @@ namespace osu.Game.Tests.Visual
where T : Drawable
{
if (typeof(T) == typeof(Button))
- AddUntilStep($"wait for {typeof(T).Name} enabled", () => (this.ChildrenOfType().Single() as Button)?.Enabled.Value == true);
+ AddUntilStep($"wait for {typeof(T).Name} enabled", () => (this.ChildrenOfType().Single() as ClickableContainer)?.Enabled.Value == true);
else
- AddUntilStep($"wait for {typeof(T).Name} enabled", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Enabled.Value);
+ AddUntilStep($"wait for {typeof(T).Name} enabled", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Enabled.Value);
AddStep($"click {typeof(T).Name}", () =>
{
diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs
index 1107089a46..b4da91a97a 100644
--- a/osu.Game/Tests/Visual/SkinnableTestScene.cs
+++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs
@@ -187,7 +187,7 @@ namespace osu.Game.Tests.Visual
private readonly bool extrapolateAnimations;
public TestLegacySkin(SkinInfo skin, IResourceStore storage, IStorageResourceProvider resources, bool extrapolateAnimations)
- : base(skin, storage, resources, "skin.ini")
+ : base(skin, resources, storage)
{
this.extrapolateAnimations = extrapolateAnimations;
}
diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
index f5da95bd7b..ac7cb43e02 100644
--- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
+++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
@@ -88,7 +88,8 @@ namespace osu.Game.Tests.Visual.Spectator
///
/// The user to send frames for.
/// The total number of frames to send.
- public void SendFramesFromUser(int userId, int count)
+ /// The time to start gameplay frames from.
+ public void SendFramesFromUser(int userId, int count, double startTime = 0)
{
var frames = new List();
@@ -102,7 +103,7 @@ namespace osu.Game.Tests.Visual.Spectator
flush();
var buttonState = currentFrameIndex == lastFrameIndex ? ReplayButtonState.None : ReplayButtonState.Left1;
- frames.Add(new LegacyReplayFrame(currentFrameIndex * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState));
+ frames.Add(new LegacyReplayFrame(currentFrameIndex * 100 + startTime, RNG.Next(0, 512), RNG.Next(0, 512), buttonState));
}
flush();
diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs
index 61ca68a1ab..c57a7c768e 100644
--- a/osu.Game/Updater/SimpleUpdateManager.cs
+++ b/osu.Game/Updater/SimpleUpdateManager.cs
@@ -3,6 +3,7 @@
using System;
using System.Linq;
+using System.Runtime.InteropServices;
using System.Threading.Tasks;
using osu.Framework;
using osu.Framework.Allocation;
@@ -82,7 +83,8 @@ namespace osu.Game.Updater
break;
case RuntimeInfo.Platform.macOS:
- bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip", StringComparison.Ordinal));
+ string arch = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "Apple.Silicon" : "Intel";
+ bestAsset = release.Assets?.Find(f => f.Name.EndsWith($".app.{arch}.zip", StringComparison.Ordinal));
break;
case RuntimeInfo.Platform.Linux:
diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs
index d5ea74c404..ff8e04cc58 100644
--- a/osu.Game/Utils/ModUtils.cs
+++ b/osu.Game/Utils/ModUtils.cs
@@ -166,15 +166,15 @@ namespace osu.Game.Utils
foreach (var apiMod in proposedMods)
{
- try
- {
- // will throw if invalid
- valid.Add(apiMod.ToMod(ruleset));
- }
- catch
+ var mod = apiMod.ToMod(ruleset);
+
+ if (mod is UnknownMod)
{
proposedWereValid = false;
+ continue;
}
+
+ valid.Add(mod);
}
return proposedWereValid;
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 5e194e2aca..3c01f29671 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -31,13 +31,13 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 23101c5af6..c8f170497d 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -61,8 +61,8 @@
-
-
+
+
@@ -84,7 +84,7 @@
-
+