1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-15 05:02:55 +08:00

Merge branch 'skin-components-bind-outwards' into skin-components-bind-outwards-score-display

This commit is contained in:
Dean Herbert 2021-05-07 16:30:08 +09:00
commit 01eff7f316
51 changed files with 1249 additions and 191 deletions

View File

@ -52,6 +52,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.427.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.506.0" />
</ItemGroup>
</Project>

View File

@ -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()
{

View File

@ -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<T>.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<TestReplayFrame>()
.Where(f => f.Time == repeating_time)
.Select(f => f.FrameIndex);
Assert.That(repeatingTimeFramesData, Is.Ordered.Ascending);
}
private void setReplayFrames()
{
replay.Frames = new List<ReplayFrame>
@ -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;
}
}

View File

@ -132,8 +132,8 @@ namespace osu.Game.Tests.Visual.Editing
{
AddStep("deselect", () => EditorBeatmap.SelectedHitObjects.Clear());
AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType<Timeline>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha == 0);
AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType<HitObjectComposer>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha == 0);
AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType<Timeline>().First().ChildrenOfType<SelectionBox>().First().Alpha == 0);
AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType<HitObjectComposer>().First().ChildrenOfType<SelectionBox>().First().Alpha == 0);
}
AddStep("paste hitobject", () => Editor.Paste());

View File

@ -0,0 +1,36 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using 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();
}
}

View File

@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using 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<Mod>())
{
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();
}
}

View File

@ -0,0 +1,191 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.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<HealthProcessor, JudgementResult, bool> currentFailConditions;
[SetUpSteps]
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true));
AddStep("set dim level to 0", () => LocalConfig.SetValue<double>(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<SkipOverlay.FadeContainer>().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<HealthProcessor, JudgementResult, bool> failConditions;
public OutroPlayer(Func<HealthProcessor, JudgementResult, bool> 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;
}
}
}
}

View File

@ -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<BeatmapInfoWedge.BufferedWedgeInfo.InfoLabel>().Any());
AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType<BeatmapInfoWedge.BufferedWedgeInfo.InfoLabel>().Count() == expectedCount);
AddAssert("check info labels exists", () => infoWedge.Info.ChildrenOfType<BeatmapInfoWedge.WedgeInfoText.InfoLabel>().Any());
AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType<BeatmapInfoWedge.WedgeInfoText.InfoLabel>().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<BeatmapInfoWedge.BufferedWedgeInfo.InfoLabel>().Any());
AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType<BeatmapInfoWedge.WedgeInfoText.InfoLabel>().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

View File

@ -28,7 +28,7 @@ namespace osu.Game.Graphics.Containers
protected override bool BlockNonPositionalInput => true;
/// <summary>
/// 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?).
/// </summary>
protected virtual bool DimMainContent => true;

View File

@ -36,6 +36,24 @@ namespace osu.Game.Graphics.Containers
private BackgroundScreenStack backgroundStack;
private bool allowScaling = true;
/// <summary>
/// Whether user scaling preferences should be applied. Enabled by default.
/// </summary>
public bool AllowScaling
{
get => allowScaling;
set
{
if (value == allowScaling)
return;
allowScaling = value;
if (IsLoaded) updateSize();
}
}
/// <summary>
/// Create a new instance.
/// </summary>
@ -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;

View File

@ -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,
}
}

View File

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

View File

@ -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) =>
{

View File

@ -13,6 +13,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
{
protected override string Header => "Renderer";
private SettingsEnumDropdown<FrameSync> 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<FrameSync>
frameLimiterDropdown = new SettingsEnumDropdown<FrameSync>
{
LabelText = "Frame limiter",
Current = config.GetBindable<FrameSync>(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);
}
}
}

View File

@ -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> user;
private SettingsEnumDropdown<BackgroundSource> 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<IntroSequence>(OsuSetting.IntroSequence),
},
new SettingsEnumDropdown<BackgroundSource>
backgroundSourceDropdown = new SettingsEnumDropdown<BackgroundSource>
{
LabelText = "Background source",
Current = config.GetBindable<BackgroundSource>(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);
}
}
}

View File

@ -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
}
}
/// <summary>
/// Text to be displayed at the bottom of this <see cref="SettingsItem{T}"/>.
/// Generally used to recommend the user change their setting as the current one is considered sub-optimal.
/// </summary>
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<T> 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()

View File

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

View File

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

View File

@ -108,17 +108,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
}
/// <summary>
/// Given a selection target and a function of truth, retrieve the correct ternary state for display.
/// </summary>
protected TernaryState GetStateFromSelection<T>(IEnumerable<T> selection, Func<T, bool> func)
{
if (selection.Any(func))
return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
return TernaryState.False;
}
#endregion
#region Ternary state changes

View File

@ -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<float, bool> OnRotation;
public Func<Vector2, Anchor, bool> OnScale;
public Func<Direction, bool> 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,

View File

@ -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<SelectionBlueprint<T>> 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();
}
/// <summary>
/// Given a selection target and a function of truth, retrieve the correct ternary state for display.
/// </summary>
protected static TernaryState GetStateFromSelection<TObject>(IEnumerable<TObject> selection, Func<TObject, bool> func)
{
if (selection.Any(func))
return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
return TernaryState.False;
}
/// <summary>
/// Called whenever the deletion of items has been requested.
/// </summary>
@ -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

View File

@ -48,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
AllowPause = false,
AllowRestart = false,
AllowSkippingIntro = false,
AllowSkipping = false,
})
{
this.userIds = userIds;

View File

@ -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;
/// <summary>
/// Whether the storyboard is considered finished.
/// </summary>
/// <remarks>
/// This is true by default in here, until an actual drawable storyboard is loaded, in which case it'll bind to it.
/// </remarks>
public IBindable<bool> 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);

View File

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

View File

@ -12,7 +12,7 @@ using osuTK;
namespace osu.Game.Screens.Play.HUD
{
public class DefaultComboCounter : RollingCounter<int>
public class DefaultComboCounter : RollingCounter<int>, ISkinnableComponent
{
private readonly Vector2 offset = new Vector2(20, 5);

View File

@ -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
{
/// <summary>
/// The base opacity of the glow.

View File

@ -7,7 +7,7 @@ using osu.Game.Graphics;
namespace osu.Game.Screens.Play.HUD
{
public class DefaultScoreCounter : GameplayScoreCounter
public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableComponent
{
public DefaultScoreCounter()
: base(6)

View File

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

View File

@ -0,0 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
namespace osu.Game.Screens.Play.HUD
{
/// <summary>
/// Denotes a drawable which, as a drawable, can be adjusted via skinning specifications.
/// </summary>
public interface ISkinnableComponent : IDrawable
{
}
}

View File

@ -15,7 +15,7 @@ namespace osu.Game.Screens.Play.HUD
/// <summary>
/// Uses the 'x' symbol and has a pop-out effect while rolling over.
/// </summary>
public class LegacyComboCounter : CompositeDrawable
public class LegacyComboCounter : CompositeDrawable, ISkinnableComponent
{
public Bindable<int> Current { get; } = new BindableInt { MinValue = 0, };

View File

@ -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<IApplicableToScoreProcessor>())
@ -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<ScoreInfo> prepareScoreForDisplayTask;
private void updateCompletionState(ValueChangedEvent<bool> completionState)
/// <summary>
/// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime.
/// </summary>
/// <param name="skipStoryboardOutro">If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it.</param>
/// <exception cref="InvalidOperationException">Thrown if this method is called more than once without changing state.</exception>
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();
}

View File

@ -21,8 +21,8 @@ namespace osu.Game.Screens.Play
public bool AllowRestart { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public bool AllowSkippingIntro { get; set; } = true;
public bool AllowSkipping { get; set; } = true;
}
}

View File

@ -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<Visibility>
public class FadeContainer : Container, IStateful<Visibility>
{
public event Action<Visibility> 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);

View File

@ -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.
}
}
}

View File

@ -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<StarDifficulty?> 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<Mod> mods, StarDifficulty difficulty)
: base(pixelSnapping: true)
public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList<Mod> 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)

View File

@ -0,0 +1,66 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osuTK.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,
},
},
},
}
};
}
}
}

View File

@ -0,0 +1,78 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.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<ISkinnableComponent>
{
private Container box;
private Drawable drawable => (Drawable)Item;
/// <summary>
/// Whether the blueprint should be shown even when the <see cref="SelectionBlueprint{T}.Item"/> is not alive.
/// </summary>
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;
}
}

View File

@ -0,0 +1,43 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.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<ISkinnableComponent>
{
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<ISkinnableComponent>().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<ISkinnableComponent> CreateSelectionHandler() => new SkinSelectionHandler();
protected override SelectionBlueprint<ISkinnableComponent> CreateBlueprintFor(ISkinnableComponent component)
=> new SkinBlueprint(component);
}
}

View File

@ -0,0 +1,79 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.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);
}
}
}

View File

@ -0,0 +1,97 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.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
{
/// <summary>
/// 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.
/// </summary>
public class SkinEditorOverlay : CompositeDrawable, IKeyBindingHandler<GlobalAction>
{
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> 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)
{
}
/// <summary>
/// Exit any existing skin editor due to the game state changing.
/// </summary>
public void Reset()
{
skinEditor?.Hide();
skinEditor?.Expire();
skinEditor = null;
}
}
}

View File

@ -0,0 +1,132 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using 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<ISkinnableComponent>
{
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<ISkinnableComponent> 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<ISkinnableComponent> items)
{
foreach (var i in items)
{
((Drawable)i).Expire();
SelectedItems.Remove(i);
}
}
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<ISkinnableComponent>> selection)
{
yield return new OsuMenuItem("Anchor")
{
Items = createAnchorItems().ToArray()
};
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;
IEnumerable<AnchorMenuItem> createAnchorItems()
{
var displayableAnchors = new[]
{
Anchor.TopLeft,
Anchor.TopCentre,
Anchor.TopRight,
Anchor.CentreLeft,
Anchor.Centre,
Anchor.CentreRight,
Anchor.BottomLeft,
Anchor.BottomCentre,
Anchor.BottomRight,
};
return displayableAnchors.Select(a =>
{
return new AnchorMenuItem(a, selection, _ => applyAnchor(a))
{
State = { Value = GetStateFromSelection(selection, c => ((Drawable)c.Item).Anchor == a) }
};
});
}
}
private void applyAnchor(Anchor anchor)
{
foreach (var item in SelectedItems)
((Drawable)item).Anchor = anchor;
}
private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference)
{
// cancel out scale in axes we don't care about (based on which drag handle was used).
if ((reference & Anchor.x1) > 0) scale.X = 0;
if ((reference & Anchor.y1) > 0) scale.Y = 0;
// reverse the scale direction if dragging from top or left.
if ((reference & Anchor.x0) > 0) scale.X = -scale.X;
if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y;
}
public class AnchorMenuItem : TernaryStateMenuItem
{
public AnchorMenuItem(Anchor anchor, IEnumerable<SelectionBlueprint<ISkinnableComponent>> selection, Action<TernaryState> action)
: base(anchor.ToString(), getNextState, MenuItemType.Standard, action)
{
}
private static TernaryState getNextState(TernaryState state) => TernaryState.True;
}
}
}

View File

@ -11,7 +11,7 @@ using osuTK;
namespace osu.Game.Skinning
{
public class LegacyAccuracyCounter : PercentageCounter, IAccuracyCounter
public class LegacyAccuracyCounter : PercentageCounter, IAccuracyCounter, ISkinnableComponent
{
private readonly ISkin skin;

View File

@ -16,7 +16,7 @@ using osuTK.Graphics;
namespace osu.Game.Skinning
{
public class LegacyHealthDisplay : CompositeDrawable, IHealthDisplay
public class LegacyHealthDisplay : CompositeDrawable, IHealthDisplay, ISkinnableComponent
{
private const double epic_cutoff = 0.5;

View File

@ -9,7 +9,7 @@ using osuTK;
namespace osu.Game.Skinning
{
public class LegacyScoreCounter : GameplayScoreCounter
public class LegacyScoreCounter : GameplayScoreCounter, ISkinnableComponent
{
private readonly ISkin skin;

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using osuTK;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Textures;
@ -19,6 +20,13 @@ namespace osu.Game.Storyboards.Drawables
[Cached]
public Storyboard Storyboard { get; }
/// <summary>
/// Whether the storyboard is considered finished.
/// </summary>
public IBindable<bool> HasStoryboardEnded => hasStoryboardEnded;
private readonly BindableBool hasStoryboardEnded = new BindableBool();
protected override Container<DrawableStoryboardLayer> Content { get; }
protected override Vector2 DrawScale => new Vector2(Parent.DrawHeight / 480);
@ -39,6 +47,8 @@ namespace osu.Game.Storyboards.Drawables
public override bool RemoveCompletedTransforms => false;
private double? lastEventEndTime;
private DependencyContainer dependencies;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
@ -73,6 +83,14 @@ namespace osu.Game.Storyboards.Drawables
Add(layer.CreateDrawable());
}
lastEventEndTime = Storyboard.LatestEventTime;
}
protected override void Update()
{
base.Update();
hasStoryboardEnded.Value = lastEventEndTime == null || Time.Current >= lastEventEndTime;
}
public DrawableStoryboardLayer OverlayLayer => Children.Single(layer => layer.Name == "Overlay");

View File

@ -14,4 +14,17 @@ namespace osu.Game.Storyboards
Drawable CreateDrawable();
}
public static class StoryboardElementExtensions
{
/// <summary>
/// Returns the end time of this storyboard element.
/// </summary>
/// <remarks>
/// This returns the <see cref="IStoryboardElementWithDuration.EndTime"/> where available, falling back to <see cref="IStoryboardElement.StartTime"/> otherwise.
/// </remarks>
/// <param name="element">The storyboard element.</param>
/// <returns>The end time of this element.</returns>
public static double GetEndTime(this IStoryboardElement element) => (element as IStoryboardElementWithDuration)?.EndTime ?? element.StartTime;
}
}

View File

@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Storyboards
{
/// <summary>
/// A <see cref="IStoryboardElement"/> that ends at a different time than its start time.
/// </summary>
public interface IStoryboardElementWithDuration : IStoryboardElement
{
/// <summary>
/// The time at which the <see cref="IStoryboardElement"/> ends.
/// </summary>
double EndTime { get; }
/// <summary>
/// The duration of the StoryboardElement.
/// </summary>
double Duration => EndTime - StartTime;
}
}

View File

@ -36,6 +36,16 @@ namespace osu.Game.Storyboards
/// </remarks>
public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.StartTime).FirstOrDefault()?.StartTime;
/// <summary>
/// Across all layers, find the latest point in time that a storyboard element ends at.
/// Will return null if there are no elements.
/// </summary>
/// <remarks>
/// This iterates all elements and as such should be used sparingly or stored locally.
/// Videos and samples return StartTime as their EndTIme.
/// </remarks>
public double? LatestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.GetEndTime()).LastOrDefault()?.GetEndTime();
/// <summary>
/// Depth of the currently front-most storyboard layer, excluding the overlay layer.
/// </summary>

View File

@ -11,7 +11,7 @@ using JetBrains.Annotations;
namespace osu.Game.Storyboards
{
public class StoryboardSprite : IStoryboardElement
public class StoryboardSprite : IStoryboardElementWithDuration
{
private readonly List<CommandLoop> loops = new List<CommandLoop>();
private readonly List<CommandTrigger> triggers = new List<CommandTrigger>();

View File

@ -29,7 +29,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.NETCore.Targets" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.427.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.506.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" />
<PackageReference Include="Sentry" Version="3.3.4" />
<PackageReference Include="SharpCompress" Version="0.28.2" />

View File

@ -70,7 +70,7 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.427.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.506.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@ -93,7 +93,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="ppy.osu.Framework" Version="2021.427.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.506.0" />
<PackageReference Include="SharpCompress" Version="0.28.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="SharpRaven" Version="2.4.0" />