diff --git a/osu.Android.props b/osu.Android.props
index 5aee9e15cc..99cda7693d 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
-
+
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs
index 5541d0e790..cda4715280 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs
@@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections
Entry = null;
}
- private void onEntryInvalidated() => refreshPoints();
+ private void onEntryInvalidated() => Scheduler.AddOnce(refreshPoints);
private void refreshPoints()
{
diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs
index a42b7d54ee..407dec936b 100644
--- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs
+++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using NUnit.Framework;
using osu.Game.Replays;
using osu.Game.Rulesets.Replays;
@@ -278,6 +279,54 @@ namespace osu.Game.Tests.NonVisual
setTime(-100, -100);
}
+ [Test]
+ public void TestReplayFramesSortStability()
+ {
+ const double repeating_time = 5000;
+
+ // add a collection of frames in shuffled order time-wise; each frame also stores its original index to check stability later.
+ // data is hand-picked and breaks if the unstable List.Sort() is used.
+ // in theory this can still return a false-positive with another unstable algorithm if extremely unlucky,
+ // but there is no conceivable fool-proof way to prevent that anyways.
+ replay.Frames.AddRange(new[]
+ {
+ repeating_time,
+ 0,
+ 3000,
+ repeating_time,
+ repeating_time,
+ 6000,
+ 9000,
+ repeating_time,
+ repeating_time,
+ 1000,
+ 11000,
+ 21000,
+ 4000,
+ repeating_time,
+ repeating_time,
+ 8000,
+ 2000,
+ 7000,
+ repeating_time,
+ repeating_time,
+ 10000
+ }.Select((time, index) => new TestReplayFrame(time, true, index)));
+
+ replay.HasReceivedAllFrames = true;
+
+ // create a new handler with the replay for the sort to be performed.
+ handler = new TestInputHandler(replay);
+
+ // ensure sort stability by checking that the frames with time == repeating_time are sorted in ascending frame index order themselves.
+ var repeatingTimeFramesData = replay.Frames
+ .Cast()
+ .Where(f => f.Time == repeating_time)
+ .Select(f => f.FrameIndex);
+
+ Assert.That(repeatingTimeFramesData, Is.Ordered.Ascending);
+ }
+
private void setReplayFrames()
{
replay.Frames = new List
@@ -324,11 +373,13 @@ namespace osu.Game.Tests.NonVisual
private class TestReplayFrame : ReplayFrame
{
public readonly bool IsImportant;
+ public readonly int FrameIndex;
- public TestReplayFrame(double time, bool isImportant = false)
+ public TestReplayFrame(double time, bool isImportant = false, int frameIndex = 0)
: base(time)
{
IsImportant = isImportant;
+ FrameIndex = frameIndex;
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs
index 3a063af843..3aff74a0a8 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs
@@ -132,8 +132,8 @@ namespace osu.Game.Tests.Visual.Editing
{
AddStep("deselect", () => EditorBeatmap.SelectedHitObjects.Clear());
- AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0);
- AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0);
+ AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0);
+ AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0);
}
AddStep("paste hitobject", () => Editor.Paste());
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
new file mode 100644
index 0000000000..0c2c6ed454
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs
@@ -0,0 +1,36 @@
+// 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 osu.Framework.Testing;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Skinning.Editor;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+ public class TestSceneSkinEditor : PlayerTestScene
+ {
+ private SkinEditor skinEditor;
+
+ [SetUpSteps]
+ public override void SetUpSteps()
+ {
+ base.SetUpSteps();
+
+ AddStep("add editor overlay", () =>
+ {
+ skinEditor?.Expire();
+ LoadComponentAsync(skinEditor = new SkinEditor(Player), Add);
+ });
+ }
+
+ [Test]
+ public void TestToggleEditor()
+ {
+ AddToggleStep("toggle editor visibility", visible => skinEditor.ToggleVisibility());
+ }
+
+ protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
+ }
+}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs
new file mode 100644
index 0000000000..086bcb19c3
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs
@@ -0,0 +1,61 @@
+// 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;
+using osu.Framework.Testing;
+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.Skinning.Editor;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+ public class TestSceneSkinEditorMultipleSkins : SkinnableTestScene
+ {
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("create editor overlay", () =>
+ {
+ SetContents(() =>
+ {
+ var ruleset = new OsuRuleset();
+ var working = CreateWorkingBeatmap(ruleset.RulesetInfo);
+ var beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo);
+
+ ScoreProcessor scoreProcessor = new ScoreProcessor();
+
+ var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap);
+
+ var hudOverlay = new HUDOverlay(scoreProcessor, null, drawableRuleset, Array.Empty())
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ };
+
+ // Add any key just to display the key counter visually.
+ hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space));
+ hudOverlay.ComboCounter.Current.Value = 1;
+
+ return new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ drawableRuleset,
+ hudOverlay,
+ new SkinEditor(hudOverlay),
+ }
+ };
+ });
+ });
+ }
+
+ protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset();
+ }
+}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
new file mode 100644
index 0000000000..4138a81ebd
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs
@@ -0,0 +1,191 @@
+// 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.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Screens;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Configuration;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring;
+using osu.Game.Screens.Play;
+using osu.Game.Screens.Ranking;
+using osu.Game.Storyboards;
+using osuTK;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+ public class TestSceneStoryboardWithOutro : PlayerTestScene
+ {
+ protected override bool HasCustomSteps => true;
+
+ protected new OutroPlayer Player => (OutroPlayer)base.Player;
+
+ private double currentStoryboardDuration;
+
+ private bool showResults = true;
+
+ private event Func currentFailConditions;
+
+ [SetUpSteps]
+ public override void SetUpSteps()
+ {
+ base.SetUpSteps();
+ AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true));
+ AddStep("set dim level to 0", () => LocalConfig.SetValue(OsuSetting.DimLevel, 0));
+ AddStep("reset fail conditions", () => currentFailConditions = (_, __) => false);
+ AddStep("set storyboard duration to 2s", () => currentStoryboardDuration = 2000);
+ AddStep("set ShowResults = true", () => showResults = true);
+ }
+
+ [Test]
+ public void TestStoryboardSkipOutro()
+ {
+ CreateTest(null);
+ AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
+ AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space));
+ AddAssert("score shown", () => Player.IsScoreShown);
+ }
+
+ [Test]
+ public void TestStoryboardNoSkipOutro()
+ {
+ CreateTest(null);
+ AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
+ AddUntilStep("wait for score shown", () => Player.IsScoreShown);
+ }
+
+ [Test]
+ public void TestStoryboardExitToSkipOutro()
+ {
+ CreateTest(null);
+ AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
+ AddStep("exit via pause", () => Player.ExitViaPause());
+ AddAssert("score shown", () => Player.IsScoreShown);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void TestStoryboardToggle(bool enabledAtBeginning)
+ {
+ CreateTest(null);
+ AddStep($"{(enabledAtBeginning ? "enable" : "disable")} storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, enabledAtBeginning));
+ AddStep("toggle storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, !enabledAtBeginning));
+ AddUntilStep("wait for score shown", () => Player.IsScoreShown);
+ }
+
+ [Test]
+ public void TestOutroEndsDuringFailAnimation()
+ {
+ CreateTest(() =>
+ {
+ AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true);
+ AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300);
+ });
+ AddUntilStep("wait for fail", () => Player.HasFailed);
+ AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
+ AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible);
+ }
+
+ [Test]
+ public void TestShowResultsFalse()
+ {
+ CreateTest(() =>
+ {
+ AddStep("set ShowResults = false", () => showResults = false);
+ });
+ AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
+ AddWaitStep("wait", 10);
+ AddAssert("no score shown", () => !Player.IsScoreShown);
+ }
+
+ [Test]
+ public void TestStoryboardEndsBeforeCompletion()
+ {
+ CreateTest(() => AddStep("set storyboard duration to .1s", () => currentStoryboardDuration = 100));
+ AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
+ AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
+ AddUntilStep("wait for score shown", () => Player.IsScoreShown);
+ }
+
+ [Test]
+ public void TestStoryboardRewind()
+ {
+ SkipOverlay.FadeContainer fadeContainer() => Player.ChildrenOfType().First();
+
+ CreateTest(null);
+ AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
+ AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible);
+
+ AddStep("rewind", () => Player.GameplayClockContainer.Seek(-1000));
+ AddUntilStep("skip overlay content not visible", () => fadeContainer().State == Visibility.Hidden);
+
+ AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible);
+ AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
+ }
+
+ protected override bool AllowFail => true;
+
+ protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
+
+ protected override TestPlayer CreatePlayer(Ruleset ruleset) => new OutroPlayer(currentFailConditions, showResults);
+
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
+ {
+ var beatmap = new Beatmap();
+ beatmap.HitObjects.Add(new HitCircle());
+ return beatmap;
+ }
+
+ protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null)
+ {
+ return base.CreateWorkingBeatmap(beatmap, createStoryboard(currentStoryboardDuration));
+ }
+
+ private Storyboard createStoryboard(double duration)
+ {
+ var storyboard = new Storyboard();
+ var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
+ sprite.TimelineGroup.Alpha.Add(Easing.None, 0, duration, 1, 0);
+ storyboard.GetLayer("Background").Add(sprite);
+ return storyboard;
+ }
+
+ protected class OutroPlayer : TestPlayer
+ {
+ public void ExitViaPause() => PerformExit(true);
+
+ public new FailOverlay FailOverlay => base.FailOverlay;
+
+ public bool IsScoreShown => !this.IsCurrentScreen() && this.GetChildScreen() is ResultsScreen;
+
+ private event Func failConditions;
+
+ public OutroPlayer(Func failConditions, bool showResults = true)
+ : base(false, showResults)
+ {
+ this.failConditions = failConditions;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ HealthProcessor.FailConditions += failConditions;
+ }
+
+ protected override Task ImportScore(Score score)
+ {
+ return Task.CompletedTask;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs
index 7ea6373763..67c85a1120 100644
--- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs
@@ -7,6 +7,7 @@ using JetBrains.Annotations;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
@@ -112,8 +113,8 @@ namespace osu.Game.Tests.Visual.SongSelect
private void testInfoLabels(int expectedCount)
{
- AddAssert("check info labels exists", () => infoWedge.Info.ChildrenOfType().Any());
- AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType().Count() == expectedCount);
+ AddAssert("check info labels exists", () => infoWedge.Info.ChildrenOfType().Any());
+ AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType().Count() == expectedCount);
}
[Test]
@@ -124,7 +125,7 @@ namespace osu.Game.Tests.Visual.SongSelect
AddAssert("check default title", () => infoWedge.Info.TitleLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Title);
AddAssert("check default artist", () => infoWedge.Info.ArtistLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Artist);
AddAssert("check empty author", () => !infoWedge.Info.MapperContainer.Children.Any());
- AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType().Any());
+ AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType().Any());
}
[Test]
@@ -135,15 +136,15 @@ namespace osu.Game.Tests.Visual.SongSelect
private void selectBeatmap([CanBeNull] IBeatmap b)
{
- BeatmapInfoWedge.BufferedWedgeInfo infoBefore = null;
+ Container containerBefore = null;
AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () =>
{
- infoBefore = infoWedge.Info;
+ containerBefore = infoWedge.DisplayedContent;
infoWedge.Beatmap = Beatmap.Value = b == null ? Beatmap.Default : CreateWorkingBeatmap(b);
});
- AddUntilStep("wait for async load", () => infoWedge.Info != infoBefore);
+ AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore);
}
private IBeatmap createTestBeatmap(RulesetInfo ruleset)
@@ -193,7 +194,9 @@ namespace osu.Game.Tests.Visual.SongSelect
private class TestBeatmapInfoWedge : BeatmapInfoWedge
{
- public new BufferedWedgeInfo Info => base.Info;
+ public new Container DisplayedContent => base.DisplayedContent;
+
+ public new WedgeInfoText Info => base.Info;
}
private class TestHitObject : ConvertHitObject, IHasPosition
diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
index e168f265dd..c0518247a9 100644
--- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
+++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Graphics.Containers
protected override bool BlockNonPositionalInput => true;
///
- /// Temporary to allow for overlays in the main screen content to not dim theirselves.
+ /// Temporary to allow for overlays in the main screen content to not dim themselves.
/// Should be eventually replaced by dimming which is aware of the target dim container (traverse parent for certain interface type?).
///
protected virtual bool DimMainContent => true;
diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs
index 8f07c3a656..2488fd14d0 100644
--- a/osu.Game/Graphics/Containers/ScalingContainer.cs
+++ b/osu.Game/Graphics/Containers/ScalingContainer.cs
@@ -36,6 +36,24 @@ namespace osu.Game.Graphics.Containers
private BackgroundScreenStack backgroundStack;
+ private bool allowScaling = true;
+
+ ///
+ /// Whether user scaling preferences should be applied. Enabled by default.
+ ///
+ public bool AllowScaling
+ {
+ get => allowScaling;
+ set
+ {
+ if (value == allowScaling)
+ return;
+
+ allowScaling = value;
+ if (IsLoaded) updateSize();
+ }
+ }
+
///
/// Create a new instance.
///
@@ -139,7 +157,7 @@ namespace osu.Game.Graphics.Containers
backgroundStack?.FadeOut(fade_time);
}
- bool scaling = targetMode == null || scalingMode.Value == targetMode;
+ bool scaling = AllowScaling && (targetMode == null || scalingMode.Value == targetMode);
var targetSize = scaling ? new Vector2(sizeX.Value, sizeY.Value) : Vector2.One;
var targetPosition = scaling ? new Vector2(posX.Value, posY.Value) * (Vector2.One - targetSize) : Vector2.Zero;
diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs
index 6717de5658..ce945f3bf8 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -48,6 +48,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings),
new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing),
new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications),
+ new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.ToggleSkinEditor),
new KeyBinding(InputKey.Escape, GlobalAction.Back),
new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back),
@@ -258,6 +259,9 @@ namespace osu.Game.Input.Bindings
EditorNudgeLeft,
[Description("Nudge selection right")]
- EditorNudgeRight
+ EditorNudgeRight,
+
+ [Description("Toggle skin editor")]
+ ToggleSkinEditor,
}
}
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 28f32ba455..b1173784b5 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -51,6 +51,7 @@ using osu.Game.Utils;
using LogLevel = osu.Framework.Logging.LogLevel;
using osu.Game.Database;
using osu.Game.IO;
+using osu.Game.Skinning.Editor;
namespace osu.Game
{
@@ -79,6 +80,8 @@ namespace osu.Game
private BeatmapSetOverlay beatmapSetOverlay;
+ private SkinEditorOverlay skinEditor;
+
[Cached]
private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender();
@@ -597,6 +600,8 @@ namespace osu.Game
screenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays)
{
RelativeSizeAxes = Axes.Both,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
Children = new Drawable[]
{
receptor = new BackButton.Receptor(),
@@ -685,6 +690,7 @@ namespace osu.Game
var changelogOverlay = loadComponentSingleFile(new ChangelogOverlay(), overlayContent.Add, true);
loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true);
loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true);
+ loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add);
loadComponentSingleFile(new LoginOverlay
{
@@ -968,6 +974,8 @@ namespace osu.Game
protected virtual void ScreenChanged(IScreen current, IScreen newScreen)
{
+ skinEditor.Reset();
+
switch (newScreen)
{
case IntroScreen intro:
diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs
index 4d5c2e06eb..937bcc8abf 100644
--- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs
@@ -141,7 +141,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
scalingSettings.ForEach(s => bindPreviewEvent(s.Current));
- windowModeDropdown.Current.ValueChanged += _ => updateResolutionDropdown();
+ windowModeDropdown.Current.BindValueChanged(mode =>
+ {
+ updateResolutionDropdown();
+
+ const string not_fullscreen_note = "Running without fullscreen mode will increase your input latency!";
+
+ windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? not_fullscreen_note : string.Empty;
+ }, true);
windowModes.BindCollectionChanged((sender, args) =>
{
diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs
index 8773e6763c..70225ff6b8 100644
--- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs
@@ -13,6 +13,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
{
protected override string Header => "Renderer";
+ private SettingsEnumDropdown frameLimiterDropdown;
+
[BackgroundDependencyLoader]
private void load(FrameworkConfigManager config, OsuConfigManager osuConfig)
{
@@ -20,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
Children = new Drawable[]
{
// TODO: this needs to be a custom dropdown at some point
- new SettingsEnumDropdown
+ frameLimiterDropdown = new SettingsEnumDropdown
{
LabelText = "Frame limiter",
Current = config.GetBindable(FrameworkSetting.FrameSync)
@@ -37,5 +39,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
},
};
}
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ frameLimiterDropdown.Current.BindValueChanged(limit =>
+ {
+ const string unlimited_frames_note = "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. \"2x refresh rate\" is recommended.";
+
+ frameLimiterDropdown.WarningText = limit.NewValue == FrameSync.Unlimited ? unlimited_frames_note : string.Empty;
+ }, true);
+ }
}
}
diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs
index 95e2e9da30..5f703ed5a4 100644
--- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs
@@ -2,8 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Configuration;
+using osu.Game.Online.API;
+using osu.Game.Users;
namespace osu.Game.Overlays.Settings.Sections.UserInterface
{
@@ -11,9 +14,15 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
{
protected override string Header => "Main Menu";
+ private IBindable user;
+
+ private SettingsEnumDropdown backgroundSourceDropdown;
+
[BackgroundDependencyLoader]
- private void load(OsuConfigManager config)
+ private void load(OsuConfigManager config, IAPIProvider api)
{
+ user = api.LocalUser.GetBoundCopy();
+
Children = new Drawable[]
{
new SettingsCheckbox
@@ -31,7 +40,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
LabelText = "Intro sequence",
Current = config.GetBindable(OsuSetting.IntroSequence),
},
- new SettingsEnumDropdown
+ backgroundSourceDropdown = new SettingsEnumDropdown
{
LabelText = "Background source",
Current = config.GetBindable(OsuSetting.MenuBackgroundSource),
@@ -43,5 +52,17 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
}
};
}
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ user.BindValueChanged(u =>
+ {
+ const string not_supporter_note = "Changes to this setting will only apply with an active osu!supporter tag.";
+
+ backgroundSourceDropdown.WarningText = u.NewValue?.IsSupporter != true ? not_supporter_note : string.Empty;
+ }, true);
+ }
}
}
diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs
index 0bd9750b0b..86a836d29b 100644
--- a/osu.Game/Overlays/Settings/SettingsItem.cs
+++ b/osu.Game/Overlays/Settings/SettingsItem.cs
@@ -18,7 +18,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
-using osuTK;
+using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Settings
{
@@ -36,10 +36,15 @@ namespace osu.Game.Overlays.Settings
private SpriteText labelText;
+ private OsuTextFlowContainer warningText;
+
public bool ShowsDefaultIndicator = true;
public string TooltipText { get; set; }
+ [Resolved]
+ private OsuColour colours { get; set; }
+
public virtual LocalisableString LabelText
{
get => labelText?.Text ?? string.Empty;
@@ -57,6 +62,31 @@ namespace osu.Game.Overlays.Settings
}
}
+ ///
+ /// Text to be displayed at the bottom of this .
+ /// Generally used to recommend the user change their setting as the current one is considered sub-optimal.
+ ///
+ public string WarningText
+ {
+ set
+ {
+ if (warningText == null)
+ {
+ // construct lazily for cases where the label is not needed (may be provided by the Control).
+ FlowContent.Add(warningText = new OsuTextFlowContainer
+ {
+ Colour = colours.Yellow,
+ Margin = new MarginPadding { Bottom = 5 },
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ });
+ }
+
+ warningText.Alpha = string.IsNullOrWhiteSpace(value) ? 0 : 1;
+ warningText.Text = value;
+ }
+ }
+
public virtual Bindable Current
{
get => controlWithCurrent.Current;
@@ -92,7 +122,10 @@ namespace osu.Game.Overlays.Settings
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS },
- Child = Control = CreateControl()
+ Children = new[]
+ {
+ Control = CreateControl(),
+ },
},
};
@@ -141,6 +174,7 @@ namespace osu.Game.Overlays.Settings
{
RelativeSizeAxes = Axes.Y;
Width = SettingsPanel.CONTENT_MARGINS;
+ Padding = new MarginPadding { Vertical = 1.5f };
Alpha = 0f;
}
@@ -163,7 +197,7 @@ namespace osu.Game.Overlays.Settings
Type = EdgeEffectType.Glow,
Radius = 2,
},
- Size = new Vector2(0.33f, 0.8f),
+ Width = 0.33f,
Child = new Box { RelativeSizeAxes = Axes.Both },
};
}
@@ -196,12 +230,6 @@ namespace osu.Game.Overlays.Settings
UpdateState();
}
- public void SetButtonColour(Color4 buttonColour)
- {
- this.buttonColour = buttonColour;
- UpdateState();
- }
-
public void UpdateState() => Scheduler.AddOnce(updateState);
private void updateState()
diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs
index 0f25a45177..bc8994bbe5 100644
--- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs
+++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using JetBrains.Annotations;
using osu.Game.Input.Handlers;
using osu.Game.Replays;
@@ -97,7 +98,7 @@ namespace osu.Game.Rulesets.Replays
{
// TODO: This replay frame ordering should be enforced on the Replay type.
// Currently, the ordering can be broken if the frames are added after this construction.
- replay.Frames.Sort((x, y) => x.Time.CompareTo(y.Time));
+ replay.Frames = replay.Frames.OrderBy(f => f.Time).ToList();
this.replay = replay;
currentFrameIndex = -1;
diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs
index 2a605f75d8..db322faf65 100644
--- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs
@@ -34,13 +34,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
[BackgroundDependencyLoader]
private void load()
{
- // For non-pooled rulesets, hitobjects are already present in the playfield which allows the blueprints to be loaded in the async context.
- if (Composer != null)
- {
- foreach (var obj in Composer.HitObjects)
- AddBlueprintFor(obj.HitObject);
- }
-
selectedHitObjects.BindTo(Beatmap.SelectedHitObjects);
selectedHitObjects.CollectionChanged += (selectedObjects, args) =>
{
@@ -69,7 +62,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (Composer != null)
{
- // For pooled rulesets, blueprints must be added for hitobjects already "current" as they would've not been "current" during the async load addition process above.
foreach (var obj in Composer.HitObjects)
AddBlueprintFor(obj.HitObject);
diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs
index 0fc305dcc4..6ab4ca8267 100644
--- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs
@@ -108,17 +108,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
}
- ///
- /// Given a selection target and a function of truth, retrieve the correct ternary state for display.
- ///
- protected TernaryState GetStateFromSelection(IEnumerable selection, Func func)
- {
- if (selection.Any(func))
- return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
-
- return TernaryState.False;
- }
-
#endregion
#region Ternary state changes
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
index 9d6b44e207..0d6cfc0689 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
@@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Input;
@@ -16,6 +17,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
public class SelectionBox : CompositeDrawable
{
+ public const float BORDER_RADIUS = 3;
+
public Func OnRotation;
public Func OnScale;
public Func OnFlip;
@@ -92,21 +95,32 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
}
+ private string text;
+
+ public string Text
+ {
+ get => text;
+ set
+ {
+ if (value == text)
+ return;
+
+ text = value;
+ if (selectionDetailsText != null)
+ selectionDetailsText.Text = value;
+ }
+ }
+
private Container dragHandles;
private FillFlowContainer buttons;
- public const float BORDER_RADIUS = 3;
+ private OsuSpriteText selectionDetailsText;
[Resolved]
private OsuColour colours { get; set; }
[BackgroundDependencyLoader]
- private void load()
- {
- RelativeSizeAxes = Axes.Both;
-
- recreate();
- }
+ private void load() => recreate();
protected override bool OnKeyDown(KeyDownEvent e)
{
@@ -144,6 +158,26 @@ namespace osu.Game.Screens.Edit.Compose.Components
InternalChildren = new Drawable[]
{
+ new Container
+ {
+ Name = "info text",
+ AutoSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ Colour = colours.YellowDark,
+ RelativeSizeAxes = Axes.Both,
+ },
+ selectionDetailsText = new OsuSpriteText
+ {
+ Padding = new MarginPadding(2),
+ Colour = colours.Gray0,
+ Font = OsuFont.Default.With(size: 11),
+ Text = text,
+ }
+ }
+ },
new Container
{
Masking = true,
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
index 917cbca4e1..6d8ae69812 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
@@ -10,13 +10,11 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Primitives;
-using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
-using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osuTK;
@@ -43,10 +41,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
private readonly List> selectedBlueprints;
- private Drawable content;
-
- private OsuSpriteText selectionDetailsText;
-
protected SelectionBox SelectionBox { get; private set; }
[Resolved(CanBeNull = true)]
@@ -58,39 +52,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
RelativeSizeAxes = Axes.Both;
AlwaysPresent = true;
- Alpha = 0;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
- InternalChild = content = new Container
- {
- Children = new Drawable[]
- {
- // todo: should maybe be inside the SelectionBox?
- new Container
- {
- Name = "info text",
- AutoSizeAxes = Axes.Both,
- Children = new Drawable[]
- {
- new Box
- {
- Colour = colours.YellowDark,
- RelativeSizeAxes = Axes.Both,
- },
- selectionDetailsText = new OsuSpriteText
- {
- Padding = new MarginPadding(2),
- Colour = colours.Gray0,
- Font = OsuFont.Default.With(size: 11)
- }
- }
- },
- SelectionBox = CreateSelectionBox(),
- }
- };
+ InternalChild = SelectionBox = CreateSelectionBox();
SelectedItems.CollectionChanged += (sender, args) =>
{
@@ -269,6 +236,17 @@ namespace osu.Game.Screens.Edit.Compose.Components
DeleteSelected();
}
+ ///
+ /// Given a selection target and a function of truth, retrieve the correct ternary state for display.
+ ///
+ protected static TernaryState GetStateFromSelection(IEnumerable selection, Func func)
+ {
+ if (selection.Any(func))
+ return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
+
+ return TernaryState.False;
+ }
+
///
/// Called whenever the deletion of items has been requested.
///
@@ -306,9 +284,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
int count = SelectedItems.Count;
- selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty;
+ SelectionBox.Text = count > 0 ? count.ToString() : string.Empty;
+ SelectionBox.FadeTo(count > 0 ? 1 : 0);
- this.FadeTo(count > 0 ? 1 : 0);
OnSelectionChanged();
}
@@ -335,8 +313,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
selectionRect = selectionRect.Inflate(5f);
- content.Position = selectionRect.Location;
- content.Size = selectionRect.Size;
+ SelectionBox.Position = selectionRect.Location;
+ SelectionBox.Size = selectionRect.Size;
}
#endregion
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs
index aaacf891bb..ae2042fbe8 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs
@@ -48,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
AllowPause = false,
AllowRestart = false,
- AllowSkippingIntro = false,
+ AllowSkipping = false,
})
{
this.userIds = userIds;
diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs
index 58eb95b7c6..f8cedddfbe 100644
--- a/osu.Game/Screens/Play/DimmableStoryboard.cs
+++ b/osu.Game/Screens/Play/DimmableStoryboard.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osu.Game.Storyboards;
@@ -19,6 +20,14 @@ namespace osu.Game.Screens.Play
private readonly Storyboard storyboard;
private DrawableStoryboard drawableStoryboard;
+ ///
+ /// Whether the storyboard is considered finished.
+ ///
+ ///
+ /// This is true by default in here, until an actual drawable storyboard is loaded, in which case it'll bind to it.
+ ///
+ public IBindable HasStoryboardEnded = new BindableBool(true);
+
public DimmableStoryboard(Storyboard storyboard)
{
this.storyboard = storyboard;
@@ -49,6 +58,7 @@ namespace osu.Game.Screens.Play
return;
drawableStoryboard = storyboard.CreateDrawable();
+ HasStoryboardEnded.BindTo(drawableStoryboard.HasStoryboardEnded);
if (async)
LoadComponentAsync(drawableStoryboard, onStoryboardCreated);
diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs
index d5d8ec570a..b8a43708b4 100644
--- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs
+++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs
@@ -9,7 +9,7 @@ using osuTK;
namespace osu.Game.Screens.Play.HUD
{
- public class DefaultAccuracyCounter : PercentageCounter, IAccuracyCounter
+ public class DefaultAccuracyCounter : PercentageCounter, IAccuracyCounter, ISkinnableComponent
{
private readonly Vector2 offset = new Vector2(-20, 5);
diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs
index 5a975c1b80..64226bbb34 100644
--- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs
+++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs
@@ -12,7 +12,7 @@ using osuTK;
namespace osu.Game.Screens.Play.HUD
{
- public class DefaultComboCounter : RollingCounter
+ public class DefaultComboCounter : RollingCounter, ISkinnableComponent
{
private readonly Vector2 offset = new Vector2(20, 5);
diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs
index b550b469e9..e3cd71691d 100644
--- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs
+++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs
@@ -16,7 +16,7 @@ using osu.Framework.Utils;
namespace osu.Game.Screens.Play.HUD
{
- public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour
+ public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableComponent
{
///
/// The base opacity of the glow.
diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs
index 1dcfe2e067..dde5c18b38 100644
--- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs
+++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs
@@ -8,7 +8,7 @@ using osu.Game.Graphics.UserInterface;
namespace osu.Game.Screens.Play.HUD
{
- public class DefaultScoreCounter : ScoreCounter
+ public class DefaultScoreCounter : ScoreCounter, ISkinnableComponent
{
public DefaultScoreCounter()
: base(6)
diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
index 89f135de7f..3b24c8cc9e 100644
--- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
+++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
@@ -18,7 +18,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD.HitErrorMeters
{
- public class BarHitErrorMeter : HitErrorMeter
+ public class BarHitErrorMeter : HitErrorMeter, ISkinnableComponent
{
private readonly Anchor alignment;
diff --git a/osu.Game/Screens/Play/HUD/ISkinnableComponent.cs b/osu.Game/Screens/Play/HUD/ISkinnableComponent.cs
new file mode 100644
index 0000000000..6d4558443f
--- /dev/null
+++ b/osu.Game/Screens/Play/HUD/ISkinnableComponent.cs
@@ -0,0 +1,14 @@
+// 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.Graphics;
+
+namespace osu.Game.Screens.Play.HUD
+{
+ ///
+ /// Denotes a drawable which, as a drawable, can be adjusted via skinning specifications.
+ ///
+ public interface ISkinnableComponent : IDrawable
+ {
+ }
+}
diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs
index 8ecd960de1..73305ac93e 100644
--- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs
+++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs
@@ -15,7 +15,7 @@ namespace osu.Game.Screens.Play.HUD
///
/// Uses the 'x' symbol and has a pop-out effect while rolling over.
///
- public class LegacyComboCounter : CompositeDrawable
+ public class LegacyComboCounter : CompositeDrawable, ISkinnableComponent
{
public Bindable Current { get; } = new BindableInt { MinValue = 0, };
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index c9d7bbf4c1..951ce334f6 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -104,7 +104,8 @@ namespace osu.Game.Screens.Play
private BreakTracker breakTracker;
- private SkipOverlay skipOverlay;
+ private SkipOverlay skipIntroOverlay;
+ private SkipOverlay skipOutroOverlay;
protected ScoreProcessor ScoreProcessor { get; private set; }
@@ -246,7 +247,6 @@ namespace osu.Game.Screens.Play
HUDOverlay.ShowHud.Value = false;
HUDOverlay.ShowHud.Disabled = true;
BreakOverlay.Hide();
- skipOverlay.Hide();
}
DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting =>
@@ -283,8 +283,14 @@ namespace osu.Game.Screens.Play
ScoreProcessor.RevertResult(r);
};
+ DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded =>
+ {
+ if (storyboardEnded.NewValue && completionProgressDelegate == null)
+ updateCompletionState();
+ };
+
// Bind the judgement processors to ourselves
- ScoreProcessor.HasCompleted.ValueChanged += updateCompletionState;
+ ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState());
HealthProcessor.Failed += onFail;
foreach (var mod in Mods.Value.OfType())
@@ -357,10 +363,15 @@ namespace osu.Game.Screens.Play
Anchor = Anchor.Centre,
Origin = Anchor.Centre
},
- skipOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime)
+ skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime)
{
RequestSkip = performUserRequestedSkip
},
+ skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0)
+ {
+ RequestSkip = () => updateCompletionState(true),
+ Alpha = 0
+ },
FailOverlay = new FailOverlay
{
OnRetry = Restart,
@@ -387,12 +398,15 @@ namespace osu.Game.Screens.Play
}
};
+ if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays)
+ {
+ skipIntroOverlay.Expire();
+ skipOutroOverlay.Expire();
+ }
+
if (GameplayClockContainer is MasterGameplayClockContainer master)
HUDOverlay.PlayerSettingsOverlay.PlaybackSettings.UserPlaybackRate.BindTarget = master.UserPlaybackRate;
- if (!Configuration.AllowSkippingIntro)
- skipOverlay.Expire();
-
if (Configuration.AllowRestart)
{
container.Add(new HotkeyRetryOverlay
@@ -527,6 +541,10 @@ namespace osu.Game.Screens.Play
Pause();
return;
}
+
+ // if the score is ready for display but results screen has not been pushed yet (e.g. storyboard is still playing beyond gameplay), then transition to results screen instead of exiting.
+ if (prepareScoreForDisplayTask != null)
+ updateCompletionState(true);
}
this.Exit();
@@ -566,17 +584,23 @@ namespace osu.Game.Screens.Play
private ScheduledDelegate completionProgressDelegate;
private Task prepareScoreForDisplayTask;
- private void updateCompletionState(ValueChangedEvent completionState)
+ ///
+ /// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime.
+ ///
+ /// If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it.
+ /// Thrown if this method is called more than once without changing state.
+ private void updateCompletionState(bool skipStoryboardOutro = false)
{
// screen may be in the exiting transition phase.
if (!this.IsCurrentScreen())
return;
- if (!completionState.NewValue)
+ if (!ScoreProcessor.HasCompleted.Value)
{
completionProgressDelegate?.Cancel();
completionProgressDelegate = null;
ValidForResume = true;
+ skipOutroOverlay.Hide();
return;
}
@@ -616,6 +640,20 @@ namespace osu.Game.Screens.Play
return score.ScoreInfo;
});
+ if (skipStoryboardOutro)
+ {
+ scheduleCompletion();
+ return;
+ }
+
+ bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value;
+
+ if (storyboardHasOutro)
+ {
+ skipOutroOverlay.Show();
+ return;
+ }
+
using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY))
scheduleCompletion();
}
diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs
index cd30ead638..18ee73374f 100644
--- a/osu.Game/Screens/Play/PlayerConfiguration.cs
+++ b/osu.Game/Screens/Play/PlayerConfiguration.cs
@@ -21,8 +21,8 @@ namespace osu.Game.Screens.Play
public bool AllowRestart { get; set; } = true;
///
- /// Whether the player should be allowed to skip the intro, advancing to the start of gameplay.
+ /// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard.
///
- public bool AllowSkippingIntro { get; set; } = true;
+ public bool AllowSkipping { get; set; } = true;
}
}
diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs
index ddb78dfb67..ed49fc40b2 100644
--- a/osu.Game/Screens/Play/SkipOverlay.cs
+++ b/osu.Game/Screens/Play/SkipOverlay.cs
@@ -8,19 +8,19 @@ using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
using osu.Framework.Threading;
+using osu.Framework.Utils;
using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
+using osu.Game.Input.Bindings;
using osu.Game.Screens.Ranking;
using osuTK;
using osuTK.Graphics;
-using osu.Framework.Graphics.Shapes;
-using osu.Framework.Graphics.Sprites;
-using osu.Game.Graphics.Containers;
-using osu.Framework.Input.Bindings;
-using osu.Framework.Input.Events;
-using osu.Framework.Utils;
-using osu.Game.Input.Bindings;
namespace osu.Game.Screens.Play
{
@@ -92,6 +92,18 @@ namespace osu.Game.Screens.Play
private double fadeOutBeginTime => startTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME;
+ public override void Hide()
+ {
+ base.Hide();
+ fadeContainer.Hide();
+ }
+
+ public override void Show()
+ {
+ base.Show();
+ fadeContainer.Show();
+ }
+
protected override void LoadComplete()
{
base.LoadComplete();
@@ -147,7 +159,7 @@ namespace osu.Game.Screens.Play
{
}
- private class FadeContainer : Container, IStateful
+ public class FadeContainer : Container, IStateful
{
public event Action StateChanged;
@@ -170,7 +182,7 @@ namespace osu.Game.Screens.Play
switch (state)
{
case Visibility.Visible:
- // we may be triggered to become visible mnultiple times but we only want to transform once.
+ // we may be triggered to become visible multiple times but we only want to transform once.
if (stateChanged)
this.FadeIn(500, Easing.OutExpo);
diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs
index 6c7cb9376c..db81633aea 100644
--- a/osu.Game/Screens/Play/SongProgress.cs
+++ b/osu.Game/Screens/Play/SongProgress.cs
@@ -14,6 +14,7 @@ using osu.Framework.Timing;
using osu.Game.Configuration;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
+using osu.Game.Screens.Play.HUD;
namespace osu.Game.Screens.Play
{
@@ -71,30 +72,38 @@ namespace osu.Game.Screens.Play
public SongProgress()
{
- Masking = true;
-
Children = new Drawable[]
{
- info = new SongProgressInfo
+ new SongProgressDisplay
{
- Origin = Anchor.BottomLeft,
- Anchor = Anchor.BottomLeft,
- RelativeSizeAxes = Axes.X,
- Height = info_height,
- },
- graph = new SongProgressGraph
- {
- RelativeSizeAxes = Axes.X,
- Origin = Anchor.BottomLeft,
- Anchor = Anchor.BottomLeft,
- Height = graph_height,
- Margin = new MarginPadding { Bottom = bottom_bar_height },
- },
- bar = new SongProgressBar(bottom_bar_height, graph_height, handle_size)
- {
- Anchor = Anchor.BottomLeft,
- Origin = Anchor.BottomLeft,
- OnSeek = time => RequestSeek?.Invoke(time),
+ Masking = true,
+ RelativeSizeAxes = Axes.Both,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ Children = new Drawable[]
+ {
+ info = new SongProgressInfo
+ {
+ Origin = Anchor.BottomLeft,
+ Anchor = Anchor.BottomLeft,
+ RelativeSizeAxes = Axes.X,
+ Height = info_height,
+ },
+ graph = new SongProgressGraph
+ {
+ RelativeSizeAxes = Axes.X,
+ Origin = Anchor.BottomLeft,
+ Anchor = Anchor.BottomLeft,
+ Height = graph_height,
+ Margin = new MarginPadding { Bottom = bottom_bar_height },
+ },
+ bar = new SongProgressBar(bottom_bar_height, graph_height, handle_size)
+ {
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ OnSeek = time => RequestSeek?.Invoke(time),
+ },
+ }
},
};
}
@@ -175,5 +184,11 @@ namespace osu.Game.Screens.Play
float finalMargin = bottom_bar_height + (AllowSeeking.Value ? handle_size.Y : 0) + (ShowGraph.Value ? graph_height : 0);
info.TransformTo(nameof(info.Margin), new MarginPadding { Bottom = finalMargin }, transition_duration, Easing.In);
}
+
+ public class SongProgressDisplay : Container, ISkinnableComponent
+ {
+ // TODO: move actual implementation into this.
+ // exists for skin customisation purposes.
+ }
}
}
diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs
index 4069dc82ed..18615d9192 100644
--- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs
+++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs
@@ -11,7 +11,6 @@ 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.Utils;
using osu.Game.Beatmaps;
@@ -49,7 +48,9 @@ namespace osu.Game.Screens.Select
private IBindable beatmapDifficulty;
- protected BufferedWedgeInfo Info;
+ protected Container DisplayedContent { get; private set; }
+
+ protected WedgeInfoText Info { get; private set; }
public BeatmapInfoWedge()
{
@@ -110,9 +111,9 @@ namespace osu.Game.Screens.Select
}
}
- public override bool IsPresent => base.IsPresent || Info == null; // Visibility is updated in the LoadComponentAsync callback
+ public override bool IsPresent => base.IsPresent || DisplayedContent == null; // Visibility is updated in the LoadComponentAsync callback
- private BufferedWedgeInfo loadingInfo;
+ private Container loadingInfo;
private void updateDisplay()
{
@@ -124,9 +125,9 @@ namespace osu.Game.Screens.Select
{
State.Value = beatmap == null ? Visibility.Hidden : Visibility.Visible;
- Info?.FadeOut(250);
- Info?.Expire();
- Info = null;
+ DisplayedContent?.FadeOut(250);
+ DisplayedContent?.Expire();
+ DisplayedContent = null;
}
if (beatmap == null)
@@ -135,17 +136,23 @@ namespace osu.Game.Screens.Select
return;
}
- LoadComponentAsync(loadingInfo = new BufferedWedgeInfo(beatmap, ruleset.Value, mods.Value, beatmapDifficulty.Value ?? new StarDifficulty())
+ LoadComponentAsync(loadingInfo = new Container
{
+ RelativeSizeAxes = Axes.Both,
Shear = -Shear,
- Depth = Info?.Depth + 1 ?? 0
+ Depth = DisplayedContent?.Depth + 1 ?? 0,
+ Children = new Drawable[]
+ {
+ new BeatmapInfoWedgeBackground(beatmap),
+ Info = new WedgeInfoText(beatmap, ruleset.Value, mods.Value, beatmapDifficulty.Value ?? new StarDifficulty()),
+ }
}, loaded =>
{
// ensure we are the most recent loaded wedge.
if (loaded != loadingInfo) return;
removeOldInfo();
- Add(Info = loaded);
+ Add(DisplayedContent = loaded);
});
}
}
@@ -156,7 +163,7 @@ namespace osu.Game.Screens.Select
cancellationSource?.Cancel();
}
- public class BufferedWedgeInfo : BufferedContainer
+ public class WedgeInfoText : Container
{
public OsuSpriteText VersionLabel { get; private set; }
public OsuSpriteText TitleLabel { get; private set; }
@@ -176,8 +183,7 @@ namespace osu.Game.Screens.Select
private ModSettingChangeTracker settingChangeTracker;
- public BufferedWedgeInfo(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList mods, StarDifficulty difficulty)
- : base(pixelSnapping: true)
+ public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList mods, StarDifficulty difficulty)
{
this.beatmap = beatmap;
ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset;
@@ -191,7 +197,6 @@ namespace osu.Game.Screens.Select
var beatmapInfo = beatmap.BeatmapInfo;
var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata();
- CacheDrawnFrameBuffer = true;
RelativeSizeAxes = Axes.Both;
titleBinding = localisation.GetLocalisedString(new RomanisableString(metadata.TitleUnicode, metadata.Title));
@@ -199,32 +204,6 @@ namespace osu.Game.Screens.Select
Children = new Drawable[]
{
- // We will create the white-to-black gradient by modulating transparency and having
- // a black backdrop. This results in an sRGB-space gradient and not linear space,
- // transitioning from white to black more perceptually uniformly.
- new Box
- {
- RelativeSizeAxes = Axes.Both,
- Colour = Color4.Black,
- },
- // We use a container, such that we can set the colour gradient to go across the
- // vertices of the masked container instead of the vertices of the (larger) sprite.
- new Container
- {
- RelativeSizeAxes = Axes.Both,
- Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0.3f)),
- Children = new[]
- {
- // Zoomed-in and cropped beatmap background
- new BeatmapBackgroundSprite(beatmap)
- {
- RelativeSizeAxes = Axes.Both,
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- FillMode = FillMode.Fill,
- },
- },
- },
new DifficultyColourBar(starDifficulty)
{
RelativeSizeAxes = Axes.Y,
@@ -340,7 +319,6 @@ namespace osu.Game.Screens.Select
{
ArtistLabel.Text = artistBinding.Value;
TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value;
- ForceRedraw();
}
private void addInfoLabels()
@@ -426,8 +404,6 @@ namespace osu.Game.Screens.Select
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm),
Content = labelText
});
-
- ForceRedraw();
}
private OsuSpriteText[] getMapper(BeatmapMetadata metadata)
diff --git a/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs
new file mode 100644
index 0000000000..f50fb4dc8a
--- /dev/null
+++ b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs
@@ -0,0 +1,66 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osuTK.Graphics;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Colour;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Drawables;
+using osu.Framework.Graphics.Shapes;
+
+namespace osu.Game.Screens.Select
+{
+ internal class BeatmapInfoWedgeBackground : CompositeDrawable
+ {
+ private readonly WorkingBeatmap beatmap;
+
+ public BeatmapInfoWedgeBackground(WorkingBeatmap beatmap)
+ {
+ this.beatmap = beatmap;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ RelativeSizeAxes = Axes.Both;
+
+ InternalChild = new BufferedContainer
+ {
+ CacheDrawnFrameBuffer = true,
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ // We will create the white-to-black gradient by modulating transparency and having
+ // a black backdrop. This results in an sRGB-space gradient and not linear space,
+ // transitioning from white to black more perceptually uniformly.
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4.Black,
+ },
+ // We use a container, such that we can set the colour gradient to go across the
+ // vertices of the masked container instead of the vertices of the (larger) sprite.
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0.3f)),
+ Children = new[]
+ {
+ // Zoomed-in and cropped beatmap background
+ new BeatmapBackgroundSprite(beatmap)
+ {
+ RelativeSizeAxes = Axes.Both,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ FillMode = FillMode.Fill,
+ },
+ },
+ },
+ }
+ };
+ }
+ }
+}
diff --git a/osu.Game/Skinning/Editor/SkinBlueprint.cs b/osu.Game/Skinning/Editor/SkinBlueprint.cs
new file mode 100644
index 0000000000..11409c46ab
--- /dev/null
+++ b/osu.Game/Skinning/Editor/SkinBlueprint.cs
@@ -0,0 +1,78 @@
+// 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.Containers;
+using osu.Framework.Graphics.Primitives;
+using osu.Framework.Graphics.Shapes;
+using osu.Game.Graphics;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Screens.Play.HUD;
+using osuTK;
+
+namespace osu.Game.Skinning.Editor
+{
+ public class SkinBlueprint : SelectionBlueprint
+ {
+ private Container box;
+
+ private Drawable drawable => (Drawable)Item;
+
+ ///
+ /// Whether the blueprint should be shown even when the is not alive.
+ ///
+ protected virtual bool AlwaysShowWhenSelected => false;
+
+ protected override bool ShouldBeAlive => (drawable.IsAlive && Item.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected);
+
+ public SkinBlueprint(ISkinnableComponent component)
+ : base(component)
+ {
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ InternalChildren = new Drawable[]
+ {
+ box = new Container
+ {
+ Colour = colours.Yellow,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Alpha = 0.2f,
+ AlwaysPresent = true,
+ },
+ }
+ },
+ };
+ }
+
+ private Quad drawableQuad;
+
+ public override Quad ScreenSpaceDrawQuad => drawableQuad;
+
+ protected override void Update()
+ {
+ base.Update();
+
+ drawableQuad = drawable.ScreenSpaceDrawQuad;
+ var quad = ToLocalSpace(drawable.ScreenSpaceDrawQuad);
+
+ box.Position = quad.TopLeft;
+ box.Size = quad.Size;
+ box.Rotation = drawable.Rotation;
+ }
+
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => drawable.ReceivePositionalInputAt(screenSpacePos);
+
+ public override Vector2 ScreenSpaceSelectionPoint => drawable.ScreenSpaceDrawQuad.Centre;
+
+ public override Quad SelectionQuad => drawable.ScreenSpaceDrawQuad;
+ }
+}
diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs
new file mode 100644
index 0000000000..d9bfefe5f2
--- /dev/null
+++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs
@@ -0,0 +1,43 @@
+// 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.Testing;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Screens.Edit.Compose.Components;
+using osu.Game.Screens.Play.HUD;
+
+namespace osu.Game.Skinning.Editor
+{
+ public class SkinBlueprintContainer : BlueprintContainer
+ {
+ private readonly Drawable target;
+
+ public SkinBlueprintContainer(Drawable target)
+ {
+ this.target = target;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ checkForComponents();
+ }
+
+ private void checkForComponents()
+ {
+ foreach (var c in target.ChildrenOfType().ToArray()) AddBlueprintFor(c);
+
+ // We'd hope to eventually be running this in a more sensible way, but this handles situations where new drawables become present (ie. during ongoing gameplay)
+ // or when drawables in the target are loaded asynchronously and may not be immediately available when this BlueprintContainer is loaded.
+ Scheduler.AddDelayed(checkForComponents, 1000);
+ }
+
+ protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler();
+
+ protected override SelectionBlueprint CreateBlueprintFor(ISkinnableComponent component)
+ => new SkinBlueprint(component);
+ }
+}
diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
new file mode 100644
index 0000000000..562dd23224
--- /dev/null
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -0,0 +1,79 @@
+// 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.Containers;
+using osu.Framework.Input.Events;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Cursor;
+
+namespace osu.Game.Skinning.Editor
+{
+ public class SkinEditor : FocusedOverlayContainer
+ {
+ public const double TRANSITION_DURATION = 500;
+
+ private readonly Drawable target;
+
+ private OsuTextFlowContainer headerText;
+
+ protected override bool StartHidden => true;
+
+ public SkinEditor(Drawable target)
+ {
+ this.target = target;
+
+ RelativeSizeAxes = Axes.Both;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ InternalChild = new OsuContextMenuContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ headerText = new OsuTextFlowContainer
+ {
+ TextAnchor = Anchor.TopCentre,
+ Padding = new MarginPadding(20),
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ RelativeSizeAxes = Axes.X
+ },
+ new SkinBlueprintContainer(target),
+ }
+ };
+
+ headerText.AddParagraph("Skin editor (preview)", cp => cp.Font = OsuFont.Default.With(size: 24));
+ headerText.AddParagraph("This is a preview of what is to come. Changes are lost on changing screens.", cp =>
+ {
+ cp.Font = OsuFont.Default.With(size: 12);
+ cp.Colour = colours.Yellow;
+ });
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ Show();
+ }
+
+ protected override bool OnHover(HoverEvent e) => true;
+
+ protected override bool OnMouseDown(MouseDownEvent e) => true;
+
+ protected override void PopIn()
+ {
+ this.FadeIn(TRANSITION_DURATION, Easing.OutQuint);
+ }
+
+ protected override void PopOut()
+ {
+ this.FadeOut(TRANSITION_DURATION, Easing.OutQuint);
+ }
+ }
+}
diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
new file mode 100644
index 0000000000..06c6dffb60
--- /dev/null
+++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
@@ -0,0 +1,97 @@
+// 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.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Input.Bindings;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Input.Bindings;
+
+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
+ {
+ private readonly ScalingContainer target;
+ private SkinEditor skinEditor;
+
+ private const float visible_target_scale = 0.8f;
+
+ [Resolved]
+ private OsuColour colours { get; set; }
+
+ public SkinEditorOverlay(ScalingContainer target)
+ {
+ this.target = target;
+ RelativeSizeAxes = Axes.Both;
+ }
+
+ public bool OnPressed(GlobalAction action)
+ {
+ switch (action)
+ {
+ case GlobalAction.Back:
+ if (skinEditor?.State.Value == Visibility.Visible)
+ {
+ skinEditor.ToggleVisibility();
+ return true;
+ }
+
+ break;
+
+ case GlobalAction.ToggleSkinEditor:
+ if (skinEditor == null)
+ {
+ LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal);
+ skinEditor.State.BindValueChanged(editorVisibilityChanged);
+ }
+ else
+ skinEditor.ToggleVisibility();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private void editorVisibilityChanged(ValueChangedEvent visibility)
+ {
+ if (visibility.NewValue == Visibility.Visible)
+ {
+ target.ScaleTo(visible_target_scale, SkinEditor.TRANSITION_DURATION, Easing.OutQuint);
+
+ target.Masking = true;
+ target.BorderThickness = 5;
+ target.BorderColour = colours.Yellow;
+ target.AllowScaling = false;
+ }
+ else
+ {
+ target.BorderThickness = 0;
+ target.AllowScaling = true;
+
+ target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => target.Masking = false);
+ }
+ }
+
+ public void OnReleased(GlobalAction action)
+ {
+ }
+
+ ///
+ /// Exit any existing skin editor due to the game state changing.
+ ///
+ public void Reset()
+ {
+ skinEditor?.Hide();
+ skinEditor?.Expire();
+ skinEditor = null;
+ }
+ }
+}
diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
new file mode 100644
index 0000000000..d09ba8af0e
--- /dev/null
+++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
@@ -0,0 +1,132 @@
+// 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.Graphics;
+using osu.Framework.Graphics.UserInterface;
+using osu.Game.Extensions;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Screens.Edit.Compose.Components;
+using osu.Game.Screens.Play.HUD;
+using osuTK;
+
+namespace osu.Game.Skinning.Editor
+{
+ public class SkinSelectionHandler : SelectionHandler
+ {
+ public override bool HandleRotation(float angle)
+ {
+ // TODO: this doesn't correctly account for origin/anchor specs being different in a multi-selection.
+ foreach (var c in SelectedBlueprints)
+ ((Drawable)c.Item).Rotation += angle;
+
+ return base.HandleRotation(angle);
+ }
+
+ public override bool HandleScale(Vector2 scale, Anchor anchor)
+ {
+ adjustScaleFromAnchor(ref scale, anchor);
+
+ foreach (var c in SelectedBlueprints)
+ // TODO: this is temporary and will be fixed with a separate refactor of selection transform logic.
+ ((Drawable)c.Item).Scale += scale * 0.02f;
+
+ return true;
+ }
+
+ public override bool HandleMovement(MoveSelectionEvent moveEvent)
+ {
+ foreach (var c in SelectedBlueprints)
+ {
+ Drawable drawable = (Drawable)c.Item;
+ drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta);
+ }
+
+ return true;
+ }
+
+ protected override void OnSelectionChanged()
+ {
+ base.OnSelectionChanged();
+
+ SelectionBox.CanRotate = true;
+ SelectionBox.CanScaleX = true;
+ SelectionBox.CanScaleY = true;
+ SelectionBox.CanReverse = false;
+ }
+
+ protected override void DeleteItems(IEnumerable items)
+ {
+ foreach (var i in items)
+ {
+ ((Drawable)i).Expire();
+ SelectedItems.Remove(i);
+ }
+ }
+
+ protected override IEnumerable